Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48d25b668d | |||
| 6eb3dfd8f9 | |||
| 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 | |||
| ab24f99b90 | |||
| c6755b586b | |||
| 2f38889726 | |||
| 95bf49509c | |||
| d033b20dfb | |||
| c77610786b | |||
| 0c27982098 | |||
| 461eca9a0a | |||
| 620b12d4b6 | |||
| 78244c3197 |
@@ -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
|
||||||
@@ -29,29 +37,36 @@ jobs:
|
|||||||
SHA=$(git rev-parse --short HEAD)
|
SHA=$(git rev-parse --short HEAD)
|
||||||
FULL_SHA=$(git rev-parse HEAD)
|
FULL_SHA=$(git rev-parse HEAD)
|
||||||
IMAGE="git.kubebase.de/${{ gitea.repository }}"
|
IMAGE="git.kubebase.de/${{ gitea.repository }}"
|
||||||
if [ "${{ gitea.ref_type }}" = "tag" ]; then VERSION="${{ gitea.ref_name }}"; else VERSION=""; fi
|
DATE_STAMP=$(date -u +"%Y%m%d")
|
||||||
TAGS="-t ${IMAGE}:sha-${SHA} -t ${IMAGE}:latest"
|
VERSION="dev-$(date -u +"%Y%m%d-%H%M")"
|
||||||
if [ -n "$VERSION" ]; then TAGS="${TAGS} -t ${IMAGE}:${VERSION}"; fi
|
TAGS="-t ${IMAGE}:latest"
|
||||||
|
if [ "${{ gitea.ref_type }}" = "tag" ]; then
|
||||||
|
VERSION="${{ gitea.ref_name }}"
|
||||||
|
TAGS="${TAGS} -t ${IMAGE}:${VERSION}"
|
||||||
|
fi
|
||||||
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"
|
||||||
if [ -n "$VERSION" ]; then docker push "${IMAGE}:${VERSION}"; fi
|
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"],
|
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", "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;
|
||||||
|
|||||||
@@ -104,7 +104,13 @@ router.get("/analytics/top-tools", async (req, res): Promise<void> => {
|
|||||||
? (Number(avgUsefulness) + Number(avgUsability)) / 2
|
? (Number(avgUsefulness) + Number(avgUsability)) / 2
|
||||||
: null;
|
: null;
|
||||||
return {
|
return {
|
||||||
tool: { ...tool, ratingCount, avgUsefulness, avgUsability, avgCombined },
|
tool: {
|
||||||
|
...tool,
|
||||||
|
ratingCount,
|
||||||
|
avgUsefulness: avgUsefulness != null ? Number(avgUsefulness) : null,
|
||||||
|
avgUsability: avgUsability != null ? Number(avgUsability) : null,
|
||||||
|
avgCombined,
|
||||||
|
},
|
||||||
score: Number(score),
|
score: Number(score),
|
||||||
ratingCount,
|
ratingCount,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +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 } from "drizzle-orm";
|
import { eq, and, inArray, isNull } from "drizzle-orm";
|
||||||
import { db, usersTable } from "@workspace/db";
|
import { z } from "zod";
|
||||||
|
import { db, usersTable, toolsTable, ratingsTable } from "@workspace/db";
|
||||||
import { logger } from "../lib/logger";
|
import { logger } from "../lib/logger";
|
||||||
import { getEntitlements } from "../middleware/feature";
|
import { writeAuditLog } from "../lib/audit";
|
||||||
|
import { loginRateLimit, passwordRateLimit } from "../lib/rate-limit";
|
||||||
|
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 !!(
|
||||||
@@ -35,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;
|
||||||
@@ -48,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;
|
||||||
@@ -106,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;
|
||||||
@@ -168,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;
|
||||||
@@ -179,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",
|
||||||
@@ -187,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> => {
|
||||||
@@ -213,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) => {
|
||||||
@@ -251,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,4 +292,154 @@ 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"]>;
|
||||||
|
|
||||||
|
async function resolveDbUser(u: SessionUser) {
|
||||||
|
if (u.isLocal) {
|
||||||
|
const id = Number(u.sub);
|
||||||
|
if (Number.isFinite(id)) {
|
||||||
|
const [byId] = await db.select().from(usersTable).where(eq(usersTable.id, id)).limit(1);
|
||||||
|
if (byId) return byId;
|
||||||
|
}
|
||||||
|
if (u.preferred_username) {
|
||||||
|
const [byName] = await db.select().from(usersTable).where(eq(usersTable.username, u.preferred_username)).limit(1);
|
||||||
|
if (byName) return byName;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const [byProvider] = await db.select().from(usersTable).where(eq(usersTable.authProviderId, u.sub)).limit(1);
|
||||||
|
return byProvider ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PreferenceSchema = z.object({
|
||||||
|
view: z.enum(["grid", "table", "rows"]).optional(),
|
||||||
|
density: z.enum(["cozy", "compact"]).optional(),
|
||||||
|
watchlist: z.array(z.number().int().positive()).max(50).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/auth/me/preferences", 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;
|
||||||
|
}
|
||||||
|
res.json(dbUser.preferences ?? {});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/auth/me/watchlist", requireFeature("watchlist"), async (req, res): Promise<void> => {
|
||||||
|
const dbUser = await resolveDbUser(req.session.user!);
|
||||||
|
if (!dbUser) {
|
||||||
|
res.status(401).json({ error: "User not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ids = (dbUser.preferences?.watchlist ?? []).filter((id) => Number.isInteger(id));
|
||||||
|
if (ids.length === 0) {
|
||||||
|
res.json([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tools = await db
|
||||||
|
.select()
|
||||||
|
.from(toolsTable)
|
||||||
|
.where(and(isNull(toolsTable.deletedAt), inArray(toolsTable.id, ids)));
|
||||||
|
const ratings = await db
|
||||||
|
.select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
|
||||||
|
.from(ratingsTable)
|
||||||
|
.where(inArray(ratingsTable.toolId, tools.map((t) => t.id)));
|
||||||
|
const byTool = new Map<number, { usefulness: number; usability: number }[]>();
|
||||||
|
for (const r of ratings) {
|
||||||
|
const list = byTool.get(r.toolId) ?? [];
|
||||||
|
list.push({ usefulness: r.usefulness, usability: r.usability });
|
||||||
|
byTool.set(r.toolId, list);
|
||||||
|
}
|
||||||
|
const byId = new Map(tools.map((t) => [t.id, t]));
|
||||||
|
const ordered = ids
|
||||||
|
.filter((id) => byId.has(id))
|
||||||
|
.map((id) => {
|
||||||
|
const tool = byId.get(id)!;
|
||||||
|
const toolRatings = byTool.get(id) ?? [];
|
||||||
|
const ratingCount = toolRatings.length;
|
||||||
|
const avgUsefulness = ratingCount > 0 ? toolRatings.reduce((s, r) => s + r.usefulness, 0) / ratingCount : null;
|
||||||
|
const avgUsability = ratingCount > 0 ? toolRatings.reduce((s, r) => s + r.usability, 0) / ratingCount : null;
|
||||||
|
const avgCombined = avgUsefulness != null && avgUsability != null ? (avgUsefulness + avgUsability) / 2 : null;
|
||||||
|
return { ...tool, ratingCount, avgUsefulness, avgUsability, avgCombined };
|
||||||
|
});
|
||||||
|
res.json(ordered);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/auth/me/preferences", async (req, res): Promise<void> => {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ error: "Not authenticated" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = PreferenceSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dbUser = await resolveDbUser(req.session.user);
|
||||||
|
if (!dbUser) {
|
||||||
|
res.status(401).json({ error: "User not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const merged = { ...(dbUser.preferences ?? {}), ...parsed.data };
|
||||||
|
await db.update(usersTable).set({ preferences: merged }).where(eq(usersTable.id, dbUser.id));
|
||||||
|
res.json(merged);
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -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, 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 {
|
||||||
@@ -52,16 +52,27 @@ router.get("/tools", async (req, res): Promise<void> => {
|
|||||||
res.status(400).json({ error: parsed.error.message });
|
res.status(400).json({ error: parsed.error.message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { category, search, sort } = parsed.data;
|
const { category, search, sort, tags, features, minRating } = parsed.data;
|
||||||
|
|
||||||
let query = db.select().from(toolsTable).where(isNull(toolsTable.deletedAt)).$dynamic();
|
const tagList = (tags ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
||||||
|
const featureList = (features ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
conditions.push(sql`${toolsTable.tags} @> ARRAY[${sql.join(tagList.map((t) => sql`${t}`), sql`, `)}]::text[]`);
|
||||||
|
}
|
||||||
|
if (featureList.length > 0) {
|
||||||
|
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));
|
||||||
|
|
||||||
@@ -87,6 +98,16 @@ router.get("/tools", async (req, res): Promise<void> => {
|
|||||||
result = result.sort((a, b) => (b.avgCombined ?? 0) - (a.avgCombined ?? 0));
|
result = result.sort((a, b) => (b.avgCombined ?? 0) - (a.avgCombined ?? 0));
|
||||||
} else if (sort === "most_reviewed") {
|
} else if (sort === "most_reviewed") {
|
||||||
result = result.sort((a, b) => b.ratingCount - a.ratingCount);
|
result = result.sort((a, b) => b.ratingCount - a.ratingCount);
|
||||||
|
} else if (sort === "name_asc") {
|
||||||
|
result = result.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
|
||||||
|
} else if (sort === "name_desc") {
|
||||||
|
result = result.sort((a, b) => b.name.localeCompare(a.name, undefined, { sensitivity: "base" }));
|
||||||
|
} else if (sort === "recently_updated") {
|
||||||
|
result = result.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minRating != null) {
|
||||||
|
result = result.filter((t) => (t.avgCombined ?? 0) >= minRating);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(result);
|
res.json(result);
|
||||||
@@ -213,6 +234,65 @@ router.get("/tools/:id", async (req, res): Promise<void> => {
|
|||||||
res.json(buildToolWithStats(tool, ratings));
|
res.json(buildToolWithStats(tool, ratings));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/tools/:id/rating-history", async (req, res): Promise<void> => {
|
||||||
|
const params = GetToolParams.safeParse(req.params);
|
||||||
|
if (!params.success) {
|
||||||
|
res.status(400).json({ error: params.error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ratings = await db
|
||||||
|
.select({
|
||||||
|
date: ratingsTable.createdAt,
|
||||||
|
usefulness: ratingsTable.usefulness,
|
||||||
|
usability: ratingsTable.usability,
|
||||||
|
})
|
||||||
|
.from(ratingsTable)
|
||||||
|
.where(eq(ratingsTable.toolId, params.data.id))
|
||||||
|
.orderBy(asc(ratingsTable.createdAt));
|
||||||
|
res.json(
|
||||||
|
ratings.map((r) => ({
|
||||||
|
date: r.date,
|
||||||
|
usefulness: r.usefulness,
|
||||||
|
usability: r.usability,
|
||||||
|
combined: (r.usefulness + r.usability) / 2,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/compare", requireFeature("compare"), async (req, res): Promise<void> => {
|
||||||
|
const ids = String(req.query.ids ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((s) => Number(s.trim()))
|
||||||
|
.filter((n) => Number.isInteger(n) && n > 0);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
res.status(400).json({ error: "Provide at least one ids value, e.g. ?ids=1,2,3" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ids.length > 8) {
|
||||||
|
res.status(400).json({ error: "Maximum of 8 tools can be compared" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tools = await db.select().from(toolsTable).where(and(isNull(toolsTable.deletedAt), inArray(toolsTable.id, ids)));
|
||||||
|
if (tools.length === 0) {
|
||||||
|
res.status(404).json({ error: "No tools found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toolIds = tools.map((t) => t.id);
|
||||||
|
const ratings = await db
|
||||||
|
.select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
|
||||||
|
.from(ratingsTable)
|
||||||
|
.where(inArray(ratingsTable.toolId, toolIds));
|
||||||
|
const byTool = new Map<number, { usefulness: number; usability: number }[]>();
|
||||||
|
for (const r of ratings) {
|
||||||
|
const list = byTool.get(r.toolId) ?? [];
|
||||||
|
list.push({ usefulness: r.usefulness, usability: r.usability });
|
||||||
|
byTool.set(r.toolId, list);
|
||||||
|
}
|
||||||
|
const byId = new Map(tools.map((t) => [t.id, t]));
|
||||||
|
const ordered = ids.filter((id) => byId.has(id)).map((id) => buildToolWithStats(byId.get(id)!, byTool.get(id) ?? []));
|
||||||
|
res.json(ordered);
|
||||||
|
});
|
||||||
|
|
||||||
router.patch("/tools/:id", requireAuth, async (req, res): Promise<void> => {
|
router.patch("/tools/:id", requireAuth, async (req, res): Promise<void> => {
|
||||||
const params = UpdateToolParams.safeParse(req.params);
|
const params = UpdateToolParams.safeParse(req.params);
|
||||||
if (!params.success) {
|
if (!params.success) {
|
||||||
|
|||||||
@@ -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,46 +4,47 @@
|
|||||||
"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:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"@types/react-dom": "catalog:",
|
"@types/react-dom": "catalog:",
|
||||||
@@ -51,27 +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:",
|
||||||
"input-otp": "^1.4.2",
|
"i18next": "26.3.6",
|
||||||
|
"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-icons": "^5.4.0",
|
"react-i18next": "17.0.11",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-icons": "5.7.0",
|
||||||
"recharts": "^2.15.2",
|
"react-is": "19.2.8",
|
||||||
"sonner": "^2.0.7",
|
"react-resizable-panels": "4.12.2",
|
||||||
|
"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,5 +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 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";
|
||||||
|
|
||||||
@@ -13,7 +17,10 @@ import Analytics from "@/pages/analytics";
|
|||||||
import Admin from "@/pages/admin";
|
import Admin from "@/pages/admin";
|
||||||
import Redundancy from "@/pages/redundancy";
|
import Redundancy from "@/pages/redundancy";
|
||||||
import Trash from "@/pages/trash";
|
import Trash from "@/pages/trash";
|
||||||
|
import Compare from "@/pages/compare";
|
||||||
|
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({
|
||||||
@@ -34,25 +41,33 @@ function Router() {
|
|||||||
<Route path="/tools/new" component={ToolNew} />
|
<Route path="/tools/new" component={ToolNew} />
|
||||||
<Route path="/tools/:id/edit" component={ToolEdit} />
|
<Route path="/tools/:id/edit" component={ToolEdit} />
|
||||||
<Route path="/tools/:id" component={ToolDetail} />
|
<Route path="/tools/:id" component={ToolDetail} />
|
||||||
|
<Route path="/compare" component={Compare} />
|
||||||
|
<Route path="/watchlist" component={Watchlist} />
|
||||||
<Route path="/analytics" component={Analytics} />
|
<Route path="/analytics" component={Analytics} />
|
||||||
<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 (
|
||||||
<QueryClientProvider client={queryClient}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<TooltipProvider>
|
<QueryClientProvider client={queryClient}>
|
||||||
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}>
|
<TooltipProvider>
|
||||||
<Router />
|
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}>
|
||||||
</WouterRouter>
|
<Router />
|
||||||
<Toaster />
|
</WouterRouter>
|
||||||
</TooltipProvider>
|
<Toaster />
|
||||||
</QueryClientProvider>
|
</TooltipProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</I18nextProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { Fragment } from "react";
|
||||||
|
import { useLocation, Link } from "wouter";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { useGetTool, getGetToolQueryKey } from "@workspace/api-client-react";
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from "@/components/ui/breadcrumb";
|
||||||
|
|
||||||
|
type Crumb = { href?: string; label: string };
|
||||||
|
|
||||||
|
function buildCrumbs(location: string, t: TFunction): Crumb[] {
|
||||||
|
const crumbs: Crumb[] = [];
|
||||||
|
|
||||||
|
if (location.startsWith("/tools")) {
|
||||||
|
crumbs.push({ href: "/tools", label: t("nav.browseTools") });
|
||||||
|
const match = location.match(/^\/tools\/(\d+)/);
|
||||||
|
if (match) {
|
||||||
|
crumbs.push({ href: `/tools/${match[1]}`, label: t("browse.tool") });
|
||||||
|
if (location.includes("/edit")) crumbs.push({ label: t("common.edit") });
|
||||||
|
} else if (location.startsWith("/tools/new")) {
|
||||||
|
crumbs.push({ label: t("nav.addTool") });
|
||||||
|
}
|
||||||
|
} else if (location.startsWith("/admin")) {
|
||||||
|
crumbs.push({ href: "/admin", label: t("nav.admin") });
|
||||||
|
if (location.startsWith("/admin/redundancy")) crumbs.push({ label: t("nav.redundancy") });
|
||||||
|
} else if (location.startsWith("/watchlist")) {
|
||||||
|
crumbs.push({ label: t("nav.watchlist") });
|
||||||
|
} else if (location.startsWith("/trash")) {
|
||||||
|
crumbs.push({ label: t("nav.trash") });
|
||||||
|
} else if (location.startsWith("/compare")) {
|
||||||
|
crumbs.push({ label: t("compare.title") });
|
||||||
|
} else if (location.startsWith("/analytics")) {
|
||||||
|
crumbs.push({ label: t("nav.analytics") });
|
||||||
|
} else if (location.startsWith("/login")) {
|
||||||
|
crumbs.push({ label: t("auth.signIn") });
|
||||||
|
}
|
||||||
|
|
||||||
|
return crumbs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Breadcrumbs() {
|
||||||
|
const [location] = useLocation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const detailMatch = location.match(/^\/tools\/(\d+)/);
|
||||||
|
const toolId = detailMatch ? parseInt(detailMatch[1], 10) : 0;
|
||||||
|
const { data: tool } = useGetTool(Number.isFinite(toolId) ? toolId : 0, {
|
||||||
|
query: {
|
||||||
|
queryKey: getGetToolQueryKey(toolId),
|
||||||
|
enabled: Number.isFinite(toolId) && toolId > 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const crumbs = buildCrumbs(location, t);
|
||||||
|
if (crumbs.length <= 1) return null;
|
||||||
|
|
||||||
|
if (toolId > 0 && tool?.name) {
|
||||||
|
const idx = crumbs.findIndex((c) => c.href === `/tools/${toolId}`);
|
||||||
|
if (idx >= 0) crumbs[idx].label = tool.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Breadcrumb className="min-w-0">
|
||||||
|
<BreadcrumbList className="flex-nowrap">
|
||||||
|
{crumbs.map((crumb, i) => {
|
||||||
|
const isLast = i === crumbs.length - 1;
|
||||||
|
return (
|
||||||
|
<Fragment key={i}>
|
||||||
|
{i > 0 && <BreadcrumbSeparator />}
|
||||||
|
<BreadcrumbItem className="min-w-0">
|
||||||
|
{isLast || !crumb.href ? (
|
||||||
|
<BreadcrumbPage className="text-sm truncate">{crumb.label}</BreadcrumbPage>
|
||||||
|
) : (
|
||||||
|
<BreadcrumbLink asChild className="text-sm">
|
||||||
|
<Link href={crumb.href} className="truncate">
|
||||||
|
{crumb.label}
|
||||||
|
</Link>
|
||||||
|
</BreadcrumbLink>
|
||||||
|
)}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useLocation } from "wouter";
|
||||||
|
import { useListTools, getListToolsQueryKey } from "@workspace/api-client-react";
|
||||||
|
import {
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandSeparator,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getRecentTools } from "@/lib/recent-tools";
|
||||||
|
import {
|
||||||
|
Compass,
|
||||||
|
PlusCircle,
|
||||||
|
BarChart3,
|
||||||
|
Trash2,
|
||||||
|
ShieldCheck,
|
||||||
|
Wrench,
|
||||||
|
Star,
|
||||||
|
History,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const openRequesters = new Set<() => void>();
|
||||||
|
|
||||||
|
export function requestOpenCommandPalette() {
|
||||||
|
for (const request of openRequesters) request();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandPalette() {
|
||||||
|
const [, navigate] = useLocation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isAuthenticated, isAdmin, hasFeature } = useAuth();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [recent, setRecent] = useState(() => getRecentTools());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const request = () => setOpen(true);
|
||||||
|
openRequesters.add(request);
|
||||||
|
return () => {
|
||||||
|
openRequesters.delete(request);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const down = (e: KeyboardEvent) => {
|
||||||
|
if (e.key.toLowerCase() === "k" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((o) => !o);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", down);
|
||||||
|
return () => window.removeEventListener("keydown", down);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSearch("");
|
||||||
|
} else {
|
||||||
|
setRecent(getRecentTools());
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const searching = search.trim().length > 0;
|
||||||
|
|
||||||
|
const { data: tools } = useListTools(
|
||||||
|
searching ? { search } : undefined,
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
queryKey: getListToolsQueryKey(searching ? { search } : undefined),
|
||||||
|
enabled: open && searching,
|
||||||
|
staleTime: 30_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function go(path: string) {
|
||||||
|
setOpen(false);
|
||||||
|
navigate(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const showTrash = hasFeature("trash") || isAdmin;
|
||||||
|
const showWatchlist = (hasFeature("watchlist") || isAdmin) && isAuthenticated;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<CommandInput
|
||||||
|
autoFocus
|
||||||
|
placeholder={`${t("common.search")}…`}
|
||||||
|
value={search}
|
||||||
|
onValueChange={setSearch}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
{searching ? t("command.noResults", { query: search }) : t("command.startTyping")}
|
||||||
|
</CommandEmpty>
|
||||||
|
|
||||||
|
{!searching && (
|
||||||
|
<>
|
||||||
|
{recent.length > 0 && (
|
||||||
|
<CommandGroup heading={t("command.recent")}>
|
||||||
|
{recent.map((tool) => (
|
||||||
|
<CommandItem key={tool.id} onSelect={() => go(`/tools/${tool.id}`)}>
|
||||||
|
<History className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="truncate">{tool.name}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
<CommandGroup heading={t("command.navigate")}>
|
||||||
|
<CommandItem onSelect={() => go("/tools")}>
|
||||||
|
<Compass className="mr-2 h-4 w-4" /> {t("nav.browseTools")}
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem onSelect={() => go("/tools/new")}>
|
||||||
|
<PlusCircle className="mr-2 h-4 w-4" /> {t("nav.addTool")}
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem onSelect={() => go("/analytics")}>
|
||||||
|
<BarChart3 className="mr-2 h-4 w-4" /> {t("nav.analytics")}
|
||||||
|
</CommandItem>
|
||||||
|
{showWatchlist && (
|
||||||
|
<CommandItem onSelect={() => go("/watchlist")}>
|
||||||
|
<Star className="mr-2 h-4 w-4" /> {t("nav.watchlist")}
|
||||||
|
</CommandItem>
|
||||||
|
)}
|
||||||
|
{showTrash && (
|
||||||
|
<CommandItem onSelect={() => go("/trash")}>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" /> {t("nav.trash")}
|
||||||
|
</CommandItem>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<CommandItem onSelect={() => go("/admin")}>
|
||||||
|
<ShieldCheck className="mr-2 h-4 w-4" /> {t("nav.admin")}
|
||||||
|
</CommandItem>
|
||||||
|
)}
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searching && (
|
||||||
|
<>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup heading={t("command.tools")}>
|
||||||
|
{(tools ?? []).slice(0, 10).map((tool) => (
|
||||||
|
<CommandItem key={tool.id} onSelect={() => go(`/tools/${tool.id}`)}>
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">{tool.name}</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground shrink-0">
|
||||||
|
{tool.avgCombined ? `${tool.avgCombined.toFixed(1)}★` : ""}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { X, Scale, Lock } from "lucide-react";
|
||||||
|
import type { ToolWithStats } from "@workspace/api-client-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export function CompareBar({
|
||||||
|
tools,
|
||||||
|
onRemove,
|
||||||
|
onClear,
|
||||||
|
canCompare,
|
||||||
|
onCompare,
|
||||||
|
onUpgrade,
|
||||||
|
}: {
|
||||||
|
tools: ToolWithStats[];
|
||||||
|
onRemove: (id: number) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
canCompare: boolean;
|
||||||
|
onCompare: () => void;
|
||||||
|
onUpgrade: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (tools.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 w-[min(96vw,42rem)]">
|
||||||
|
<div className="rounded-xl border bg-popover/95 backdrop-blur shadow-lg p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Scale className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
|
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-x-auto">
|
||||||
|
{tools.map((t) => (
|
||||||
|
<span
|
||||||
|
key={t.id}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs whitespace-nowrap",
|
||||||
|
"bg-primary/5 border-primary/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="max-w-[8rem] truncate">{t.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(t.id)}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={`Remove ${t.name}`}
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClear}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground whitespace-nowrap px-1"
|
||||||
|
>
|
||||||
|
{t("common.clear")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{canCompare ? (
|
||||||
|
<Button size="sm" onClick={onCompare} disabled={tools.length < 2}>
|
||||||
|
{t("browse.compareCount", { count: tools.length })}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="outline" onClick={onUpgrade} title={t("browse.comparePremiumTitle")}>
|
||||||
|
<Lock className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
{t("browse.compare")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { Maximize2, Minimize2 } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export type Density = "cozy" | "compact";
|
||||||
|
|
||||||
|
export function DensityToggle({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
}: {
|
||||||
|
value: Density;
|
||||||
|
onValueChange: (d: Density) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const densityLabel = value === "compact" ? t("common.densityCompact") : t("common.densityComfortable");
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
title={t("common.densityTitle", { density: densityLabel })}
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-4 h-4 text-muted-foreground" aria-hidden />
|
||||||
|
<Slider
|
||||||
|
className="w-16"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={1}
|
||||||
|
value={[value === "compact" ? 1 : 0]}
|
||||||
|
onValueChange={([v]) => onValueChange(v === 1 ? "compact" : "cozy")}
|
||||||
|
aria-label={t("common.listDensity")}
|
||||||
|
/>
|
||||||
|
<Minimize2 className="w-4 h-4 text-muted-foreground" aria-hidden />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { useListAllTags, useListAllFeatures } from "@workspace/api-client-react";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { SlidersHorizontal, X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export function FilterPopover({
|
||||||
|
tags,
|
||||||
|
features,
|
||||||
|
minRating,
|
||||||
|
onChange,
|
||||||
|
onClear,
|
||||||
|
activeCount,
|
||||||
|
}: {
|
||||||
|
tags: string[];
|
||||||
|
features: string[];
|
||||||
|
minRating: number | null;
|
||||||
|
onChange: (patch: { tags?: string[]; features?: string[]; minRating?: number | null }) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
activeCount: number;
|
||||||
|
}) {
|
||||||
|
const { data: allTags } = useListAllTags();
|
||||||
|
const { data: allFeatures } = useListAllFeatures();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
function toggle(list: string[], value: string): string[] {
|
||||||
|
return list.includes(value) ? list.filter((v) => v !== value) : [...list, value];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="gap-2" data-testid="button-filters">
|
||||||
|
<SlidersHorizontal className="w-4 h-4" />
|
||||||
|
{t("browse.filters")}
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<span className="rounded-full bg-primary text-primary-foreground text-[10px] font-medium px-1.5 py-0.5 min-w-[1.25rem] text-center">
|
||||||
|
{activeCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[340px]" align="start">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{allTags && allTags.length > 0 && (
|
||||||
|
<div className="pb-3 border-b border-border/60">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">{t("filter.tags")}</h4>
|
||||||
|
<ScrollArea className="max-h-40">
|
||||||
|
<div className="space-y-1.5 pr-2">
|
||||||
|
{allTags.map((t) => (
|
||||||
|
<Label key={t} className="flex items-center gap-2 text-sm font-normal cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={tags.includes(t)}
|
||||||
|
onCheckedChange={() => onChange({ tags: toggle(tags, t) })}
|
||||||
|
/>
|
||||||
|
{t}
|
||||||
|
</Label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{allFeatures && allFeatures.length > 0 && (
|
||||||
|
<div className="pb-3 border-b border-border/60">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">{t("filter.features")}</h4>
|
||||||
|
<ScrollArea className="max-h-40">
|
||||||
|
<div className="space-y-1.5 pr-2">
|
||||||
|
{allFeatures.map((f) => (
|
||||||
|
<Label key={f} className="flex items-center gap-2 text-sm font-normal cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={features.includes(f)}
|
||||||
|
onCheckedChange={() => onChange({ features: toggle(features, f) })}
|
||||||
|
/>
|
||||||
|
{f}
|
||||||
|
</Label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t("filter.minRating")}</h4>
|
||||||
|
{minRating != null && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-1 text-xs text-muted-foreground"
|
||||||
|
onClick={() => onChange({ minRating: null })}
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Slider
|
||||||
|
className="flex-1"
|
||||||
|
min={0}
|
||||||
|
max={5}
|
||||||
|
step={0.5}
|
||||||
|
value={[minRating ?? 0]}
|
||||||
|
onValueChange={([v]) => onChange({ minRating: v })}
|
||||||
|
aria-label={t("filter.minRating")}
|
||||||
|
/>
|
||||||
|
<span className="text-sm tabular-nums w-8 text-right text-muted-foreground">
|
||||||
|
{minRating != null ? `${minRating.toFixed(1)}+` : t("filter.any")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" className="w-full" onClick={onClear}>
|
||||||
|
{t("filter.clearFilters")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Kbd } from "@/components/ui/kbd";
|
||||||
|
import { requestOpenCommandPalette } from "@/components/command-palette";
|
||||||
|
|
||||||
|
export function HeaderSearch() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden md:inline-flex justify-start gap-2 w-56 text-muted-foreground"
|
||||||
|
onClick={requestOpenCommandPalette}
|
||||||
|
data-testid="button-header-search"
|
||||||
|
>
|
||||||
|
<Search className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="flex-1 text-left text-sm truncate">{t("nav.search")}</span>
|
||||||
|
<Kbd className="hidden lg:inline-flex">
|
||||||
|
<span>⌘</span>K
|
||||||
|
</Kbd>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Languages, Check } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { SUPPORTED_LANGUAGES, setStoredLanguage, type LanguageCode } from "@/i18n";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function LanguageSwitcher() {
|
||||||
|
const { i18n, t } = useTranslation();
|
||||||
|
const current = (i18n.language?.split("-")[0] ?? "en") as LanguageCode;
|
||||||
|
|
||||||
|
function change(lang: LanguageCode) {
|
||||||
|
setStoredLanguage(lang);
|
||||||
|
void i18n.changeLanguage(lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="gap-1.5" aria-label={t("common.language")} data-testid="button-language">
|
||||||
|
<Languages className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline text-xs uppercase font-semibold">{current}</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>{t("common.language")}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{SUPPORTED_LANGUAGES.map(({ code, label }) => (
|
||||||
|
<DropdownMenuItem key={code} onClick={() => change(code)} className="justify-between">
|
||||||
|
{label}
|
||||||
|
{current === code && <Check className="w-4 h-4 text-primary" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,156 +1,196 @@
|
|||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, User, ShieldCheck, AlertTriangle, Trash2 } from "lucide-react";
|
import { LayoutDashboard, Wrench, BarChart3, FileText, LogIn, LogOut, ShieldCheck, AlertTriangle, Search } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
|
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
import { CommandPalette, requestOpenCommandPalette } from "@/components/command-palette";
|
||||||
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
|
import { UserMenu } from "@/components/user-menu";
|
||||||
|
import { Breadcrumbs } from "@/components/breadcrumbs";
|
||||||
|
import { HeaderSearch } from "@/components/header-search";
|
||||||
|
import {
|
||||||
|
SidebarProvider,
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarRail,
|
||||||
|
SidebarTrigger,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
const { user, isLoading, isAuthenticated, isAdmin, isLocalMode, tier, hasFeature, login, logout } = useAuth();
|
const { t } = useTranslation();
|
||||||
|
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 },
|
||||||
});
|
});
|
||||||
|
|
||||||
const links = [
|
const mainLinks = [
|
||||||
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
{ href: "/", label: t("nav.home"), icon: LayoutDashboard },
|
||||||
{ href: "/tools", label: "Browse Tools", icon: Wrench },
|
{ href: "/tools", label: t("nav.browseTools"), icon: Wrench },
|
||||||
{ href: "/tools/new", label: "Add Tool", icon: PlusCircle },
|
{ href: "/analytics", label: t("nav.analytics"), icon: BarChart3 },
|
||||||
{ href: "/analytics", label: "Analytics", icon: BarChart3 },
|
{ href: "/docs", label: t("nav.docs"), icon: FileText },
|
||||||
...(hasFeature("trash") ? [{ href: "/trash", label: "Trash", icon: Trash2 }] : []),
|
|
||||||
...(isAdmin ? [{ href: "/admin", label: "Admin", icon: ShieldCheck }] : []),
|
|
||||||
...(isAdmin ? [{ href: "/admin/redundancy", label: "Redundancy", icon: AlertTriangle }] : []),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeLink = links.find(
|
const adminLinks = [
|
||||||
(link) => location === link.href || (link.href !== "/" && location.startsWith(link.href)),
|
{ href: "/admin", label: t("nav.admin"), icon: ShieldCheck },
|
||||||
);
|
{ href: "/admin/redundancy", label: t("nav.redundancy"), icon: AlertTriangle },
|
||||||
|
];
|
||||||
|
|
||||||
|
function isActive(href: string) {
|
||||||
|
return location === href || (href !== "/" && location.startsWith(href));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-background text-foreground">
|
<SidebarProvider>
|
||||||
<aside className="w-64 border-r bg-card flex flex-col hidden md:flex">
|
<CommandPalette />
|
||||||
<div className="p-6 border-b">
|
<Sidebar collapsible="icon">
|
||||||
|
<SidebarHeader className="flex h-14 items-center justify-between border-b px-4">
|
||||||
<Link href="/" className="flex items-center gap-2 text-primary font-bold text-xl">
|
<Link href="/" className="flex items-center gap-2 text-primary font-bold text-xl">
|
||||||
<Wrench className="w-6 h-6" />
|
<Wrench className="w-6 h-6 shrink-0" />
|
||||||
<span>toolr</span>
|
<span className="group-data-[collapsible=icon]:hidden">toolr</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</SidebarHeader>
|
||||||
<nav className="flex-1 p-4 space-y-2">
|
<SidebarContent>
|
||||||
{links.map((link) => {
|
<SidebarGroup>
|
||||||
const isActive = location === link.href || (link.href !== "/" && location.startsWith(link.href));
|
<SidebarGroupContent>
|
||||||
const Icon = link.icon;
|
<SidebarMenu>
|
||||||
return (
|
{mainLinks.map((link) => {
|
||||||
<Link
|
const Icon = link.icon;
|
||||||
key={link.href}
|
return (
|
||||||
href={link.href}
|
<SidebarMenuItem key={link.href}>
|
||||||
className={`flex items-center gap-3 px-3 py-2 rounded-md transition-colors ${isActive ? "bg-primary text-primary-foreground font-medium" : "text-muted-foreground hover:bg-muted hover:text-foreground"}`}
|
<SidebarMenuButton asChild isActive={isActive(link.href)} tooltip={link.label}>
|
||||||
>
|
<Link href={link.href}>
|
||||||
<Icon className="w-5 h-5" />
|
<Icon />
|
||||||
{link.label}
|
<span>{link.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
</SidebarMenuButton>
|
||||||
})}
|
</SidebarMenuItem>
|
||||||
</nav>
|
);
|
||||||
|
})}
|
||||||
<div className="p-4 border-t">
|
</SidebarMenu>
|
||||||
{isLoading ? (
|
</SidebarGroupContent>
|
||||||
<div className="flex items-center gap-3 px-3 py-2">
|
</SidebarGroup>
|
||||||
<Skeleton className="w-8 h-8 rounded-full" />
|
{isAdmin && (
|
||||||
<Skeleton className="h-4 w-24" />
|
<SidebarGroup>
|
||||||
</div>
|
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">
|
||||||
) : isAuthenticated && user ? (
|
{t("nav.admin")}
|
||||||
<div className="space-y-2">
|
</SidebarGroupLabel>
|
||||||
<div className="flex items-center gap-3 px-3 py-2 rounded-md bg-muted/50">
|
<SidebarGroupContent>
|
||||||
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center shrink-0">
|
<SidebarMenu>
|
||||||
<User className="w-4 h-4 text-primary" />
|
{adminLinks.map((link) => {
|
||||||
</div>
|
const Icon = link.icon;
|
||||||
<div className="min-w-0">
|
return (
|
||||||
<p className="text-sm font-medium truncate text-foreground">
|
<SidebarMenuItem key={link.href}>
|
||||||
{user.name || user.preferredUsername || "User"}
|
<SidebarMenuButton asChild isActive={isActive(link.href)} tooltip={link.label}>
|
||||||
</p>
|
<Link href={link.href}>
|
||||||
<div className="flex items-center gap-1.5 mt-0.5">
|
<Icon />
|
||||||
<span className="text-[10px] uppercase tracking-wider font-semibold px-1.5 py-0.5 rounded-sm bg-primary/10 text-primary">{tier}</span>
|
<span>{link.label}</span>
|
||||||
{user.email && <span className="text-xs text-muted-foreground truncate">{user.email}</span>}
|
</Link>
|
||||||
</div>
|
</SidebarMenuButton>
|
||||||
</div>
|
</SidebarMenuItem>
|
||||||
</div>
|
);
|
||||||
<Button
|
})}
|
||||||
variant="ghost"
|
</SidebarMenu>
|
||||||
size="sm"
|
</SidebarGroupContent>
|
||||||
className="w-full justify-start gap-2 text-muted-foreground hover:text-destructive"
|
</SidebarGroup>
|
||||||
onClick={logout}
|
|
||||||
data-testid="button-logout"
|
|
||||||
>
|
|
||||||
<LogOut className="w-4 h-4" />
|
|
||||||
Sign out
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-full gap-2"
|
|
||||||
onClick={() => login(location)}
|
|
||||||
data-testid="button-login"
|
|
||||||
>
|
|
||||||
<LogIn className="w-4 h-4" />
|
|
||||||
{isLocalMode ? "Sign in" : "Sign in with Keycloak"}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</SidebarContent>
|
||||||
<div className="px-4 py-2.5 border-t flex items-center justify-between text-[11px] text-muted-foreground">
|
<SidebarFooter className="gap-2">
|
||||||
<span className="font-medium">toolr</span>
|
<UserMenu />
|
||||||
{version?.commitSha ? (
|
<div className="border-t pt-2 flex items-center justify-between text-[11px] text-muted-foreground px-2 group-data-[collapsible=icon]:justify-center">
|
||||||
<a
|
<span className="font-medium group-data-[collapsible=icon]:hidden">toolr</span>
|
||||||
href={`https://git.kubebase.de/admin/tool-evaluator/commit/${version.commitSha}`}
|
{version?.commitSha ? (
|
||||||
target="_blank"
|
<a
|
||||||
rel="noreferrer"
|
href={`https://git.kubebase.de/admin/tool-evaluator/commit/${version.commitSha}`}
|
||||||
className="font-mono hover:underline"
|
target="_blank"
|
||||||
title={version.commitSha}
|
rel="noreferrer"
|
||||||
>
|
className="font-mono hover:underline truncate group-data-[collapsible=icon]:hidden"
|
||||||
{version.version && version.version !== "dev"
|
title={version.commitSha}
|
||||||
? version.version
|
>
|
||||||
: `sha-${version.commitSha.slice(0, 7)}`}
|
{version.version && version.version !== "dev"
|
||||||
</a>
|
? version.version
|
||||||
) : version?.version && version.version !== "dev" ? (
|
: `sha-${version.commitSha.slice(0, 7)}`}
|
||||||
<span className="font-mono">{version.version}</span>
|
</a>
|
||||||
) : null}
|
) : version?.version && version.version !== "dev" ? (
|
||||||
</div>
|
<span className="font-mono group-data-[collapsible=icon]:hidden">{version.version}</span>
|
||||||
</aside>
|
) : null}
|
||||||
|
</div>
|
||||||
|
</SidebarFooter>
|
||||||
|
<SidebarRail />
|
||||||
|
</Sidebar>
|
||||||
|
|
||||||
<main className="flex-1 flex flex-col min-w-0">
|
<SidebarInset>
|
||||||
<header className="hidden md:flex items-center justify-between border-b bg-card px-6 py-3">
|
<header className="flex items-center justify-between gap-2 border-b bg-card px-3 md:px-5 h-14 shrink-0">
|
||||||
<h1 className="text-sm font-semibold text-muted-foreground">
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
{activeLink?.label ?? "toolr"}
|
<SidebarTrigger className="h-8 w-8 shrink-0" data-testid="button-sidebar-toggle" />
|
||||||
</h1>
|
<Link
|
||||||
<ThemeToggle />
|
href="/"
|
||||||
</header>
|
className="md:hidden flex items-center gap-2 text-primary font-bold text-lg shrink-0 pl-1"
|
||||||
<header className="md:hidden border-b p-4 flex items-center justify-between bg-card">
|
>
|
||||||
<Link href="/" className="flex items-center gap-2 text-primary font-bold text-lg">
|
<Wrench className="w-5 h-5" />
|
||||||
<Wrench className="w-5 h-5" />
|
<span>toolr</span>
|
||||||
<span>toolr</span>
|
</Link>
|
||||||
</Link>
|
<div className="hidden md:block min-w-0 pl-1">
|
||||||
<div className="flex items-center gap-1">
|
<Breadcrumbs />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<HeaderSearch />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 md:hidden"
|
||||||
|
onClick={requestOpenCommandPalette}
|
||||||
|
aria-label={t("nav.search")}
|
||||||
|
data-testid="button-search-mobile"
|
||||||
|
>
|
||||||
|
<Search className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<LanguageSwitcher />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
isAuthenticated ? (
|
<div className="md:hidden">
|
||||||
<Button variant="ghost" size="sm" onClick={logout} data-testid="button-logout-mobile">
|
{isAuthenticated ? (
|
||||||
<LogOut className="w-4 h-4" />
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
) : (
|
size="icon"
|
||||||
<Button variant="outline" size="sm" onClick={() => login(location)} data-testid="button-login-mobile">
|
className="h-9 w-9"
|
||||||
<LogIn className="w-4 h-4 mr-1" />
|
onClick={logout}
|
||||||
Sign in
|
aria-label={t("auth.signOut")}
|
||||||
</Button>
|
data-testid="button-logout-mobile"
|
||||||
)
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => login(location)}
|
||||||
|
data-testid="button-login-mobile"
|
||||||
|
>
|
||||||
|
<LogIn className="w-4 h-4 mr-1" />
|
||||||
|
{t("auth.signIn")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="flex-1 p-6 md:p-8 overflow-auto">
|
<div className="flex-1 overflow-auto p-4 md:p-6 lg:p-8">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</SidebarInset>
|
||||||
</div>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function num(v: number | string | null | undefined): number | null {
|
||||||
|
if (v == null || v === "") return null;
|
||||||
|
const n = typeof v === "string" ? Number(v) : v;
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(v: number | string | null | undefined): number {
|
||||||
|
const n = num(v);
|
||||||
|
if (n == null) return 0;
|
||||||
|
return Math.max(0, Math.min(5, n)) / 5 * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MiniBars({
|
||||||
|
usefulness,
|
||||||
|
usability,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
usefulness: number | string | null | undefined;
|
||||||
|
usability: number | string | null | undefined;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const rows = [
|
||||||
|
{ label: "Usefulness", value: usefulness },
|
||||||
|
{ label: "Usability", value: usability },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-1 w-full", className)}>
|
||||||
|
{rows.map(({ label, value }) => {
|
||||||
|
const n = num(value);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
title={`${label}: ${n != null ? n.toFixed(1) : "N/A"} / 5`}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] uppercase tracking-wide text-muted-foreground w-[3.5rem] shrink-0">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary/70"
|
||||||
|
style={{ width: `${pct(value)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground tabular-nums w-7 text-right shrink-0">
|
||||||
|
{n != null ? n.toFixed(1) : "–"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MiniBarStack({
|
||||||
|
usefulness,
|
||||||
|
usability,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
usefulness: number | string | null | undefined;
|
||||||
|
usability: number | string | null | undefined;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const u = num(usefulness);
|
||||||
|
const a = num(usability);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("h-1.5 w-16 rounded-full bg-muted overflow-hidden flex", className)}
|
||||||
|
title={`Usefulness ${u != null ? u.toFixed(1) : "N/A"} / Usability ${
|
||||||
|
a != null ? a.toFixed(1) : "N/A"
|
||||||
|
} (of 5)`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary/80"
|
||||||
|
style={{ width: `${pct(usefulness)}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary/40"
|
||||||
|
style={{ width: `${pct(usability)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,97 @@
|
|||||||
|
import { Link } from "wouter";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Star, MessageSquare, Wrench, ChevronRight, Scale } from "lucide-react";
|
||||||
|
import { ToolWithStats } from "@workspace/api-client-react";
|
||||||
|
import { MiniBarStack } from "@/components/mini-bars";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function ToolCardWide({
|
||||||
|
tool,
|
||||||
|
density,
|
||||||
|
compare,
|
||||||
|
}: {
|
||||||
|
tool: ToolWithStats;
|
||||||
|
density: "cozy" | "compact";
|
||||||
|
compare?: { selected: boolean; onToggle: () => void };
|
||||||
|
}) {
|
||||||
|
const compact = density === "compact";
|
||||||
|
return (
|
||||||
|
<Card className="hover-elevate transition-all cursor-pointer hover:border-primary/50">
|
||||||
|
<Link href={`/tools/${tool.id}`} className="flex items-center gap-4 p-4">
|
||||||
|
<div className="shrink-0 w-10 h-10 rounded-md border bg-muted flex items-center justify-center overflow-hidden">
|
||||||
|
{tool.iconUrl ? (
|
||||||
|
<img
|
||||||
|
src={tool.iconUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-contain p-0.5"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Wrench className="w-5 h-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-semibold truncate">{tool.name}</span>
|
||||||
|
<Badge variant="outline" className="text-xs shrink-0">
|
||||||
|
{tool.category}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{!compact && <p className="text-sm text-muted-foreground line-clamp-2">{tool.description}</p>}
|
||||||
|
{!compact && tool.tags && tool.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||||
|
{tool.tags.slice(0, 3).map((tag) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="text-xs px-1.5 py-0">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{tool.tags.length > 3 && (
|
||||||
|
<Badge variant="secondary" className="text-xs px-1.5 py-0 text-muted-foreground">
|
||||||
|
+{tool.tags.length - 3}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 flex flex-col items-end gap-2 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Star className="w-4 h-4 fill-primary text-primary" />
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{tool.avgCombined ? tool.avgCombined.toFixed(1) : "N/A"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<MessageSquare className="w-4 h-4" />
|
||||||
|
<span>{tool.ratingCount}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
{!compact && <MiniBarStack usefulness={tool.avgUsefulness} usability={tool.avgUsability} />}
|
||||||
|
{compare && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
compare.onToggle();
|
||||||
|
}}
|
||||||
|
title={compare.selected ? "Remove from compare" : "Add to compare"}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 w-7 h-7 rounded-md border flex items-center justify-center transition-colors",
|
||||||
|
compare.selected
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Scale className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,20 @@
|
|||||||
import { ToolWithStats } from "@workspace/api-client-react";
|
import { ToolWithStats } from "@workspace/api-client-react";
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card";
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Star, MessageSquare, Wrench } from "lucide-react";
|
import { Star, MessageSquare, Wrench, Scale, Bookmark } from "lucide-react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
|
import { MiniBars } from "@/components/mini-bars";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function ToolCard({ tool }: { tool: ToolWithStats }) {
|
export function ToolCard({
|
||||||
|
tool,
|
||||||
|
compare,
|
||||||
|
watchlist,
|
||||||
|
}: {
|
||||||
|
tool: ToolWithStats;
|
||||||
|
compare?: { selected: boolean; onToggle: () => void };
|
||||||
|
watchlist?: { watched: boolean; onToggle: () => void };
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card className="hover-elevate transition-all flex flex-col h-full cursor-pointer hover:border-primary/50">
|
<Card className="hover-elevate transition-all flex flex-col h-full cursor-pointer hover:border-primary/50">
|
||||||
<Link href={`/tools/${tool.id}`} className="flex flex-col h-full p-0 m-0">
|
<Link href={`/tools/${tool.id}`} className="flex flex-col h-full p-0 m-0">
|
||||||
@@ -47,19 +57,62 @@ export function ToolCard({ tool }: { tool: ToolWithStats }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="pt-0 flex justify-between items-center text-sm text-muted-foreground border-t p-4 mt-auto">
|
<CardFooter className="pt-0 flex flex-col gap-3 border-t p-4 mt-auto">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-4">
|
||||||
<Star className="w-4 h-4 fill-primary text-primary" />
|
<div className="flex items-center gap-1">
|
||||||
<span className="font-medium text-foreground">
|
<Star className="w-4 h-4 fill-primary text-primary" />
|
||||||
{tool.avgCombined ? tool.avgCombined.toFixed(1) : "N/A"}
|
<span className="font-medium text-foreground">
|
||||||
</span>
|
{tool.avgCombined ? tool.avgCombined.toFixed(1) : "N/A"}
|
||||||
</div>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
</div>
|
||||||
<MessageSquare className="w-4 h-4" />
|
<div className="flex items-center gap-1">
|
||||||
<span>{tool.ratingCount}</span>
|
<MessageSquare className="w-4 h-4" />
|
||||||
|
<span>{tool.ratingCount}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={cn("items-center gap-3", (compare || watchlist) ? "flex" : "")}>
|
||||||
|
<MiniBars usefulness={tool.avgUsefulness} usability={tool.avgUsability} className={(compare || watchlist) ? "flex-1" : undefined} />
|
||||||
|
{watchlist && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
watchlist.onToggle();
|
||||||
|
}}
|
||||||
|
title={watchlist.watched ? "Remove from watchlist" : "Add to watchlist"}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 w-8 h-8 rounded-md border flex items-center justify-center transition-colors",
|
||||||
|
watchlist.watched
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Bookmark className={cn("w-4 h-4", watchlist.watched && "fill-current")} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{compare && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
compare.onToggle();
|
||||||
|
}}
|
||||||
|
title={compare.selected ? "Remove from compare" : "Add to compare"}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 w-8 h-8 rounded-md border flex items-center justify-center transition-colors",
|
||||||
|
compare.selected
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Scale className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Link>
|
</Link>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Link } from "wouter";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { ToolWithStats } from "@workspace/api-client-react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { RatingStars } from "@/components/rating-stars";
|
||||||
|
import { MiniBars } from "@/components/mini-bars";
|
||||||
|
|
||||||
|
export function ToolPreviewCard({ tool }: { tool: ToolWithStats }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-semibold leading-tight truncate">{tool.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{tool.category}</div>
|
||||||
|
</div>
|
||||||
|
<RatingStars value={tool.avgCombined ?? 0} size="sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-3">{tool.description}</p>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">{t("browse.reviewCount", { count: tool.ratingCount })}</span>
|
||||||
|
<span className="font-semibold tabular-nums">
|
||||||
|
{tool.avgCombined != null ? tool.avgCombined.toFixed(1) : "–"}/5
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<MiniBars usefulness={tool.avgUsefulness} usability={tool.avgUsability} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tool.tags && tool.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{tool.tags.slice(0, 4).map((tag) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="text-[10px]">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{tool.tags.length > 4 && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">+{tool.tags.length - 4}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{t("common.viewDetails")}
|
||||||
|
</Link> </div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Link } from "wouter";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Star, MessageSquare, Wrench, Scale } from "lucide-react";
|
||||||
|
import { ToolWithStats } from "@workspace/api-client-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { MiniBarStack } from "@/components/mini-bars";
|
||||||
|
|
||||||
|
export const TABLE_GRID =
|
||||||
|
"grid-cols-[minmax(0,2fr)_minmax(0,1fr)_80px_80px]";
|
||||||
|
|
||||||
|
export function ToolRow({
|
||||||
|
tool,
|
||||||
|
density,
|
||||||
|
className,
|
||||||
|
compare,
|
||||||
|
}: {
|
||||||
|
tool: ToolWithStats;
|
||||||
|
density: "cozy" | "compact";
|
||||||
|
className?: string;
|
||||||
|
compare?: { selected: boolean; onToggle: () => void };
|
||||||
|
}) {
|
||||||
|
const compact = density === "compact";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid items-center gap-3 border-b border-border/60 hover:bg-muted/40 cursor-pointer",
|
||||||
|
TABLE_GRID,
|
||||||
|
compact ? "py-1.5" : "py-3",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
{compare && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
compare.onToggle();
|
||||||
|
}}
|
||||||
|
title={compare.selected ? "Remove from compare" : "Add to compare"}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 w-6 h-6 rounded border flex items-center justify-center transition-colors",
|
||||||
|
compare.selected
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Scale className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="shrink-0 w-8 h-8 rounded-md border bg-muted flex items-center justify-center overflow-hidden">
|
||||||
|
{tool.iconUrl ? (
|
||||||
|
<img
|
||||||
|
src={tool.iconUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-contain p-0.5"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Wrench className="w-4 h-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Link href={`/tools/${tool.id}`} className="font-medium hover:text-primary hover:underline truncate block">
|
||||||
|
{tool.name}
|
||||||
|
</Link>
|
||||||
|
{!compact && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate max-w-[32rem]">{tool.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{tool.category}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<div className="flex flex-col items-end gap-0.5">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Star className="w-4 h-4 fill-primary text-primary" />
|
||||||
|
<span className="font-medium">{tool.avgCombined ? tool.avgCombined.toFixed(1) : "N/A"}</span>
|
||||||
|
</div>
|
||||||
|
{!compact && <MiniBarStack usefulness={tool.avgUsefulness} usability={tool.avgUsability} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-1 text-muted-foreground">
|
||||||
|
<MessageSquare className="w-4 h-4" />
|
||||||
|
<span>{tool.ratingCount}</span>
|
||||||
|
</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 }
|
||||||
|
|||||||
@@ -53,6 +53,15 @@ function useSidebar() {
|
|||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSidebarCookie(): boolean | null {
|
||||||
|
if (typeof document === "undefined") return null;
|
||||||
|
const match = document.cookie.match(
|
||||||
|
new RegExp(`(?:^|; )${SIDEBAR_COOKIE_NAME}=([^;]*)`)
|
||||||
|
);
|
||||||
|
if (!match) return null;
|
||||||
|
return match[1] === "true";
|
||||||
|
}
|
||||||
|
|
||||||
function SidebarProvider({
|
function SidebarProvider({
|
||||||
defaultOpen = true,
|
defaultOpen = true,
|
||||||
open: openProp,
|
open: openProp,
|
||||||
@@ -71,7 +80,10 @@ function SidebarProvider({
|
|||||||
|
|
||||||
// This is the internal state of the sidebar.
|
// This is the internal state of the sidebar.
|
||||||
// We use openProp and setOpenProp for control from outside the component.
|
// We use openProp and setOpenProp for control from outside the component.
|
||||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
const [_open, _setOpen] = React.useState(() => {
|
||||||
|
const stored = getSidebarCookie()
|
||||||
|
return stored !== null ? stored : defaultOpen
|
||||||
|
})
|
||||||
const open = openProp ?? _open
|
const open = openProp ?? _open
|
||||||
const setOpen = React.useCallback(
|
const setOpen = React.useCallback(
|
||||||
(value: boolean | ((value: boolean) => boolean)) => {
|
(value: boolean | ((value: boolean) => boolean)) => {
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useLocation } from "wouter";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
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";
|
||||||
|
|
||||||
|
function initials(name?: string | null): string {
|
||||||
|
if (!name) return "?";
|
||||||
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length === 0) return "?";
|
||||||
|
return parts
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((p) => p[0]?.toUpperCase() ?? "")
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserMenu() {
|
||||||
|
const [location, navigate] = useLocation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { user, isLoading, isAuthenticated, isAdmin, tier, hasFeature, login, logout } = useAuth();
|
||||||
|
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 showTrash = hasFeature("trash");
|
||||||
|
const displayName = user?.name || user?.preferredUsername || "User";
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<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-4 w-24 group-data-[collapsible=icon]:hidden" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated || !user) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
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)}
|
||||||
|
data-testid="button-login"
|
||||||
|
>
|
||||||
|
<LogIn className="w-4 h-4" />
|
||||||
|
<span className="group-data-[collapsible=icon]:hidden">{t("auth.signIn")}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function go(path: string) {
|
||||||
|
setOpen(false);
|
||||||
|
navigate(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarFallback className="bg-primary/15 text-primary text-xs font-semibold">
|
||||||
|
{initials(displayName)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<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-[11px] text-muted-foreground truncate">
|
||||||
|
{user.email ?? tier}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-64">
|
||||||
|
<DropdownMenuLabel className="flex items-center justify-between gap-2 font-normal">
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-medium text-foreground truncate">{displayName}</span>
|
||||||
|
{user.email && <span className="block text-xs text-muted-foreground truncate">{user.email}</span>}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 text-[10px] uppercase tracking-wider",
|
||||||
|
isAdmin && "border-primary text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isAdmin ? "admin" : tier}
|
||||||
|
</Badge>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{(showWatchlist || showTrash) && (
|
||||||
|
<>
|
||||||
|
{showWatchlist && (
|
||||||
|
<DropdownMenuItem onSelect={() => go("/watchlist")}>
|
||||||
|
<Bookmark className="mr-2 h-4 w-4" />
|
||||||
|
{t("nav.watchlist")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{showTrash && (
|
||||||
|
<DropdownMenuItem onSelect={() => go("/trash")}>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{t("nav.trash")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<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
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={logout}
|
||||||
|
data-testid="button-logout"
|
||||||
|
>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
{t("auth.signOut")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { LayoutGrid, List, Rows3 } from "lucide-react";
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export type ViewMode = "grid" | "table" | "rows";
|
||||||
|
|
||||||
|
const OPTIONS: { value: ViewMode; labelKey: string; Icon: typeof LayoutGrid }[] = [
|
||||||
|
{ value: "grid", labelKey: "common.viewGrid", Icon: LayoutGrid },
|
||||||
|
{ value: "table", labelKey: "common.viewTable", Icon: List },
|
||||||
|
{ value: "rows", labelKey: "common.viewRows", Icon: Rows3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ViewToggle({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
}: {
|
||||||
|
value: ViewMode;
|
||||||
|
onValueChange: (v: ViewMode) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={value}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) onValueChange(v as ViewMode);
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label={t("common.viewMode")}
|
||||||
|
>
|
||||||
|
{OPTIONS.map(({ value: v, labelKey, Icon }) => (
|
||||||
|
<ToggleGroupItem
|
||||||
|
key={v}
|
||||||
|
value={v}
|
||||||
|
aria-label={t(labelKey)}
|
||||||
|
title={t(labelKey)}
|
||||||
|
data-testid={`view-${v}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import {
|
||||||
|
useGetMePreferences,
|
||||||
|
useUpdateMePreferences,
|
||||||
|
getGetMePreferencesQueryKey,
|
||||||
|
type UserPreferences,
|
||||||
|
} from "@workspace/api-client-react";
|
||||||
|
|
||||||
|
export type ViewMode = "grid" | "table" | "rows";
|
||||||
|
export type Density = "cozy" | "compact";
|
||||||
|
|
||||||
|
const LS_KEY = "toolrate:browse-preferences";
|
||||||
|
const VIEWS = new Set<string>(["grid", "table", "rows"]);
|
||||||
|
const DENSITIES = new Set<string>(["cozy", "compact"]);
|
||||||
|
|
||||||
|
export function isViewMode(v: string | null): v is ViewMode {
|
||||||
|
return v !== null && VIEWS.has(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDensity(v: string | null): v is Density {
|
||||||
|
return v !== null && DENSITIES.has(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLocal(): Partial<UserPreferences> {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(LS_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
...(isViewMode(typeof parsed.view === "string" ? parsed.view : null) ? { view: parsed.view as ViewMode } : {}),
|
||||||
|
...(isDensity(typeof parsed.density === "string" ? parsed.density : null) ? { density: parsed.density as Density } : {}),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLocal(prefs: Partial<UserPreferences>) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(LS_KEY, JSON.stringify(prefs));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBrowsePreferences() {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const [localPrefs, setLocalPrefs] = useState(readLocal);
|
||||||
|
const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const { data: serverPrefs } = useGetMePreferences({
|
||||||
|
query: {
|
||||||
|
queryKey: getGetMePreferencesQueryKey(),
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { mutate: updatePrefs } = useUpdateMePreferences();
|
||||||
|
|
||||||
|
function persist(view: ViewMode, density: Density) {
|
||||||
|
const prefs: Partial<UserPreferences> = { view, density };
|
||||||
|
setLocalPrefs(prefs);
|
||||||
|
writeLocal(prefs);
|
||||||
|
if (isAuthenticated) {
|
||||||
|
if (syncTimer.current) clearTimeout(syncTimer.current);
|
||||||
|
syncTimer.current = setTimeout(() => updatePrefs({ data: prefs }), 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (syncTimer.current) clearTimeout(syncTimer.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const serverView = isViewMode(serverPrefs?.view ?? null) ? serverPrefs!.view : undefined;
|
||||||
|
const serverDensity = isDensity(serverPrefs?.density ?? null) ? serverPrefs!.density : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
serverView,
|
||||||
|
serverDensity,
|
||||||
|
localView: localPrefs.view,
|
||||||
|
localDensity: localPrefs.density,
|
||||||
|
persist,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
useGetMePreferences,
|
||||||
|
useUpdateMePreferences,
|
||||||
|
getGetMePreferencesQueryKey,
|
||||||
|
getGetMeWatchlistQueryKey,
|
||||||
|
} from "@workspace/api-client-react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
|
||||||
|
export function useWatchlist() {
|
||||||
|
const { isAuthenticated, hasFeature } = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const canWatchlist = isAuthenticated && hasFeature("watchlist");
|
||||||
|
|
||||||
|
const { data: prefs, isLoading } = useGetMePreferences({
|
||||||
|
query: { queryKey: getGetMePreferencesQueryKey(), enabled: canWatchlist, retry: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const update = useUpdateMePreferences();
|
||||||
|
|
||||||
|
const watchlist = prefs?.watchlist ?? [];
|
||||||
|
|
||||||
|
function invalidate() {
|
||||||
|
queryClient.invalidateQueries({ queryKey: getGetMePreferencesQueryKey() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: getGetMeWatchlistQueryKey() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(id: number) {
|
||||||
|
const next = watchlist.includes(id) ? watchlist.filter((x) => x !== id) : [...watchlist, id];
|
||||||
|
update.mutate(
|
||||||
|
{ data: { watchlist: next } },
|
||||||
|
{
|
||||||
|
onSuccess: () => invalidate(),
|
||||||
|
onError: () => invalidate(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWatched = (id: number) => watchlist.includes(id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
watchlist,
|
||||||
|
toggle,
|
||||||
|
isWatched,
|
||||||
|
canWatchlist,
|
||||||
|
isLoading: canWatchlist && isLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import i18n from "i18next";
|
||||||
|
import { initReactI18next } from "react-i18next";
|
||||||
|
import en from "./locales/en.json";
|
||||||
|
import de from "./locales/de.json";
|
||||||
|
|
||||||
|
export const SUPPORTED_LANGUAGES = [
|
||||||
|
{ code: "en", label: "English" },
|
||||||
|
{ code: "de", label: "Deutsch" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]["code"];
|
||||||
|
|
||||||
|
const STORAGE_KEY = "toolr-language";
|
||||||
|
|
||||||
|
export function getStoredLanguage(): LanguageCode {
|
||||||
|
if (typeof window === "undefined") return "en";
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored === "en" || stored === "de") return stored;
|
||||||
|
const nav = window.navigator.language?.toLowerCase() ?? "";
|
||||||
|
return nav.startsWith("de") ? "de" : "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredLanguage(lang: LanguageCode) {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
void i18n.use(initReactI18next).init({
|
||||||
|
resources: {
|
||||||
|
en: { translation: en },
|
||||||
|
de: { translation: de },
|
||||||
|
},
|
||||||
|
lng: getStoredLanguage(),
|
||||||
|
fallbackLng: "en",
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"name": "toolr",
|
||||||
|
"tagline": "Tool-Bewertungsplattform"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"home": "Start",
|
||||||
|
"browseTools": "Tools durchsuchen",
|
||||||
|
"addTool": "Tool hinzufügen",
|
||||||
|
"analytics": "Analysen",
|
||||||
|
"watchlist": "Merkliste",
|
||||||
|
"trash": "Papierkorb",
|
||||||
|
"admin": "Admin",
|
||||||
|
"redundancy": "Redundanz",
|
||||||
|
"docs": "Hilfe",
|
||||||
|
"search": "Tools suchen…"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"signIn": "Anmelden",
|
||||||
|
"signInKeycloak": "Mit Keycloak anmelden",
|
||||||
|
"signOut": "Abmelden",
|
||||||
|
"username": "Benutzername",
|
||||||
|
"password": "Passwort",
|
||||||
|
"login": "Anmelden",
|
||||||
|
"invalidCredentials": "Ungültiger Benutzername oder ungültiges Passwort",
|
||||||
|
"notAuthenticated": "Nicht angemeldet",
|
||||||
|
"loginSubtitle": "Melde dich bei deinem Konto an",
|
||||||
|
"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": {
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"save": "Speichern",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"clear": "Leeren",
|
||||||
|
"clearAll": "Alle löschen",
|
||||||
|
"search": "Suchen",
|
||||||
|
"add": "Hinzufügen",
|
||||||
|
"back": "Zurück",
|
||||||
|
"backToTools": "Zurück zu den Tools",
|
||||||
|
"browseTools": "Tools durchsuchen",
|
||||||
|
"viewAll": "Alle anzeigen",
|
||||||
|
"loading": "Wird geladen…",
|
||||||
|
"all": "Alle",
|
||||||
|
"none": "Keine",
|
||||||
|
"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": {
|
||||||
|
"welcome": "Willkommen bei toolr",
|
||||||
|
"tagline": "Bewerte dein Tool.",
|
||||||
|
"totalTools": "Tools gesamt",
|
||||||
|
"totalRatings": "Bewertungen gesamt",
|
||||||
|
"avgRating": "Ø Bewertung",
|
||||||
|
"topRated": "Bestbewertete Tools",
|
||||||
|
"topRatedSub": "Höchste kombinierte Nützlichkeit und Benutzerfreundlichkeit.",
|
||||||
|
"recentlyAdded": "Zuletzt hinzugefügt",
|
||||||
|
"recentlyAddedSub": "Die neuesten Ergänzungen im Katalog.",
|
||||||
|
"noRatingsYet": "Noch keine Bewertungen",
|
||||||
|
"noRatingsSub": "Bewerte als Erster ein Tool und hilf der Community.",
|
||||||
|
"noTools": "Keine Tools gefunden",
|
||||||
|
"noToolsSub": "Es sind noch keine Tools in der Datenbank vorhanden."
|
||||||
|
},
|
||||||
|
"browse": {
|
||||||
|
"filters": "Filter",
|
||||||
|
"subtitle": "Entdecke und bewerte die besten Tools für deinen Stack.",
|
||||||
|
"allCategories": "Alle Kategorien",
|
||||||
|
"showingTool_one": "{{count}} Tool wird angezeigt",
|
||||||
|
"showingTool_other": "{{count}} Tools werden angezeigt",
|
||||||
|
"tool": "Tool",
|
||||||
|
"category": "Kategorie",
|
||||||
|
"rating": "Bewertung",
|
||||||
|
"reviews": "Bewertungen",
|
||||||
|
"reviewCount_one": "{{count}} Bewertung",
|
||||||
|
"reviewCount_other": "{{count}} Bewertungen",
|
||||||
|
"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.",
|
||||||
|
"addTool": "Tool hinzufügen",
|
||||||
|
"compare": "Vergleichen",
|
||||||
|
"compareCount": "Vergleichen ({{count}})",
|
||||||
|
"addToCompare": "Zum Vergleich hinzufügen",
|
||||||
|
"removeFromCompare": "Aus dem Vergleich entfernen",
|
||||||
|
"comparePremiumTitle": "Vergleichen ist ein Premium-Feature",
|
||||||
|
"comparePremiumSub": "Das Vergleichen von Tools nebeneinander ist für Premium- und Enterprise-Nutzer verfügbar. Erweitere deinen Plan, um es freizuschalten.",
|
||||||
|
"notNow": "Später",
|
||||||
|
"upgradePremium": "Auf Premium upgraden",
|
||||||
|
"sortNewest": "Neueste",
|
||||||
|
"sortTopRated": "Bestbewertet",
|
||||||
|
"sortMostReviewed": "Meiste Bewertungen",
|
||||||
|
"sortNameAsc": "Name (A–Z)",
|
||||||
|
"sortNameDesc": "Name (Z–A)",
|
||||||
|
"sortRecentlyUpdated": "Zuletzt aktualisiert"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"tags": "Tags",
|
||||||
|
"features": "Funktionen",
|
||||||
|
"minRating": "Mindestbewertung",
|
||||||
|
"any": "Beliebig",
|
||||||
|
"clearFilters": "Filter zurücksetzen",
|
||||||
|
"removeFeature": "Funktion entfernen: {{feature}}",
|
||||||
|
"removeMinRating": "Mindestbewertung entfernen"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"ratingBreakdown": "Bewertungsaufschlüsselung",
|
||||||
|
"usefulness": "Nützlichkeit",
|
||||||
|
"usability": "Benutzerfreundlichkeit",
|
||||||
|
"scoreDistribution": "Punkteverteilung",
|
||||||
|
"scoreTrend": "Punkteverlauf",
|
||||||
|
"notEnoughRatings": "Noch nicht genügend Bewertungen für einen Verlauf.",
|
||||||
|
"reviews": "Bewertungen",
|
||||||
|
"basedOnReview_one": "Basierend auf {{count}} Bewertung",
|
||||||
|
"basedOnReview_other": "Basierend auf {{count}} Bewertungen",
|
||||||
|
"saveToWatchlist": "Zur Merkliste hinzufügen",
|
||||||
|
"savedToWatchlist": "Zur Merkliste hinzugefügt",
|
||||||
|
"removeFromWatchlist": "Von der Merkliste entfernen",
|
||||||
|
"visitWebsite": "Website besuchen",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"noReviews": "Noch keine Bewertungen.",
|
||||||
|
"addReview": "Bewertung hinzufügen",
|
||||||
|
"yourRating": "Deine Bewertung",
|
||||||
|
"submit": "Bewertung absenden",
|
||||||
|
"relatedTools": "Ähnliche Tools",
|
||||||
|
"costs": "Kosten",
|
||||||
|
"addCost": "Kosten hinzufügen",
|
||||||
|
"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 Benutzer",
|
||||||
|
"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": {
|
||||||
|
"title": "Tools vergleichen",
|
||||||
|
"subtitle": "{{count}} Tool(s) werden nebeneinander verglichen.",
|
||||||
|
"sideBySide": "Vergleich nebeneinander",
|
||||||
|
"bestValue": "Der beste Wert jeder Zeile ist hervorgehoben.",
|
||||||
|
"attribute": "Eigenschaft",
|
||||||
|
"rating": "Bewertung",
|
||||||
|
"reviews": "Bewertungen",
|
||||||
|
"description": "Beschreibung",
|
||||||
|
"lastUpdated": "Zuletzt aktualisiert",
|
||||||
|
"requiresPremium": "Vergleichen erfordert einen höheren Tarif",
|
||||||
|
"requiresPremiumSub": "Das Vergleichen von Tools nebeneinander ist für Premium- und Enterprise-Nutzer verfügbar.",
|
||||||
|
"nothingToCompare": "Nichts zu vergleichen",
|
||||||
|
"nothingToCompareSub": "Wähle mindestens ein Tool aus und kehre hierher zurück.",
|
||||||
|
"backToBrowse": "Zurück zur Übersicht"
|
||||||
|
},
|
||||||
|
"watchlist": {
|
||||||
|
"title": "Merkliste",
|
||||||
|
"subtitle": "Tools, die du für später gespeichert hast, mit Live-Punkten.",
|
||||||
|
"requiresPremium": "Die Merkliste erfordert einen höheren Tarif",
|
||||||
|
"requiresPremiumSub": "Das Speichern von Tools in deiner Merkliste ist für Premium- und Enterprise-Nutzer verfügbar.",
|
||||||
|
"empty": "Deine Merkliste ist leer",
|
||||||
|
"emptySub": "Durchsuche die Tools und klicke auf das Lesezeichen, um sie hier zu speichern.",
|
||||||
|
"remove": "Von der Merkliste entfernen"
|
||||||
|
},
|
||||||
|
"trash": {
|
||||||
|
"title": "Papierkorb",
|
||||||
|
"subtitle": "Gelöschte Tools werden hier aufbewahrt, bis sie wiederhergestellt oder endgültig entfernt werden.",
|
||||||
|
"requiresPremium": "Der Papierkorb erfordert einen höheren Tarif",
|
||||||
|
"requiresPremiumSub": "Der Papierkorb ist für Premium- und Enterprise-Nutzer verfügbar.",
|
||||||
|
"empty": "Der Papierkorb ist leer.",
|
||||||
|
"noMatch": "Keine Tools entsprechen deiner Suche.",
|
||||||
|
"trashedTools": "Gelöschte Tools",
|
||||||
|
"toolsInTrash_one": "{{count}} Tool im Papierkorb",
|
||||||
|
"toolsInTrash_other": "{{count}} Tools im Papierkorb",
|
||||||
|
"selected_one": "{{count}} ausgewählt",
|
||||||
|
"selected_other": "{{count}} ausgewählt",
|
||||||
|
"restore": "Wiederherstellen",
|
||||||
|
"deletePermanently": "Endgültig löschen",
|
||||||
|
"emptyTrash": "Papierkorb leeren",
|
||||||
|
"deletedAt": "Gelöscht am",
|
||||||
|
"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": {
|
||||||
|
"text": "Diese Seite existiert nicht.",
|
||||||
|
"backHome": "Zurück zur Startseite"
|
||||||
|
},
|
||||||
|
"docs": {
|
||||||
|
"title": "Dokumentation",
|
||||||
|
"subtitle": "Version-gebundene Dokumentation — Release-Notes, Endpunkte und Datenfelder je Version.",
|
||||||
|
"backToIndex": "Alle Releases",
|
||||||
|
"noDocs": "Keine Dokumentation für diesen Pfad verfügbar.",
|
||||||
|
"version": "Version",
|
||||||
|
"latest": "Aktuell",
|
||||||
|
"repo": "Repository",
|
||||||
|
"nav": "Dokumentation",
|
||||||
|
"guides": "Handbuch",
|
||||||
|
"endpoints": "Endpunkte",
|
||||||
|
"schemas": "Datenmodelle",
|
||||||
|
"releases": "Release-Notes",
|
||||||
|
"reference": "API-Referenz",
|
||||||
|
"referenceIntro": "Automatisch aus der OpenAPI-Spezifikation generiert — alle Endpunkte und Datenfelder der aktuellen Version.",
|
||||||
|
"onThisPage": "Auf dieser Seite",
|
||||||
|
"searchPlaceholder": "Doku durchsuchen…",
|
||||||
|
"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": {
|
||||||
|
"navigate": "Navigation",
|
||||||
|
"recent": "Zuletzt besucht",
|
||||||
|
"tools": "Tools",
|
||||||
|
"noResults": "Keine Tools für „{{query}}“ gefunden.",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"name": "toolr",
|
||||||
|
"tagline": "Tool-Bewertungsplattform"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"home": "Home",
|
||||||
|
"browseTools": "Browse Tools",
|
||||||
|
"addTool": "Add Tool",
|
||||||
|
"analytics": "Analytics",
|
||||||
|
"watchlist": "Watchlist",
|
||||||
|
"trash": "Trash",
|
||||||
|
"admin": "Admin",
|
||||||
|
"redundancy": "Redundancy",
|
||||||
|
"docs": "Help",
|
||||||
|
"search": "Search tools…"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"signIn": "Sign in",
|
||||||
|
"signInKeycloak": "Sign in with Keycloak",
|
||||||
|
"signOut": "Sign out",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"login": "Login",
|
||||||
|
"invalidCredentials": "Invalid username or password",
|
||||||
|
"notAuthenticated": "Not authenticated",
|
||||||
|
"loginSubtitle": "Sign in to your account",
|
||||||
|
"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": {
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"clear": "Clear",
|
||||||
|
"clearAll": "Clear all",
|
||||||
|
"search": "Search",
|
||||||
|
"add": "Add",
|
||||||
|
"back": "Back",
|
||||||
|
"backToTools": "Back to tools",
|
||||||
|
"browseTools": "Browse tools",
|
||||||
|
"viewAll": "View all",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"all": "All",
|
||||||
|
"none": "None",
|
||||||
|
"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": {
|
||||||
|
"welcome": "Welcome to toolr",
|
||||||
|
"tagline": "Rate your Tool.",
|
||||||
|
"totalTools": "Total Tools",
|
||||||
|
"totalRatings": "Total Ratings",
|
||||||
|
"avgRating": "Avg Rating",
|
||||||
|
"topRated": "Top Rated Tools",
|
||||||
|
"topRatedSub": "Highest combined usefulness and usability.",
|
||||||
|
"recentlyAdded": "Recently Added",
|
||||||
|
"recentlyAddedSub": "The latest additions to the catalog.",
|
||||||
|
"noRatingsYet": "No ratings yet",
|
||||||
|
"noRatingsSub": "Be the first to rate a tool and help the community.",
|
||||||
|
"noTools": "No tools found",
|
||||||
|
"noToolsSub": "There are no tools in the database yet."
|
||||||
|
},
|
||||||
|
"browse": {
|
||||||
|
"filters": "Filters",
|
||||||
|
"subtitle": "Discover and evaluate the best tools for your stack.",
|
||||||
|
"allCategories": "All Categories",
|
||||||
|
"showingTool_one": "Showing {{count}} tool",
|
||||||
|
"showingTool_other": "Showing {{count}} tools",
|
||||||
|
"tool": "Tool",
|
||||||
|
"category": "Category",
|
||||||
|
"rating": "Rating",
|
||||||
|
"reviews": "Reviews",
|
||||||
|
"reviewCount_one": "{{count}} review",
|
||||||
|
"reviewCount_other": "{{count}} reviews",
|
||||||
|
"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.",
|
||||||
|
"addTool": "Add Tool",
|
||||||
|
"compare": "Compare",
|
||||||
|
"compareCount": "Compare ({{count}})",
|
||||||
|
"addToCompare": "Add to compare",
|
||||||
|
"removeFromCompare": "Remove from compare",
|
||||||
|
"comparePremiumTitle": "Compare is a Premium feature",
|
||||||
|
"comparePremiumSub": "Comparing tools side-by-side is available to Premium and Enterprise users. Upgrade your plan to unlock it.",
|
||||||
|
"notNow": "Not now",
|
||||||
|
"upgradePremium": "Upgrade to Premium",
|
||||||
|
"sortNewest": "Newest",
|
||||||
|
"sortTopRated": "Top Rated",
|
||||||
|
"sortMostReviewed": "Most Reviewed",
|
||||||
|
"sortNameAsc": "Name (A–Z)",
|
||||||
|
"sortNameDesc": "Name (Z–A)",
|
||||||
|
"sortRecentlyUpdated": "Recently Updated"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"tags": "Tags",
|
||||||
|
"features": "Features",
|
||||||
|
"minRating": "Min. rating",
|
||||||
|
"any": "Any",
|
||||||
|
"clearFilters": "Clear filters",
|
||||||
|
"removeFeature": "Remove feature: {{feature}}",
|
||||||
|
"removeMinRating": "Remove min rating"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"ratingBreakdown": "Rating Breakdown",
|
||||||
|
"usefulness": "Usefulness",
|
||||||
|
"usability": "Usability",
|
||||||
|
"scoreDistribution": "Score Distribution",
|
||||||
|
"scoreTrend": "Score Trend",
|
||||||
|
"notEnoughRatings": "Not enough ratings yet to show a trend.",
|
||||||
|
"reviews": "Reviews",
|
||||||
|
"basedOnReview_one": "Based on {{count}} review",
|
||||||
|
"basedOnReview_other": "Based on {{count}} reviews",
|
||||||
|
"saveToWatchlist": "Save to watchlist",
|
||||||
|
"savedToWatchlist": "Saved to watchlist",
|
||||||
|
"removeFromWatchlist": "Remove from watchlist",
|
||||||
|
"visitWebsite": "Visit Website",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"noReviews": "No reviews yet.",
|
||||||
|
"addReview": "Add Review",
|
||||||
|
"yourRating": "Your Rating",
|
||||||
|
"submit": "Submit Rating",
|
||||||
|
"relatedTools": "Related Tools",
|
||||||
|
"costs": "Costs",
|
||||||
|
"addCost": "Add Cost",
|
||||||
|
"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 User",
|
||||||
|
"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": {
|
||||||
|
"title": "Compare Tools",
|
||||||
|
"subtitle": "Comparing {{count}} tool(s) side-by-side.",
|
||||||
|
"sideBySide": "Side-by-side comparison",
|
||||||
|
"bestValue": "Best value in each row is highlighted.",
|
||||||
|
"attribute": "Attribute",
|
||||||
|
"rating": "Rating",
|
||||||
|
"reviews": "Reviews",
|
||||||
|
"description": "Description",
|
||||||
|
"lastUpdated": "Last updated",
|
||||||
|
"requiresPremium": "Compare requires a higher tier",
|
||||||
|
"requiresPremiumSub": "Compare tools side-by-side is available to Premium and Enterprise users.",
|
||||||
|
"nothingToCompare": "Nothing to compare",
|
||||||
|
"nothingToCompareSub": "Select at least one tool to compare and come back here.",
|
||||||
|
"backToBrowse": "Back to browse"
|
||||||
|
},
|
||||||
|
"watchlist": {
|
||||||
|
"title": "Watchlist",
|
||||||
|
"subtitle": "Tools you saved for later, with live scores.",
|
||||||
|
"requiresPremium": "Watchlist requires a higher tier",
|
||||||
|
"requiresPremiumSub": "Saving tools to your watchlist is available to Premium and Enterprise users.",
|
||||||
|
"empty": "Your watchlist is empty",
|
||||||
|
"emptySub": "Browse tools and click the bookmark to save them here.",
|
||||||
|
"remove": "Remove from watchlist"
|
||||||
|
},
|
||||||
|
"trash": {
|
||||||
|
"title": "Trash",
|
||||||
|
"subtitle": "Deleted tools are kept here until they are restored or permanently removed.",
|
||||||
|
"requiresPremium": "Trash requires a higher tier",
|
||||||
|
"requiresPremiumSub": "The trash is available to Premium and Enterprise users.",
|
||||||
|
"empty": "The trash is empty.",
|
||||||
|
"noMatch": "No tools match your search.",
|
||||||
|
"trashedTools": "Trashed Tools",
|
||||||
|
"toolsInTrash_one": "{{count}} tool in trash",
|
||||||
|
"toolsInTrash_other": "{{count}} tools in trash",
|
||||||
|
"selected_one": "{{count}} selected",
|
||||||
|
"selected_other": "{{count}} selected",
|
||||||
|
"restore": "Restore",
|
||||||
|
"deletePermanently": "Delete permanently",
|
||||||
|
"emptyTrash": "Empty trash",
|
||||||
|
"deletedAt": "Deleted at",
|
||||||
|
"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": {
|
||||||
|
"text": "This page doesn't exist.",
|
||||||
|
"backHome": "Back to Home"
|
||||||
|
},
|
||||||
|
"docs": {
|
||||||
|
"title": "Documentation",
|
||||||
|
"subtitle": "Version-bound documentation — release notes, endpoints and data fields per version.",
|
||||||
|
"backToIndex": "All releases",
|
||||||
|
"noDocs": "No documentation available for this path.",
|
||||||
|
"version": "Version",
|
||||||
|
"latest": "Latest",
|
||||||
|
"repo": "Repository",
|
||||||
|
"nav": "Documentation",
|
||||||
|
"guides": "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": {
|
||||||
|
"navigate": "Navigate",
|
||||||
|
"recent": "Recent",
|
||||||
|
"tools": "Tools",
|
||||||
|
"noResults": "No tools found for \"{{query}}\".",
|
||||||
|
"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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
const STORAGE_KEY = "toolrate-recent";
|
||||||
|
const MAX_ITEMS = 5;
|
||||||
|
|
||||||
|
export type RecentTool = { id: number; name: string };
|
||||||
|
|
||||||
|
export function getRecentTools(): RecentTool[] {
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed
|
||||||
|
.filter(
|
||||||
|
(item): item is RecentTool =>
|
||||||
|
!!item && typeof item.id === "number" && typeof item.name === "string",
|
||||||
|
)
|
||||||
|
.slice(0, MAX_ITEMS);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordRecentTool(id: number, name: string) {
|
||||||
|
if (typeof window === "undefined" || !Number.isFinite(id) || !name) return;
|
||||||
|
try {
|
||||||
|
const next = [{ id, name }, ...getRecentTools().filter((t) => t.id !== id)].slice(0, MAX_ITEMS);
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
useListCompareTools,
|
||||||
|
getListCompareToolsQueryKey,
|
||||||
|
type ToolWithStats,
|
||||||
|
} from "@workspace/api-client-react";
|
||||||
|
import { Link, useSearch } from "wouter";
|
||||||
|
import { Layout } from "@/components/layout";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { RatingStars } from "@/components/rating-stars";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { ShieldAlert, Trophy, Scale } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
function fmt(v: number | null | undefined): string {
|
||||||
|
return v != null ? v.toFixed(1) : "–";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Compare() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { hasFeature, isLoading: authLoading } = useAuth();
|
||||||
|
const search = useSearch();
|
||||||
|
|
||||||
|
const params = useMemo(() => {
|
||||||
|
const ids = new URLSearchParams(search)
|
||||||
|
.get("ids")
|
||||||
|
?.split(",")
|
||||||
|
.map((s) => Number(s.trim()))
|
||||||
|
.filter((n) => Number.isInteger(n) && n > 0);
|
||||||
|
return ids && ids.length > 0 ? { ids: ids.join(",") } : undefined;
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const canCompare = hasFeature("compare");
|
||||||
|
|
||||||
|
const { data: tools, isLoading: loading } = useListCompareTools(
|
||||||
|
params ?? { ids: "" },
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
queryKey: getListCompareToolsQueryKey(params ?? { ids: "" }),
|
||||||
|
enabled: !!params && canCompare,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!authLoading && !canCompare) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||||
|
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
|
||||||
|
<h2 className="text-2xl font-bold">{t("compare.requiresPremium")}</h2>
|
||||||
|
<p className="text-muted-foreground">{t("compare.requiresPremiumSub")}</p>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href="/tools">{t("common.backToTools")}</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = tools ?? [];
|
||||||
|
if (list.length === 0) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||||
|
<Scale className="w-12 h-12 text-muted-foreground" />
|
||||||
|
<h2 className="text-2xl font-bold">{t("compare.nothingToCompare")}</h2>
|
||||||
|
<p className="text-muted-foreground">{t("compare.nothingToCompareSub")}</p>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href="/tools">{t("common.browseTools")}</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bestRating = Math.max(...list.map((t) => t.avgCombined ?? 0));
|
||||||
|
const bestUsefulness = Math.max(...list.map((t) => t.avgUsefulness ?? 0));
|
||||||
|
const bestUsability = Math.max(...list.map((t) => t.avgUsability ?? 0));
|
||||||
|
const bestReviews = Math.max(...list.map((t) => t.ratingCount));
|
||||||
|
|
||||||
|
const isBest = (value: number | null, best: number) =>
|
||||||
|
value != null && value > 0 && value >= best && value === best;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="space-y-6 pb-10">
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("compare.sideBySide")}</CardTitle>
|
||||||
|
<CardDescription>{t("compare.bestValue")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-44">{t("compare.attribute")}</TableHead>
|
||||||
|
{list.map((t) => (
|
||||||
|
<TableHead key={t.id} className="min-w-[12rem]">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Link to={`/tools/${t.id}`} className="font-semibold hover:text-primary hover:underline">
|
||||||
|
{t.name}
|
||||||
|
</Link>
|
||||||
|
<Badge variant="outline" className="w-fit text-[10px]">{t.category}</Badge>
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-medium">{t("compare.rating")}</TableCell>
|
||||||
|
{list.map((t) => (
|
||||||
|
<TableCell key={t.id} className={cn(isBest(t.avgCombined, bestRating) && "bg-primary/5")}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RatingStars value={t.avgCombined ?? 0} size="sm" />
|
||||||
|
<span className="font-semibold tabular-nums">{fmt(t.avgCombined)}/5</span>
|
||||||
|
{isBest(t.avgCombined, bestRating) && <Trophy className="w-3.5 h-3.5 text-primary" />}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-medium">{t("detail.usefulness")}</TableCell>
|
||||||
|
{list.map((t) => (
|
||||||
|
<TableCell key={t.id} className={cn(isBest(t.avgUsefulness, bestUsefulness) && "bg-primary/5")}>
|
||||||
|
<span className="tabular-nums">{fmt(t.avgUsefulness)}/5</span>
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-medium">{t("detail.usability")}</TableCell>
|
||||||
|
{list.map((t) => (
|
||||||
|
<TableCell key={t.id} className={cn(isBest(t.avgUsability, bestUsability) && "bg-primary/5")}>
|
||||||
|
<span className="tabular-nums">{fmt(t.avgUsability)}/5</span>
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-medium">{t("compare.reviews")}</TableCell>
|
||||||
|
{list.map((t) => (
|
||||||
|
<TableCell key={t.id} className={cn(isBest(t.ratingCount, bestReviews) && "bg-primary/5")}>
|
||||||
|
<span className="tabular-nums">{t.ratingCount}</span>
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-medium">{t("compare.description")}</TableCell>
|
||||||
|
{list.map((t) => (
|
||||||
|
<TableCell key={t.id} className="text-sm text-muted-foreground align-top">
|
||||||
|
{t.description}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-medium">{t("filter.features")}</TableCell>
|
||||||
|
{list.map((t) => (
|
||||||
|
<TableCell key={t.id} className="align-top">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(t.features ?? []).map((f) => (
|
||||||
|
<Badge key={f} variant="secondary" className="text-[10px]">{f}</Badge>
|
||||||
|
))}
|
||||||
|
{(t.features?.length ?? 0) === 0 && <span className="text-muted-foreground">—</span>}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-medium">{t("filter.tags")}</TableCell>
|
||||||
|
{list.map((t) => (
|
||||||
|
<TableCell key={t.id} className="align-top">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(t.tags ?? []).map((tag) => (
|
||||||
|
<Badge key={tag} variant="outline" className="text-[10px]">{tag}</Badge>
|
||||||
|
))}
|
||||||
|
{(t.tags?.length ?? 0) === 0 && <span className="text-muted-foreground">—</span>}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-medium">{t("compare.lastUpdated")}</TableCell>
|
||||||
|
{list.map((t) => (
|
||||||
|
<TableCell key={t.id} className="text-sm text-muted-foreground">
|
||||||
|
{new Date(t.updatedAt).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link to="/tools">{t("compare.backToBrowse")}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -8,11 +8,13 @@ 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";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: summary, isLoading: loadingSummary } = useGetAnalyticsSummary();
|
const { data: summary, isLoading: loadingSummary } = useGetAnalyticsSummary();
|
||||||
const { data: topTools, isLoading: loadingTopTools } = useGetTopTools({ limit: 4, metric: GetTopToolsMetric.combined });
|
const { data: topTools, isLoading: loadingTopTools } = useGetTopTools({ limit: 4, metric: GetTopToolsMetric.combined });
|
||||||
const { data: recentTools, isLoading: loadingRecent } = useListTools({ sort: "newest" });
|
const { data: recentTools, isLoading: loadingRecent } = useListTools({ sort: "newest" });
|
||||||
@@ -20,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">Welcome to toolr</h1>
|
<div>
|
||||||
<p className="text-muted-foreground">The community hub where engineers honestly rate the tools they use daily.</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 */}
|
||||||
@@ -33,7 +45,7 @@ export default function Home() {
|
|||||||
<Wrench className="w-6 h-6" />
|
<Wrench className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground mb-1">Total Tools</p>
|
<p className="text-sm font-medium text-muted-foreground mb-1">{t("home.totalTools")}</p>
|
||||||
{loadingSummary ? (
|
{loadingSummary ? (
|
||||||
<Skeleton className="h-8 w-16" />
|
<Skeleton className="h-8 w-16" />
|
||||||
) : (
|
) : (
|
||||||
@@ -49,7 +61,7 @@ export default function Home() {
|
|||||||
<MessageSquare className="w-6 h-6" />
|
<MessageSquare className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground mb-1">Total Ratings</p>
|
<p className="text-sm font-medium text-muted-foreground mb-1">{t("home.totalRatings")}</p>
|
||||||
{loadingSummary ? (
|
{loadingSummary ? (
|
||||||
<Skeleton className="h-8 w-16" />
|
<Skeleton className="h-8 w-16" />
|
||||||
) : (
|
) : (
|
||||||
@@ -65,7 +77,7 @@ export default function Home() {
|
|||||||
<Star className="w-6 h-6" />
|
<Star className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground mb-1">Avg Rating</p>
|
<p className="text-sm font-medium text-muted-foreground mb-1">{t("home.avgRating")}</p>
|
||||||
{loadingSummary ? (
|
{loadingSummary ? (
|
||||||
<Skeleton className="h-8 w-16" />
|
<Skeleton className="h-8 w-16" />
|
||||||
) : (
|
) : (
|
||||||
@@ -80,12 +92,12 @@ export default function Home() {
|
|||||||
<section>
|
<section>
|
||||||
<div className="flex justify-between items-end mb-4">
|
<div className="flex justify-between items-end mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight mb-1">Top Rated Tools</h2>
|
<h2 className="text-2xl font-bold tracking-tight mb-1">{t("home.topRated")}</h2>
|
||||||
<p className="text-muted-foreground text-sm">Highest combined usefulness and usability.</p>
|
<p className="text-muted-foreground text-sm">{t("home.topRatedSub")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" className="text-primary hidden sm:flex" asChild>
|
<Button variant="ghost" className="text-primary hidden sm:flex" asChild>
|
||||||
<Link href="/tools?sort=top_rated">
|
<Link href="/tools?sort=top_rated">
|
||||||
View all <ArrowRight className="w-4 h-4 ml-2" />
|
{t("common.viewAll")} <ArrowRight className="w-4 h-4 ml-2" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,10 +118,10 @@ export default function Home() {
|
|||||||
<Card className="bg-muted/30 border-dashed">
|
<Card className="bg-muted/30 border-dashed">
|
||||||
<CardContent className="flex flex-col items-center justify-center p-12 text-center">
|
<CardContent className="flex flex-col items-center justify-center p-12 text-center">
|
||||||
<Star className="w-12 h-12 text-muted-foreground/30 mb-4" />
|
<Star className="w-12 h-12 text-muted-foreground/30 mb-4" />
|
||||||
<h3 className="text-lg font-medium">No ratings yet</h3>
|
<h3 className="text-lg font-medium">{t("home.noRatingsYet")}</h3>
|
||||||
<p className="text-muted-foreground max-w-sm mt-1 mb-4">Be the first to rate a tool and help the community.</p>
|
<p className="text-muted-foreground max-w-sm mt-1 mb-4">{t("home.noRatingsSub")}</p>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href="/tools">Browse Tools</Link>
|
<Link href="/tools">{t("common.browseTools")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -120,12 +132,12 @@ export default function Home() {
|
|||||||
<section>
|
<section>
|
||||||
<div className="flex justify-between items-end mb-4">
|
<div className="flex justify-between items-end mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight mb-1">Recently Added</h2>
|
<h2 className="text-2xl font-bold tracking-tight mb-1">{t("home.recentlyAdded")}</h2>
|
||||||
<p className="text-muted-foreground text-sm">The latest additions to the catalog.</p>
|
<p className="text-muted-foreground text-sm">{t("home.recentlyAddedSub")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" className="text-primary hidden sm:flex" asChild>
|
<Button variant="ghost" className="text-primary hidden sm:flex" asChild>
|
||||||
<Link href="/tools?sort=newest">
|
<Link href="/tools?sort=newest">
|
||||||
View all <ArrowRight className="w-4 h-4 ml-2" />
|
{t("common.viewAll")} <ArrowRight className="w-4 h-4 ml-2" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,10 +158,10 @@ export default function Home() {
|
|||||||
<Card className="bg-muted/30 border-dashed">
|
<Card className="bg-muted/30 border-dashed">
|
||||||
<CardContent className="flex flex-col items-center justify-center p-12 text-center">
|
<CardContent className="flex flex-col items-center justify-center p-12 text-center">
|
||||||
<Wrench className="w-12 h-12 text-muted-foreground/30 mb-4" />
|
<Wrench className="w-12 h-12 text-muted-foreground/30 mb-4" />
|
||||||
<h3 className="text-lg font-medium">No tools found</h3>
|
<h3 className="text-lg font-medium">{t("home.noTools")}</h3>
|
||||||
<p className="text-muted-foreground max-w-sm mt-1 mb-4">There are no tools in the database yet.</p>
|
<p className="text-muted-foreground max-w-sm mt-1 mb-4">{t("home.noToolsSub")}</p>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href="/tools/new">Add a Tool</Link>
|
<Link href="/tools/new">{t("browse.addTool")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ 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";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const search = useSearch();
|
const search = useSearch();
|
||||||
const params = new URLSearchParams(search);
|
const params = new URLSearchParams(search);
|
||||||
@@ -30,10 +34,11 @@ export default function Login() {
|
|||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries();
|
queryClient.invalidateQueries();
|
||||||
|
void loadCsrfToken();
|
||||||
setLocation(returnTo);
|
setLocation(returnTo);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
setError(err.data?.error || err.message || "Invalid username or password");
|
setError(err.data?.error || err.message || t("auth.invalidCredentials"));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -50,18 +55,18 @@ export default function Login() {
|
|||||||
<Wrench className="w-7 h-7" />
|
<Wrench className="w-7 h-7" />
|
||||||
<span>toolr</span>
|
<span>toolr</span>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-muted-foreground text-sm">Sign in to your account</p>
|
<p className="text-muted-foreground text-sm">{t("auth.loginSubtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<CardTitle className="text-lg">Sign in</CardTitle>
|
<CardTitle className="text-lg">{t("auth.signIn")}</CardTitle>
|
||||||
<CardDescription>Enter your credentials to continue</CardDescription>
|
<CardDescription>{t("auth.loginDescription")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="username">Username</Label>
|
<Label htmlFor="username">{t("auth.username")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -73,10 +78,9 @@ export default function Login() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">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
|
||||||
@@ -95,7 +99,7 @@ export default function Login() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={localLogin.isPending}
|
disabled={localLogin.isPending}
|
||||||
>
|
>
|
||||||
{localLogin.isPending ? "Signing in..." : "Sign in"}
|
{localLogin.isPending ? t("common.loading") : t("auth.signIn")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -103,7 +107,7 @@ export default function Login() {
|
|||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Link href="/" className="text-sm text-muted-foreground hover:text-primary transition-colors">
|
<Link href="/" className="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||||
Back to Home
|
{t("notFound.backHome")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Wrench, ArrowLeft } from "lucide-react";
|
import { Wrench, ArrowLeft } from "lucide-react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen w-full flex items-center justify-center bg-background">
|
<div className="min-h-screen w-full flex items-center justify-center bg-background">
|
||||||
<Card className="w-full max-w-md mx-4">
|
<Card className="w-full max-w-md mx-4">
|
||||||
@@ -13,10 +15,10 @@ export default function NotFound() {
|
|||||||
<span>toolr</span>
|
<span>toolr</span>
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-6xl font-bold text-muted-foreground/30 mb-2">404</h1>
|
<h1 className="text-6xl font-bold text-muted-foreground/30 mb-2">404</h1>
|
||||||
<p className="text-muted-foreground mb-6">This page doesn't exist.</p>
|
<p className="text-muted-foreground mb-6">{t("notFound.text")}</p>
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<Button variant="outline" className="gap-2">
|
<Button variant="outline" className="gap-2">
|
||||||
<ArrowLeft className="w-4 h-4" /> Back to Home
|
<ArrowLeft className="w-4 h-4" /> {t("notFound.backHome")}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
getListToolRatingsQueryKey,
|
getListToolRatingsQueryKey,
|
||||||
useGetRatingDistribution,
|
useGetRatingDistribution,
|
||||||
getGetRatingDistributionQueryKey,
|
getGetRatingDistributionQueryKey,
|
||||||
|
useGetToolRatingHistory,
|
||||||
|
getGetToolRatingHistoryQueryKey,
|
||||||
useCreateRating,
|
useCreateRating,
|
||||||
getGetTopToolsQueryKey,
|
getGetTopToolsQueryKey,
|
||||||
getGetAnalyticsSummaryQueryKey,
|
getGetAnalyticsSummaryQueryKey,
|
||||||
@@ -38,10 +40,13 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, Legend } from "recharts";
|
||||||
import { ExternalLink, Star, ArrowLeft, Plus, Pencil, Trash2, Link as LinkIcon, DollarSign, Euro } from "lucide-react";
|
import { ExternalLink, Star, ArrowLeft, Plus, Pencil, Trash2, Link as LinkIcon, DollarSign, Euro, Bookmark } from "lucide-react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { useWatchlist } from "@/hooks/use-watchlist";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useDeleteTool, getListToolsQueryKey } from "@workspace/api-client-react";
|
import { useDeleteTool, getListToolsQueryKey } from "@workspace/api-client-react";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -54,6 +59,8 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} 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 { 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),
|
||||||
@@ -75,6 +82,8 @@ export default function ToolDetail() {
|
|||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
||||||
const { user, isAdmin, hasFeature } = useAuth();
|
const { user, isAdmin, hasFeature } = useAuth();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isWatched, toggle: toggleWatchlist, canWatchlist } = useWatchlist();
|
||||||
const canManageCosts = hasFeature("costs");
|
const canManageCosts = hasFeature("costs");
|
||||||
const hasTrash = hasFeature("trash");
|
const hasTrash = hasFeature("trash");
|
||||||
const deleteTool = useDeleteTool();
|
const deleteTool = useDeleteTool();
|
||||||
@@ -111,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" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,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" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,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" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +204,10 @@ export default function ToolDetail() {
|
|||||||
query: { enabled: !!id, queryKey: getGetToolQueryKey(id) }
|
query: { enabled: !!id, queryKey: getGetToolQueryKey(id) }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tool?.id && tool.name) recordRecentTool(tool.id, tool.name);
|
||||||
|
}, [tool]);
|
||||||
|
|
||||||
const { data: ratings, isLoading: loadingRatings } = useListToolRatings(id, {
|
const { data: ratings, isLoading: loadingRatings } = useListToolRatings(id, {
|
||||||
query: { enabled: !!id, queryKey: getListToolRatingsQueryKey(id) }
|
query: { enabled: !!id, queryKey: getListToolRatingsQueryKey(id) }
|
||||||
});
|
});
|
||||||
@@ -203,6 +216,10 @@ export default function ToolDetail() {
|
|||||||
query: { enabled: !!id, queryKey: getGetRatingDistributionQueryKey({ toolId: id }) }
|
query: { enabled: !!id, queryKey: getGetRatingDistributionQueryKey({ toolId: id }) }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: ratingHistory, isLoading: loadingRatingHistory } = useGetToolRatingHistory(id, {
|
||||||
|
query: { enabled: !!id, queryKey: getGetToolRatingHistoryQueryKey(id) }
|
||||||
|
});
|
||||||
|
|
||||||
const createRating = useCreateRating();
|
const createRating = useCreateRating();
|
||||||
|
|
||||||
const form = useForm<RatingFormValues>({
|
const form = useForm<RatingFormValues>({
|
||||||
@@ -219,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();
|
||||||
@@ -234,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"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -246,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>
|
||||||
@@ -270,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() });
|
||||||
@@ -279,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);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -291,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 */}
|
||||||
@@ -346,13 +363,24 @@ export default function ToolDetail() {
|
|||||||
<span className="text-muted-foreground self-end mb-1">/ 5</span>
|
<span className="text-muted-foreground self-end mb-1">/ 5</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Based on {tool.ratingCount} reviews
|
{t("detail.basedOnReview", { count: tool.ratingCount })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{canWatchlist && (
|
||||||
|
<Button
|
||||||
|
variant={isWatched(id) ? "default" : "outline"}
|
||||||
|
className="w-full gap-2"
|
||||||
|
onClick={() => toggleWatchlist(Number(id))}
|
||||||
|
>
|
||||||
|
<Bookmark className={cn("w-4 h-4", isWatched(id) && "fill-current")} />
|
||||||
|
{isWatched(id) ? t("detail.savedToWatchlist") : t("detail.saveToWatchlist")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{tool.websiteUrl && (
|
{tool.websiteUrl && (
|
||||||
<Button asChild className="w-full mt-2" variant="outline">
|
<Button asChild className="w-full mt-2" variant="outline">
|
||||||
<a href={tool.websiteUrl} target="_blank" rel="noopener noreferrer">
|
<a href={tool.websiteUrl} target="_blank" rel="noopener noreferrer">
|
||||||
Visit Website <ExternalLink className="w-4 h-4 ml-2" />
|
{t("detail.visitWebsite")} <ExternalLink className="w-4 h-4 ml-2" />
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -361,7 +389,7 @@ export default function ToolDetail() {
|
|||||||
<div className="flex gap-2 w-full mt-1">
|
<div className="flex gap-2 w-full mt-1">
|
||||||
<Button asChild variant="outline" size="sm" className="flex-1 gap-2">
|
<Button asChild variant="outline" size="sm" className="flex-1 gap-2">
|
||||||
<Link href={`/tools/${id}/edit`}>
|
<Link href={`/tools/${id}/edit`}>
|
||||||
<Pencil className="w-3.5 h-3.5" /> Edit
|
<Pencil className="w-3.5 h-3.5" /> {t("detail.edit")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -370,7 +398,7 @@ export default function ToolDetail() {
|
|||||||
className="flex-1 gap-2 text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
className="flex-1 gap-2 text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||||
onClick={() => setDeleteOpen(true)}
|
onClick={() => setDeleteOpen(true)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" /> Delete
|
<Trash2 className="w-3.5 h-3.5" /> {t("detail.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -379,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">
|
||||||
@@ -394,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>
|
||||||
@@ -472,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>
|
||||||
@@ -481,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>
|
||||||
)}
|
)}
|
||||||
@@ -490,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>
|
||||||
@@ -536,10 +564,10 @@ export default function ToolDetail() {
|
|||||||
{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">Costs</h3>
|
<h3 className="text-xl font-bold">{t("detail.costs")}</h3>
|
||||||
{canManageCosts && (
|
{canManageCosts && (
|
||||||
<Button variant="outline" size="sm" onClick={() => { resetCostForm(); setCostDialogOpen(true); }} className="gap-2">
|
<Button variant="outline" size="sm" onClick={() => { resetCostForm(); setCostDialogOpen(true); }} className="gap-2">
|
||||||
<Plus className="w-4 h-4" /> Add Cost
|
<Plus className="w-4 h-4" /> {t("detail.addCost")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -558,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>
|
||||||
@@ -578,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>
|
||||||
)}
|
)}
|
||||||
@@ -587,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 ? "Edit Cost" : "Add Cost"}</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>
|
||||||
@@ -635,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>
|
||||||
@@ -654,19 +684,19 @@ export default function ToolDetail() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Rating Breakdown</CardTitle>
|
<CardTitle>{t("detail.ratingBreakdown")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<span className="font-medium">Usefulness</span>
|
<span className="font-medium">{t("detail.usefulness")}</span>
|
||||||
<span className="font-bold">{tool.avgUsefulness ? tool.avgUsefulness.toFixed(1) : "0.0"}</span>
|
<span className="font-bold">{tool.avgUsefulness ? tool.avgUsefulness.toFixed(1) : "0.0"}</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={((tool.avgUsefulness || 0) / 5) * 100} className="h-2" />
|
<Progress value={((tool.avgUsefulness || 0) / 5) * 100} className="h-2" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<span className="font-medium">Usability</span>
|
<span className="font-medium">{t("detail.usability")}</span>
|
||||||
<span className="font-bold">{tool.avgUsability ? tool.avgUsability.toFixed(1) : "0.0"}</span>
|
<span className="font-bold">{tool.avgUsability ? tool.avgUsability.toFixed(1) : "0.0"}</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={((tool.avgUsability || 0) / 5) * 100} className="h-2" />
|
<Progress value={((tool.avgUsability || 0) / 5) * 100} className="h-2" />
|
||||||
@@ -676,7 +706,7 @@ export default function ToolDetail() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Score Distribution</CardTitle>
|
<CardTitle>{t("detail.scoreDistribution")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loadingDistribution ? (
|
{loadingDistribution ? (
|
||||||
@@ -695,22 +725,53 @@ export default function ToolDetail() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("detail.scoreTrend")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loadingRatingHistory ? (
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
) : ratingHistory && ratingHistory.length > 1 ? (
|
||||||
|
<div className="h-48">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={ratingHistory} margin={{ top: 5, right: 10, bottom: 0, left: -20 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" opacity={0.2} />
|
||||||
|
<XAxis dataKey="date" hide />
|
||||||
|
<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") : "")} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
|
<Line type="monotone" dataKey="combined" name={t("detail.score")} stroke="hsl(var(--primary))" strokeWidth={2} dot={false} />
|
||||||
|
<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={t("detail.usability")} stroke="hsl(var(--warning))" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("detail.notEnoughRatings")}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column: Reviews */}
|
{/* Right Column: Reviews */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h3 className="text-2xl font-bold">Reviews</h3>
|
<h3 className="text-2xl font-bold">{t("detail.reviews")}</h3>
|
||||||
{!isReviewFormOpen && user && (
|
{!isReviewFormOpen && user && (
|
||||||
<Button onClick={() => setIsReviewFormOpen(true)}>Write a Review</Button>
|
<Button onClick={() => setIsReviewFormOpen(true)}>{t("detail.addReview")}</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{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>Write a Review</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}>
|
||||||
@@ -721,7 +782,9 @@ export default function ToolDetail() {
|
|||||||
name="usefulness"
|
name="usefulness"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>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}
|
||||||
@@ -739,7 +802,9 @@ export default function ToolDetail() {
|
|||||||
name="usability"
|
name="usability"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>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}
|
||||||
@@ -759,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}
|
||||||
/>
|
/>
|
||||||
@@ -777,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>
|
||||||
@@ -793,10 +862,10 @@ 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 ? "Submitting..." : "Submit Review"}
|
{createRating.isPending ? t("common.loading") : t("detail.submit")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -816,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>
|
||||||
@@ -825,11 +894,11 @@ export default function ToolDetail() {
|
|||||||
|
|
||||||
<div className="flex gap-6 mb-4">
|
<div className="flex gap-6 mb-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">Usefulness</span>
|
<span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">{t("detail.usefulness")}</span>
|
||||||
<RatingStars value={rating.usefulness} size="sm" />
|
<RatingStars value={rating.usefulness} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">Usability</span>
|
<span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">{t("detail.usability")}</span>
|
||||||
<RatingStars value={rating.usability} size="sm" />
|
<RatingStars value={rating.usability} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -845,8 +914,8 @@ 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">No reviews yet</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>
|
||||||
@@ -859,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>
|
||||||
|
|||||||
@@ -1,25 +1,86 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
useListTools,
|
useListTools,
|
||||||
useListCategories,
|
useListCategories,
|
||||||
ListToolsSort
|
ListToolsSort,
|
||||||
} from "@workspace/api-client-react";
|
} from "@workspace/api-client-react";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { Layout } from "@/components/layout";
|
import { Layout } from "@/components/layout";
|
||||||
import { ToolCard } from "@/components/tool-card";
|
import { ToolCard } from "@/components/tool-card";
|
||||||
|
import { ToolCardWide } from "@/components/tool-card-wide";
|
||||||
|
import { ToolRow, TABLE_GRID } from "@/components/tool-row";
|
||||||
|
import { ToolPreviewCard } from "@/components/tool-preview-card";
|
||||||
|
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
|
||||||
|
import { ViewToggle, type ViewMode } from "@/components/view-toggle";
|
||||||
|
import { DensityToggle, type Density } from "@/components/density-toggle";
|
||||||
|
import { FilterPopover } from "@/components/filter-popover";
|
||||||
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||||
|
import { useBrowsePreferences, isViewMode, isDensity } from "@/hooks/use-browse-preferences";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { useWatchlist } from "@/hooks/use-watchlist";
|
||||||
|
import { CompareBar } from "@/components/compare-bar";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
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, SlidersHorizontal, X } 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";
|
||||||
|
|
||||||
const SORT_VALUES = new Set<string>([ListToolsSort.newest, ListToolsSort.top_rated, ListToolsSort.most_reviewed]);
|
const SORT_VALUES = new Set<string>(Object.values(ListToolsSort));
|
||||||
|
|
||||||
|
const SORT_KEYS: Record<string, string> = {
|
||||||
|
[ListToolsSort.newest]: "browse.sortNewest",
|
||||||
|
[ListToolsSort.top_rated]: "browse.sortTopRated",
|
||||||
|
[ListToolsSort.most_reviewed]: "browse.sortMostReviewed",
|
||||||
|
[ListToolsSort.name_asc]: "browse.sortNameAsc",
|
||||||
|
[ListToolsSort.name_desc]: "browse.sortNameDesc",
|
||||||
|
[ListToolsSort.recently_updated]: "browse.sortRecentlyUpdated",
|
||||||
|
};
|
||||||
|
|
||||||
|
function SortHeader({
|
||||||
|
active,
|
||||||
|
direction,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
align = "left",
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
direction?: "asc" | "desc";
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
align?: "left" | "right";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 text-xs font-semibold uppercase tracking-wide hover:text-foreground transition-colors",
|
||||||
|
active ? "text-foreground" : "text-muted-foreground",
|
||||||
|
align === "right" && "w-full justify-end",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{active && direction === "asc" ? (
|
||||||
|
<ArrowUp className="w-3.5 h-3.5" />
|
||||||
|
) : active && direction === "desc" ? (
|
||||||
|
<ArrowDown className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="w-3.5 h-3.5 opacity-50" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ToolsBrowse() {
|
export default function ToolsBrowse() {
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
const urlSearch = useSearch();
|
const urlSearch = useSearch();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const initialParams = new URLSearchParams(urlSearch);
|
const initialParams = useMemo(() => new URLSearchParams(urlSearch), [urlSearch]);
|
||||||
const initialSearch = initialParams.get("search") ?? "";
|
const initialSearch = initialParams.get("search") ?? "";
|
||||||
const initialCategory = initialParams.get("category") ?? "all";
|
const initialCategory = initialParams.get("category") ?? "all";
|
||||||
const initialSortParam = initialParams.get("sort") ?? "";
|
const initialSortParam = initialParams.get("sort") ?? "";
|
||||||
@@ -27,144 +88,484 @@ export default function ToolsBrowse() {
|
|||||||
? (initialSortParam as ListToolsSort)
|
? (initialSortParam as ListToolsSort)
|
||||||
: ListToolsSort.newest;
|
: ListToolsSort.newest;
|
||||||
|
|
||||||
|
const { serverView, serverDensity, localView, localDensity, persist } = useBrowsePreferences();
|
||||||
|
const { hasFeature } = useAuth();
|
||||||
|
const { isWatched, toggle: toggleWatchlist, canWatchlist } = useWatchlist();
|
||||||
|
|
||||||
|
const [compareIds, setCompareIds] = useState<number[]>([]);
|
||||||
|
const [compareUpsellOpen, setCompareUpsellOpen] = useState(false);
|
||||||
|
|
||||||
|
function toggleCompare(id: number) {
|
||||||
|
setCompareIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||||
|
}
|
||||||
|
|
||||||
const [search, setSearch] = useState(initialSearch);
|
const [search, setSearch] = useState(initialSearch);
|
||||||
const [searchInput, setSearchInput] = useState(initialSearch);
|
const [searchInput, setSearchInput] = useState(initialSearch);
|
||||||
const [category, setCategory] = useState<string>(initialCategory);
|
const [category, setCategory] = useState<string>(initialCategory);
|
||||||
const [sort, setSort] = useState<ListToolsSort>(initialSort);
|
const [sort, setSort] = useState<ListToolsSort>(initialSort);
|
||||||
|
const [overrideView, setOverrideView] = useState<ViewMode | null>(null);
|
||||||
|
const [overrideDensity, setOverrideDensity] = useState<Density | null>(null);
|
||||||
|
const [tags, setTags] = useState<string[]>(() =>
|
||||||
|
(initialParams.get("tags") ?? "").split(",").map((t) => t.trim()).filter(Boolean),
|
||||||
|
);
|
||||||
|
const [features, setFeatures] = useState<string[]>(() =>
|
||||||
|
(initialParams.get("features") ?? "").split(",").map((f) => f.trim()).filter(Boolean),
|
||||||
|
);
|
||||||
|
const [minRating, setMinRating] = useState<number | null>(() => {
|
||||||
|
const v = Number(initialParams.get("minRating"));
|
||||||
|
return Number.isFinite(v) ? Math.min(5, Math.max(0, v)) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const urlView = initialParams.get("view");
|
||||||
|
const urlDensity = initialParams.get("density");
|
||||||
|
const view: ViewMode =
|
||||||
|
overrideView ?? (isViewMode(urlView) ? urlView : null) ?? serverView ?? localView ?? "grid";
|
||||||
|
const density: Density =
|
||||||
|
overrideDensity ?? (isDensity(urlDensity) ? urlDensity : null) ?? serverDensity ?? localDensity ?? "cozy";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
if (search) p.set("search", search);
|
if (search) p.set("search", search);
|
||||||
if (category && category !== "all") p.set("category", category);
|
if (category && category !== "all") p.set("category", category);
|
||||||
if (sort && sort !== ListToolsSort.newest) p.set("sort", sort);
|
if (sort && sort !== ListToolsSort.newest) p.set("sort", sort);
|
||||||
|
if (view !== "grid") p.set("view", view);
|
||||||
|
if (density !== "cozy") p.set("density", density);
|
||||||
|
if (tags.length > 0) p.set("tags", tags.join(","));
|
||||||
|
if (features.length > 0) p.set("features", features.join(","));
|
||||||
|
if (minRating != null) p.set("minRating", String(minRating));
|
||||||
const qs = p.toString();
|
const qs = p.toString();
|
||||||
navigate(qs ? `/tools?${qs}` : "/tools", { replace: true });
|
navigate(qs ? `/tools?${qs}` : "/tools", { replace: true });
|
||||||
}, [search, category, sort]);
|
}, [search, category, sort, view, density, tags, features, minRating, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setSearch(searchInput), 300);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [searchInput]);
|
||||||
|
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const isTyping =
|
||||||
|
target.tagName === "INPUT" ||
|
||||||
|
target.tagName === "TEXTAREA" ||
|
||||||
|
target.tagName === "SELECT" ||
|
||||||
|
target.isContentEditable;
|
||||||
|
if (e.key === "/" && !isTyping) {
|
||||||
|
e.preventDefault();
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { data: categories, isLoading: loadingCategories } = useListCategories();
|
const { data: categories, isLoading: loadingCategories } = useListCategories();
|
||||||
|
|
||||||
const queryParams = {
|
const queryParams = {
|
||||||
...(search ? { search } : {}),
|
...(search ? { search } : {}),
|
||||||
...(category && category !== "all" ? { category } : {}),
|
...(category && category !== "all" ? { category } : {}),
|
||||||
...(sort ? { sort } : {})
|
...(sort ? { sort } : {}),
|
||||||
|
...(tags.length > 0 ? { tags: tags.join(",") } : {}),
|
||||||
|
...(features.length > 0 ? { features: features.join(",") } : {}),
|
||||||
|
...(minRating != null ? { minRating } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data: tools, isLoading: loadingTools } = useListTools(queryParams);
|
|
||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
const { data: tools, isLoading: loadingTools } = useListTools(queryParams);
|
||||||
e.preventDefault();
|
const allTools = tools ?? [];
|
||||||
setSearch(searchInput);
|
|
||||||
};
|
function setView(v: ViewMode) {
|
||||||
|
setOverrideView(v);
|
||||||
|
persist(v, density);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDensity(d: Density) {
|
||||||
|
setOverrideDensity(d);
|
||||||
|
persist(view, d);
|
||||||
|
}
|
||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
setSearch("");
|
setSearch("");
|
||||||
setSearchInput("");
|
setSearchInput("");
|
||||||
setCategory("all");
|
setCategory("all");
|
||||||
setSort(ListToolsSort.newest);
|
setSort(ListToolsSort.newest);
|
||||||
|
setTags([]);
|
||||||
|
setFeatures([]);
|
||||||
|
setMinRating(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFilters = search !== "" || category !== "all" || sort !== ListToolsSort.newest;
|
const filterCount = tags.length + features.length + (minRating != null ? 1 : 0);
|
||||||
|
const hasFilters = search !== "" || category !== "all" || sort !== ListToolsSort.newest || filterCount > 0;
|
||||||
|
|
||||||
|
function toggleNameSort() {
|
||||||
|
setSort(sort === ListToolsSort.name_asc ? ListToolsSort.name_desc : ListToolsSort.name_asc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- virtualization ----
|
||||||
|
const tableParentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const rowsParentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const tableVirtualizer = useVirtualizer({
|
||||||
|
count: allTools.length,
|
||||||
|
getScrollElement: () => tableParentRef.current,
|
||||||
|
estimateSize: () => (density === "compact" ? 44 : 64),
|
||||||
|
overscan: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rowsVirtualizer = useVirtualizer({
|
||||||
|
count: allTools.length,
|
||||||
|
getScrollElement: () => rowsParentRef.current,
|
||||||
|
estimateSize: () => (density === "compact" ? 72 : 116),
|
||||||
|
overscan: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
const gridPad = density === "compact" ? "gap-3" : "gap-4";
|
||||||
|
|
||||||
|
const sortActive = (v: ListToolsSort) => sort === v;
|
||||||
|
const nameDir: "asc" | "desc" | undefined =
|
||||||
|
sort === ListToolsSort.name_asc ? "asc" : sort === ListToolsSort.name_desc ? "desc" : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<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">Browse Tools</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||||
<p className="text-muted-foreground">Discover and evaluate the best tools for your stack.</p>
|
{t("nav.browseTools")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">{t("browse.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href="/tools/new">Add a Tool</Link>
|
<Link href="/tools/new">
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
{t("browse.addTool")}
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="bg-card border rounded-xl p-4 flex flex-col md:flex-row gap-4">
|
<div className="bg-card border rounded-xl p-4 flex flex-col md:flex-row gap-4">
|
||||||
<form onSubmit={handleSearch} className="flex-1 relative">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search tools..."
|
ref={searchInputRef}
|
||||||
|
placeholder={`${t("common.search")}… (press "/" to focus)`}
|
||||||
className="pl-9 w-full"
|
className="pl-9 w-full"
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
data-testid="input-search"
|
data-testid="input-search"
|
||||||
/>
|
/>
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap sm:flex-nowrap gap-4 shrink-0">
|
<div className="flex flex-wrap sm:flex-nowrap gap-4 shrink-0">
|
||||||
<Select value={category} onValueChange={setCategory}>
|
<Select value={category} onValueChange={setCategory}>
|
||||||
<SelectTrigger className="w-full sm:w-[180px]" data-testid="select-category">
|
<SelectTrigger className="w-full sm:w-[180px]" data-testid="select-category">
|
||||||
<SelectValue placeholder="All Categories" />
|
<SelectValue placeholder="All Categories" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Categories</SelectItem>
|
<SelectItem value="all">{t("browse.allCategories")}</SelectItem>
|
||||||
{!loadingCategories && categories?.map((c) => (
|
{!loadingCategories &&
|
||||||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
categories?.map((c) => (
|
||||||
))}
|
<SelectItem key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={sort} onValueChange={(val) => setSort(val as ListToolsSort)}>
|
<Select value={sort} onValueChange={(val) => setSort(val as ListToolsSort)}>
|
||||||
<SelectTrigger className="w-full sm:w-[180px]" data-testid="select-sort">
|
<SelectTrigger className="w-full sm:w-[180px]" data-testid="select-sort">
|
||||||
<SelectValue placeholder="Sort By" />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value={ListToolsSort.newest}>Newest</SelectItem>
|
{Object.values(ListToolsSort).map((v) => (
|
||||||
<SelectItem value={ListToolsSort.top_rated}>Top Rated</SelectItem>
|
<SelectItem key={v} value={v}>
|
||||||
<SelectItem value={ListToolsSort.most_reviewed}>Most Reviewed</SelectItem>
|
{t(SORT_KEYS[v])}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{hasFilters && (
|
<FilterPopover
|
||||||
<Button variant="ghost" size="icon" onClick={clearFilters} className="shrink-0" title="Clear filters">
|
tags={tags}
|
||||||
<X className="w-4 h-4" />
|
features={features}
|
||||||
</Button>
|
minRating={minRating}
|
||||||
|
onChange={({ tags: t, features: f, minRating: m }) => {
|
||||||
|
if (t) setTags(t);
|
||||||
|
if (f) setFeatures(f);
|
||||||
|
if (m !== undefined) setMinRating(m);
|
||||||
|
}}
|
||||||
|
onClear={() => {
|
||||||
|
setTags([]);
|
||||||
|
setFeatures([]);
|
||||||
|
setMinRating(null);
|
||||||
|
}}
|
||||||
|
activeCount={filterCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasFilters && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 -mt-2">
|
||||||
|
{search && (
|
||||||
|
<Badge variant="secondary" className="gap-1 pl-2.5 pr-1.5 py-1">
|
||||||
|
“{search}”
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSearch("");
|
||||||
|
setSearchInput("");
|
||||||
|
}}
|
||||||
|
className="rounded-sm hover:bg-muted p-0.5"
|
||||||
|
aria-label="Remove search"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{category !== "all" && (
|
||||||
|
<Badge variant="secondary" className="gap-1 pl-2.5 pr-1.5 py-1">
|
||||||
|
{category}
|
||||||
|
<button
|
||||||
|
onClick={() => setCategory("all")}
|
||||||
|
className="rounded-sm hover:bg-muted p-0.5"
|
||||||
|
aria-label="Remove category"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{sort !== ListToolsSort.newest && (
|
||||||
|
<Badge variant="secondary" className="gap-1 pl-2.5 pr-1.5 py-1">
|
||||||
|
{t(SORT_KEYS[sort])}
|
||||||
|
<button
|
||||||
|
onClick={() => setSort(ListToolsSort.newest)}
|
||||||
|
className="rounded-sm hover:bg-muted p-0.5"
|
||||||
|
aria-label="Reset sort"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{tags.map((t) => (
|
||||||
|
<Badge key={`tag-${t}`} variant="secondary" className="gap-1 pl-2.5 pr-1.5 py-1">
|
||||||
|
{t}
|
||||||
|
<button
|
||||||
|
onClick={() => setTags(tags.filter((x) => x !== t))}
|
||||||
|
className="rounded-sm hover:bg-muted p-0.5"
|
||||||
|
aria-label={`Remove tag ${t}`}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{features.map((f) => (
|
||||||
|
<Badge key={`feat-${f}`} variant="secondary" className="gap-1 pl-2.5 pr-1.5 py-1">
|
||||||
|
{f}
|
||||||
|
<button
|
||||||
|
onClick={() => setFeatures(features.filter((x) => x !== f))}
|
||||||
|
className="rounded-sm hover:bg-muted p-0.5"
|
||||||
|
aria-label={t("filter.removeFeature", { feature: f })}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{minRating != null && (
|
||||||
|
<Badge variant="secondary" className="gap-1 pl-2.5 pr-1.5 py-1">
|
||||||
|
⭐ {minRating.toFixed(1)}+
|
||||||
|
<button
|
||||||
|
onClick={() => setMinRating(null)}
|
||||||
|
className="rounded-sm hover:bg-muted p-0.5"
|
||||||
|
aria-label={t("filter.removeMinRating")}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Button variant="link" size="sm" className="px-1 text-muted-foreground" onClick={clearFilters}>
|
||||||
|
{t("common.clearAll")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results toolbar */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center text-sm text-muted-foreground">
|
||||||
|
<SlidersHorizontal className="w-4 h-4 mr-2" />
|
||||||
|
{loadingTools ? t("common.loading") : t("browse.showingTool", { count: allTools.length })}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<DensityToggle value={density} onValueChange={setDensity} />
|
||||||
|
<ViewToggle value={view} onValueChange={setView} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
<div>
|
{loadingTools ? (
|
||||||
<div className="flex items-center text-sm text-muted-foreground mb-4">
|
<Skeletons view={view} density={density} />
|
||||||
<SlidersHorizontal className="w-4 h-4 mr-2" />
|
) : allTools.length > 0 ? (
|
||||||
{loadingTools ? "Loading tools..." : `Showing ${tools?.length || 0} tools`}
|
view === "grid" ? (
|
||||||
</div>
|
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4", gridPad)}>
|
||||||
|
{allTools.map((tool) => (
|
||||||
{loadingTools ? (
|
<ToolCard
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
key={tool.id}
|
||||||
{[1, 2, 3, 4, 5, 6, 7, 8].map(i => (
|
tool={tool}
|
||||||
<Skeleton key={`sk-tools-${i}`} className="h-[200px] w-full rounded-xl" />
|
compare={{ selected: compareIds.includes(tool.id), onToggle: () => toggleCompare(tool.id) }}
|
||||||
|
watchlist={canWatchlist ? { watched: isWatched(tool.id), onToggle: () => toggleWatchlist(tool.id) } : undefined}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : tools && tools.length > 0 ? (
|
) : view === "rows" ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div ref={rowsParentRef} className="overflow-auto max-h-[72vh]">
|
||||||
{tools.map((tool) => (
|
<div className="relative" style={{ height: rowsVirtualizer.getTotalSize() }}>
|
||||||
<ToolCard key={tool.id} tool={tool} />
|
{rowsVirtualizer.getVirtualItems().map((vi) => (
|
||||||
))}
|
<div
|
||||||
|
key={allTools[vi.index].id}
|
||||||
|
ref={rowsVirtualizer.measureElement}
|
||||||
|
data-index={vi.index}
|
||||||
|
className="absolute top-0 left-0 w-full"
|
||||||
|
style={{ transform: `translateY(${vi.start}px)` }}
|
||||||
|
>
|
||||||
|
<ToolCardWide
|
||||||
|
tool={allTools[vi.index]}
|
||||||
|
density={density}
|
||||||
|
compare={{ selected: compareIds.includes(allTools[vi.index].id), onToggle: () => toggleCompare(allTools[vi.index].id) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-muted/30 border border-dashed rounded-xl py-24 flex flex-col items-center justify-center text-center">
|
<div ref={tableParentRef} className="overflow-auto max-h-[72vh] rounded-xl border bg-background">
|
||||||
<div className="bg-muted p-4 rounded-full mb-4">
|
<div className={cn("grid items-center gap-3 border-b bg-muted sticky top-0 z-10 px-4", TABLE_GRID)}>
|
||||||
<Wrench className="w-8 h-8 text-muted-foreground" />
|
<div className="py-2.5">
|
||||||
|
<SortHeader active={sortActive(ListToolsSort.name_asc) || sortActive(ListToolsSort.name_desc)} direction={nameDir} onClick={toggleNameSort}>
|
||||||
|
{t("browse.tool")}
|
||||||
|
</SortHeader>
|
||||||
|
</div>
|
||||||
|
<div className="py-2.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t("browse.category")}
|
||||||
|
</div>
|
||||||
|
<div className="py-2.5">
|
||||||
|
<SortHeader active={sortActive(ListToolsSort.top_rated)} onClick={() => setSort(ListToolsSort.top_rated)} align="right">
|
||||||
|
{t("browse.rating")}
|
||||||
|
</SortHeader>
|
||||||
|
</div>
|
||||||
|
<div className="py-2.5">
|
||||||
|
<SortHeader active={sortActive(ListToolsSort.most_reviewed)} onClick={() => setSort(ListToolsSort.most_reviewed)} align="right">
|
||||||
|
{t("browse.reviews")}
|
||||||
|
</SortHeader>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-medium mb-2">No tools found</h3>
|
<div className="relative px-4" style={{ height: tableVirtualizer.getTotalSize() }}>
|
||||||
<p className="text-muted-foreground max-w-md mx-auto mb-6">
|
{tableVirtualizer.getVirtualItems().map((vi) => {
|
||||||
We couldn't find any tools matching your current filters. Try adjusting your search criteria or add a new tool.
|
const tool = allTools[vi.index];
|
||||||
</p>
|
return (
|
||||||
<div className="flex gap-4">
|
<div
|
||||||
{hasFilters && (
|
key={tool.id}
|
||||||
<Button variant="outline" onClick={clearFilters}>
|
ref={tableVirtualizer.measureElement}
|
||||||
Clear Filters
|
data-index={vi.index}
|
||||||
</Button>
|
className="absolute top-0 left-0 w-full"
|
||||||
)}
|
style={{ transform: `translateY(${vi.start}px)` }}
|
||||||
<Button asChild>
|
>
|
||||||
<Link href="/tools/new">Add Tool</Link>
|
<HoverCard openDelay={250} closeDelay={100}>
|
||||||
</Button>
|
<HoverCardTrigger asChild>
|
||||||
|
<div>
|
||||||
|
<ToolRow
|
||||||
|
tool={tool}
|
||||||
|
density={density}
|
||||||
|
compare={{ selected: compareIds.includes(tool.id), onToggle: () => toggleCompare(tool.id) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent side="right" align="start" className="w-72" collisionPadding={16}>
|
||||||
|
<ToolPreviewCard tool={tool} />
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
</div>
|
) : (
|
||||||
|
<div className="bg-muted/30 border border-dashed rounded-xl py-24 flex flex-col items-center justify-center text-center">
|
||||||
|
<div className="bg-muted p-4 rounded-full mb-4">
|
||||||
|
<Wrench className="w-8 h-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-medium mb-2">{t("browse.noToolsFound")}</h3>
|
||||||
|
<p className="text-muted-foreground max-w-md mx-auto mb-6">
|
||||||
|
{t("browse.noToolsMatch")}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{hasFilters && (
|
||||||
|
<Button variant="outline" onClick={clearFilters}>
|
||||||
|
{t("filter.clearFilters")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/tools/new">{t("browse.addTool")}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CompareBar
|
||||||
|
tools={allTools.filter((t) => compareIds.includes(t.id))}
|
||||||
|
onRemove={(id) => toggleCompare(id)}
|
||||||
|
onClear={() => setCompareIds([])}
|
||||||
|
canCompare={hasFeature("compare")}
|
||||||
|
onCompare={() => navigate(`/compare?ids=${compareIds.join(",")}`)}
|
||||||
|
onUpgrade={() => setCompareUpsellOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog open={compareUpsellOpen} onOpenChange={setCompareUpsellOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("browse.comparePremiumTitle")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("browse.comparePremiumSub")}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t("browse.notNow")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction asChild>
|
||||||
|
<a href="/admin?tab=plan">{t("browse.upgradePremium")}</a>
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Skeletons({ view, density }: { view: ViewMode; density: Density }) {
|
||||||
|
const compact = density === "compact";
|
||||||
|
if (view === "grid") {
|
||||||
|
return (
|
||||||
|
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4", compact ? "gap-3" : "gap-4")}>
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
|
||||||
|
<Skeleton key={`sk-grid-${i}`} className="h-[200px] w-full rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (view === "rows") {
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-3")}>
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<Skeleton key={`sk-rows-${i}`} className={cn("w-full rounded-xl", compact ? "h-16" : "h-[104px]")} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border overflow-hidden">
|
||||||
|
<Skeleton className="h-10 w-full rounded-none" />
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<Skeleton key={`sk-table-${i}`} className={cn("w-full rounded-none border-t border-border/40", compact ? "h-11" : "h-16")} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -27,8 +26,10 @@ 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 { Trash2, RotateCcw, Search, ShieldAlert, Trash as TrashIcon, RefreshCcw } from "lucide-react";
|
import { Trash2, RotateCcw, Search, ShieldAlert, Trash as TrashIcon, RefreshCcw } from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function Trash() {
|
export default function Trash() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { user, isAdmin, hasFeature, isLoading: authLoading } = useAuth();
|
const { user, isAdmin, hasFeature, isLoading: authLoading } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -89,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" });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -105,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" });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -122,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" });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -139,10 +140,10 @@ export default function Trash() {
|
|||||||
<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">Trash requires a higher tier</h2>
|
<h2 className="text-2xl font-bold">{t("trash.requiresPremium")}</h2>
|
||||||
<p className="text-muted-foreground">The trash is available to Premium and Enterprise users.</p>
|
<p className="text-muted-foreground">{t("trash.requiresPremiumSub")}</p>
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<a href="/tools">Back to tools</a>
|
<a href="/tools">{t("common.backToTools")}</a>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
@@ -155,16 +156,18 @@ 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">Trash</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||||
<p className="text-muted-foreground">Deleted tools are kept here until they are restored or permanently removed.</p>
|
{t("trash.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">{t("trash.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>Trashed Tools</CardTitle>
|
<CardTitle>{t("trash.trashedTools")}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{selectedIds.length > 0 ? `${selectedIds.length} selected` : `${trashed.length} tool(s) in trash`}
|
{selectedIds.length > 0 ? t("trash.selected", { count: selectedIds.length }) : t("trash.toolsInTrash", { count: trashed.length })}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@@ -172,7 +175,7 @@ export default function Trash() {
|
|||||||
<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 trash…"
|
placeholder={`${t("common.search")}…`}
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -183,7 +186,7 @@ export default function Trash() {
|
|||||||
disabled={selectedIds.length === 0 || restore.isPending}
|
disabled={selectedIds.length === 0 || restore.isPending}
|
||||||
onClick={() => handleRestore(selectedIds)}
|
onClick={() => handleRestore(selectedIds)}
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-4 h-4 mr-2" /> Restore ({selectedIds.length})
|
<RotateCcw className="w-4 h-4 mr-2" /> {t("trash.restore")} ({selectedIds.length})
|
||||||
</Button>
|
</Button>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
@@ -193,7 +196,7 @@ export default function Trash() {
|
|||||||
disabled={selectedIds.length === 0 || deletePermanent.isPending}
|
disabled={selectedIds.length === 0 || deletePermanent.isPending}
|
||||||
onClick={() => setConfirmDelete(true)}
|
onClick={() => setConfirmDelete(true)}
|
||||||
>
|
>
|
||||||
<TrashIcon className="w-4 h-4 mr-2" /> Delete permanently ({selectedIds.length})
|
<TrashIcon className="w-4 h-4 mr-2" /> {t("trash.deletePermanently")} ({selectedIds.length})
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -201,7 +204,7 @@ export default function Trash() {
|
|||||||
disabled={trashed.length === 0 || empty.isPending}
|
disabled={trashed.length === 0 || empty.isPending}
|
||||||
onClick={() => setConfirmEmpty(true)}
|
onClick={() => setConfirmEmpty(true)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-2" /> Empty trash
|
<Trash2 className="w-4 h-4 mr-2" /> {t("trash.emptyTrash")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -215,7 +218,7 @@ export default function Trash() {
|
|||||||
) : trashed.length === 0 ? (
|
) : trashed.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground py-8 text-center flex flex-col items-center gap-2">
|
<div className="text-sm text-muted-foreground py-8 text-center flex flex-col items-center gap-2">
|
||||||
<RefreshCcw className="w-6 h-6 text-muted-foreground/50" />
|
<RefreshCcw className="w-6 h-6 text-muted-foreground/50" />
|
||||||
{search ? "No tools match your search." : "The trash is empty."}
|
{search ? t("trash.noMatch") : t("trash.empty")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
@@ -225,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>Deleted at</TableHead>
|
<TableHead>{t("trash.deletedAt")}</TableHead>
|
||||||
<TableHead>Deleted by</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>
|
||||||
@@ -283,18 +286,18 @@ 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)}
|
||||||
>
|
>
|
||||||
Delete permanently
|
{t("trash.deletePermanently")}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
@@ -303,18 +306,18 @@ 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}
|
||||||
>
|
>
|
||||||
Empty trash
|
{t("trash.emptyTrash")}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
useGetMeWatchlist,
|
||||||
|
getGetMeWatchlistQueryKey,
|
||||||
|
} from "@workspace/api-client-react";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { useWatchlist } from "@/hooks/use-watchlist";
|
||||||
|
import { Layout } from "@/components/layout";
|
||||||
|
import { ToolCard } from "@/components/tool-card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { ShieldAlert, Bookmark } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function Watchlist() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { hasFeature, isLoading: authLoading } = useAuth();
|
||||||
|
const { toggle } = useWatchlist();
|
||||||
|
const canWatchlist = hasFeature("watchlist");
|
||||||
|
|
||||||
|
const { data: tools, isLoading: loading } = useGetMeWatchlist({
|
||||||
|
query: { queryKey: getGetMeWatchlistQueryKey(), enabled: canWatchlist },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!authLoading && !canWatchlist) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||||
|
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
|
||||||
|
<h2 className="text-2xl font-bold">{t("watchlist.requiresPremium")}</h2>
|
||||||
|
<p className="text-muted-foreground">{t("watchlist.requiresPremiumSub")}</p>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href="/tools">{t("common.backToTools")}</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = tools ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="space-y-6 pb-10">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||||
|
{t("watchlist.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">{t("watchlist.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{[1, 2, 3, 4].map((i) => <Skeleton key={i} className="h-[200px] w-full rounded-xl" />)}
|
||||||
|
</div>
|
||||||
|
) : list.length === 0 ? (
|
||||||
|
<div className="bg-muted/30 border border-dashed rounded-xl py-24 flex flex-col items-center justify-center text-center gap-3">
|
||||||
|
<Bookmark className="w-8 h-8 text-muted-foreground" />
|
||||||
|
<h3 className="text-xl font-medium">{t("watchlist.empty")}</h3>
|
||||||
|
<p className="text-muted-foreground max-w-md mx-auto">
|
||||||
|
{t("watchlist.emptySub")}
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href="/tools">{t("common.browseTools")}</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{list.map((tool) => (
|
||||||
|
<ToolCard
|
||||||
|
key={tool.id}
|
||||||
|
tool={tool}
|
||||||
|
watchlist={{ watched: true, onToggle: () => toggle(tool.id) }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,295 @@
|
|||||||
|
# Beispiel-YAML zum Import in den Admin-Bereich (Tab „Tools" -> Importieren).
|
||||||
|
# Erwartetes Format pro Eintrag:
|
||||||
|
# name (Pflicht)
|
||||||
|
# description, category, websiteUrl, iconUrl (optional)
|
||||||
|
# features, tags (Listen oder | -getrennte Strings)
|
||||||
|
# Das Format wird automatisch erkannt; bei CSV den Trenner wählen.
|
||||||
|
# Fehlerhafte Zeilen werden übersprungen und mit Zeilennummer aufgelistet.
|
||||||
|
|
||||||
|
- name: Slack
|
||||||
|
description: Team-Kommunikation mit Channels, Direktnachrichten und App-Integrationen
|
||||||
|
category: Kommunikation
|
||||||
|
websiteUrl: https://slack.com
|
||||||
|
features:
|
||||||
|
- Channels
|
||||||
|
- Videoanrufe
|
||||||
|
- App-Integrationen
|
||||||
|
tags:
|
||||||
|
- communication
|
||||||
|
- teamwork
|
||||||
|
|
||||||
|
- name: Microsoft Teams
|
||||||
|
description: Chat, Besprechungen, Dateien und Zusammenarbeit in einer Plattform
|
||||||
|
category: Kommunikation
|
||||||
|
websiteUrl: https://www.microsoft.com/de-de/microsoft-365/microsoft-teams
|
||||||
|
features:
|
||||||
|
- Videokonferenzen
|
||||||
|
- Teamkanäle
|
||||||
|
- Office-Integration
|
||||||
|
tags:
|
||||||
|
- communication
|
||||||
|
- meetings
|
||||||
|
|
||||||
|
- name: Zoom
|
||||||
|
description: Videokonferenzen und Webinare für Teams und externe Partner
|
||||||
|
category: Kommunikation
|
||||||
|
websiteUrl: https://zoom.us
|
||||||
|
features:
|
||||||
|
- Videokonferenzen
|
||||||
|
- Webinare
|
||||||
|
- Bildschirmfreigabe
|
||||||
|
tags:
|
||||||
|
- video
|
||||||
|
- meetings
|
||||||
|
|
||||||
|
- name: Jira
|
||||||
|
description: Agile Projekt- und Issue-Verfolgung für Entwicklung und Betrieb
|
||||||
|
category: Projektmanagement
|
||||||
|
websiteUrl: https://www.atlassian.com/software/jira
|
||||||
|
features:
|
||||||
|
- Backlog
|
||||||
|
- Sprints
|
||||||
|
- Kanban-Boards
|
||||||
|
tags:
|
||||||
|
- agile
|
||||||
|
- project-management
|
||||||
|
|
||||||
|
- name: Confluence
|
||||||
|
description: Kollaborative Wissensdatenbank und Dokumentation für Teams
|
||||||
|
category: Projektmanagement
|
||||||
|
websiteUrl: https://www.atlassian.com/software/confluence
|
||||||
|
features:
|
||||||
|
- Wikis
|
||||||
|
- Seitenbausteine
|
||||||
|
- Berechtigungen
|
||||||
|
tags:
|
||||||
|
- documentation
|
||||||
|
- knowledge-base
|
||||||
|
|
||||||
|
- name: GitLab
|
||||||
|
description: DevOps-Plattform mit Git-Repositorys, CI/CD und Code-Review
|
||||||
|
category: Entwicklung
|
||||||
|
websiteUrl: https://about.gitlab.com
|
||||||
|
features:
|
||||||
|
- Git-Repositorys
|
||||||
|
- CI/CD
|
||||||
|
- Merge Requests
|
||||||
|
tags:
|
||||||
|
- devops
|
||||||
|
- git
|
||||||
|
|
||||||
|
- name: GitHub
|
||||||
|
description: Versionsverwaltung, Code-Review und Collaboration auf Git-Basis
|
||||||
|
category: Entwicklung
|
||||||
|
websiteUrl: https://github.com
|
||||||
|
features:
|
||||||
|
- Repositories
|
||||||
|
- Pull Requests
|
||||||
|
- Actions
|
||||||
|
tags:
|
||||||
|
- git
|
||||||
|
- collaboration
|
||||||
|
|
||||||
|
- name: Jenkins
|
||||||
|
description: Automatisierte Build- und Deploy-Pipelines für Softwareprojekte
|
||||||
|
category: CI/CD
|
||||||
|
websiteUrl: https://www.jenkins.io
|
||||||
|
features:
|
||||||
|
- Pipelines
|
||||||
|
- Plugins
|
||||||
|
- Job-Automatisierung
|
||||||
|
tags:
|
||||||
|
- ci
|
||||||
|
- cd
|
||||||
|
|
||||||
|
- name: Docker
|
||||||
|
description: Container-Plattform zum Paketieren und Ausführen von Anwendungen
|
||||||
|
category: Entwicklung
|
||||||
|
websiteUrl: https://www.docker.com
|
||||||
|
features:
|
||||||
|
- Container
|
||||||
|
- Images
|
||||||
|
- Compose
|
||||||
|
tags:
|
||||||
|
- containers
|
||||||
|
- virtualization
|
||||||
|
|
||||||
|
- name: Grafana
|
||||||
|
description: Observability- und Dashboard-Plattform für Metriken, Logs und Alerts
|
||||||
|
category: Monitoring
|
||||||
|
websiteUrl: https://grafana.com
|
||||||
|
features:
|
||||||
|
- Dashboards
|
||||||
|
- Alerts
|
||||||
|
- Datenquellen
|
||||||
|
tags:
|
||||||
|
- observability
|
||||||
|
- monitoring
|
||||||
|
|
||||||
|
- name: Prometheus
|
||||||
|
description: Open-Source-Systeme zur Zeitreihen-Metrikerfassung und -Auswertung
|
||||||
|
category: Monitoring
|
||||||
|
websiteUrl: https://prometheus.io
|
||||||
|
features:
|
||||||
|
- Metriken
|
||||||
|
- Alerting
|
||||||
|
- Time-Series-Datenbank
|
||||||
|
tags:
|
||||||
|
- monitoring
|
||||||
|
- metrics
|
||||||
|
|
||||||
|
- name: Datadog
|
||||||
|
description: Cloud-basierte Observability für Infrastruktur, APM und Logs
|
||||||
|
category: Monitoring
|
||||||
|
websiteUrl: https://www.datadoghq.com
|
||||||
|
features:
|
||||||
|
- Infrastruktur-Monitoring
|
||||||
|
- APM
|
||||||
|
- Log-Management
|
||||||
|
tags:
|
||||||
|
- observability
|
||||||
|
- saas
|
||||||
|
|
||||||
|
- name: PagerDuty
|
||||||
|
description: Incident-Management und On-Call-Benachrichtigungen für Teams
|
||||||
|
category: Betrieb
|
||||||
|
websiteUrl: https://www.pagerduty.com
|
||||||
|
features:
|
||||||
|
- Alerting
|
||||||
|
- On-Call-Pläne
|
||||||
|
- Incident-Response
|
||||||
|
tags:
|
||||||
|
- incident-management
|
||||||
|
- on-call
|
||||||
|
|
||||||
|
- name: Sentry
|
||||||
|
description: Fehlerüberwachung und Performance-Monitoring für Anwendungen
|
||||||
|
category: Monitoring
|
||||||
|
websiteUrl: https://sentry.io
|
||||||
|
features:
|
||||||
|
- Error-Tracking
|
||||||
|
- Releases
|
||||||
|
- Performance
|
||||||
|
tags:
|
||||||
|
- error-monitoring
|
||||||
|
- debugging
|
||||||
|
|
||||||
|
- name: Terraform
|
||||||
|
description: Infrastructure-as-Code zur deklarativen Bereitstellung von Ressourcen
|
||||||
|
category: Infrastruktur
|
||||||
|
websiteUrl: https://www.terraform.io
|
||||||
|
features:
|
||||||
|
- Modules
|
||||||
|
- State-Management
|
||||||
|
- Provider
|
||||||
|
tags:
|
||||||
|
- iac
|
||||||
|
- infrastructure
|
||||||
|
|
||||||
|
- name: Ansible
|
||||||
|
description: Agentenlose Automatisierung für Konfiguration, Deploy und Orchestrierung
|
||||||
|
category: Infrastruktur
|
||||||
|
websiteUrl: https://www.ansible.com
|
||||||
|
features:
|
||||||
|
- Playbooks
|
||||||
|
- Inventories
|
||||||
|
- Modules
|
||||||
|
tags:
|
||||||
|
- automation
|
||||||
|
- configuration
|
||||||
|
|
||||||
|
- name: Elasticsearch
|
||||||
|
description: Verteilter Such- und Analytics-Server für Logs, Metriken und Daten
|
||||||
|
category: Datenbank
|
||||||
|
websiteUrl: https://www.elastic.co
|
||||||
|
features:
|
||||||
|
- Volltextsuche
|
||||||
|
- Aggregationen
|
||||||
|
- Skalierung
|
||||||
|
tags:
|
||||||
|
- search
|
||||||
|
- analytics
|
||||||
|
|
||||||
|
- name: Kubernetes
|
||||||
|
description: Container-Orchestrierung für Deployment, Skalierung und Betrieb
|
||||||
|
category: Infrastruktur
|
||||||
|
websiteUrl: https://kubernetes.io
|
||||||
|
features:
|
||||||
|
- Pods
|
||||||
|
- Services
|
||||||
|
- Autoskalierung
|
||||||
|
tags:
|
||||||
|
- containers
|
||||||
|
- orchestration
|
||||||
|
|
||||||
|
- name: Sonarqube
|
||||||
|
description: Statische Code-Analyse und Qualitätsmessung für Softwareprojekte
|
||||||
|
category: Entwicklung
|
||||||
|
websiteUrl: https://www.sonarsource.com/products/sonarqube
|
||||||
|
features:
|
||||||
|
- Code-Qualität
|
||||||
|
- Security-Scans
|
||||||
|
- Quality Gates
|
||||||
|
tags:
|
||||||
|
- code-quality
|
||||||
|
- static-analysis
|
||||||
|
|
||||||
|
- name: Power BI
|
||||||
|
description: Business-Intelligence-Tool für Dashboards, Berichte und Datenanalyse
|
||||||
|
category: Analytics
|
||||||
|
websiteUrl: https://powerbi.microsoft.com
|
||||||
|
features:
|
||||||
|
- Dashboards
|
||||||
|
- Berichte
|
||||||
|
- Datenmodelle
|
||||||
|
tags:
|
||||||
|
- bi
|
||||||
|
- analytics
|
||||||
|
|
||||||
|
- name: Google Workspace
|
||||||
|
description: Produktivitäts-Suite mit E-Mail, Docs, Drive und Meet
|
||||||
|
category: Produktivität
|
||||||
|
websiteUrl: https://workspace.google.com
|
||||||
|
features:
|
||||||
|
- Gmail
|
||||||
|
- Google Docs
|
||||||
|
- Google Drive
|
||||||
|
tags:
|
||||||
|
- suite
|
||||||
|
- productivity
|
||||||
|
|
||||||
|
- name: Microsoft 365
|
||||||
|
description: Office-Anwendungen, E-Mail und Cloud-Dienste für Unternehmen
|
||||||
|
category: Produktivität
|
||||||
|
websiteUrl: https://www.microsoft.com/microsoft-365
|
||||||
|
features:
|
||||||
|
- Word
|
||||||
|
- Excel
|
||||||
|
- Outlook
|
||||||
|
tags:
|
||||||
|
- suite
|
||||||
|
- productivity
|
||||||
|
|
||||||
|
- name: Slack Enterprise Grid
|
||||||
|
description: Slack-Verwaltung für große Organisationen mit erweiterten Sicherheitsfunktionen
|
||||||
|
category: Kommunikation
|
||||||
|
websiteUrl: https://slack.com
|
||||||
|
features:
|
||||||
|
- Enterprise-Admin
|
||||||
|
- Compliance
|
||||||
|
- Multi-Workspace
|
||||||
|
tags:
|
||||||
|
- communication
|
||||||
|
- enterprise
|
||||||
|
|
||||||
|
- name: Atlassian Rovo
|
||||||
|
description: KI-Assistent für Suche und Zusammenarbeit über Atlassian-Produkte
|
||||||
|
category: Projektmanagement
|
||||||
|
websiteUrl: https://www.atlassian.com/software/rovo
|
||||||
|
features:
|
||||||
|
- KI-Suche
|
||||||
|
- Agenten
|
||||||
|
- Integrationen
|
||||||
|
tags:
|
||||||
|
- ai
|
||||||
|
- search
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Eine umfangreichere, fertig importierbare Beispieldatei liegt unter
|
||||||
|
`docs/examples/import-tools.yaml` (24 gängige Enterprise-Tools).
|
||||||
|
|
||||||
|
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,108 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
A larger, ready-to-import example file is located at
|
||||||
|
`docs/examples/import-tools.yaml` (24 common enterprise tools).
|
||||||
|
|
||||||
|
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,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,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,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,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,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,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,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,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: Überblick
|
||||||
|
order: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
# Willkommen bei toolr
|
||||||
|
|
||||||
|
toolr ist eine Plattform zum **Entdecken, Bewerten und Vergleichen von
|
||||||
|
Entwicklungstools**. Nutzer 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 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: 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: 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,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,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,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,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,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,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,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,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,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,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.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user