Compare commits

..

31 Commits

Author SHA1 Message Date
opencode a65e2eb92c docs: link v0.8.3 release notes to commit 3bb4598
Build & Push Docker Image / build (push) Successful in 2m27s
2026-08-04 07:32:33 +02:00
opencode b886246808 docs: add v0.8.3 release notes 2026-08-04 07:32:14 +02:00
opencode 3bb4598d04 feat(docs): version-based docs, correct Gitea tag links, form guide help
- each release now carries a full docs snapshot (reference + handbook)
  under docs/releases/<v>/; the docs frontend loads a version's snapshot
  for /docs/vX.Y.Z/* and shows the handbook/reference of that version
- version dropdown navigates to /docs/<version> (docs home) instead of
  the release notes page; old /docs/vX.Y.Z redirect removed
- generator: --snapshot writes handbook + reference; build copies
  versioned snapshots (last 7 versions) into /docs/versions/<v>/
- fix Gitea tag links: /admin/tool-evaluator/tags/<v> was 404, correct
  URL is /releases/tag/<v>
- add GuideHelp button to forms/pages linking to the matching handbook
  guide (rating, tool add/edit, costs, compare, watchlist, analytics)
2026-08-04 07:31:47 +02:00
opencode d23990e322 docs: link v0.8.2 release notes to commit 33cf261
Build & Push Docker Image / build (push) Successful in 2m39s
2026-08-04 07:01:28 +02:00
opencode 33cf261777 fix(docs): mobile nav drawer and version dropdown state
Build & Push Docker Image / build (push) Successful in 2m30s
- sidebar was hidden below lg with no replacement; add a Sheet-based
  nav drawer opened from a menu button in the header
- version dropdown stayed on 'aktuell' for /docs/releases/* pages because
  only first-segment versions were parsed; derive header version from the
  releases route too
2026-08-04 06:58:07 +02:00
opencode 56f1edd613 docs: link v0.8.1 release notes to commit fdf2d74
Build & Push Docker Image / build (push) Successful in 2m30s
2026-08-03 22:52:23 +02:00
opencode fdf2d741a1 feat(docs): standalone docs page, Hilfe nav entry, rewritten user guide
- docs route renders without the app shell (own header, back-to-app link,
  theme toggle)
- nav entry renamed to Hilfe (de) / Help (en)
- reference links now case-insensitive (schema/tag slugs)
- handbook rewritten as 17-page user guide with links into the API reference
- release notes v0.8.1 added
2026-08-03 22:52:14 +02:00
opencode 9ceb11e7f9 docs: link v0.8.0 release notes to commit 6c92b63
Build & Push Docker Image / build (push) Successful in 3m14s
2026-08-03 22:16:00 +02:00
opencode 6c92b6358d feat(docs): mkdocs-style documentation site served at /docs
Full documentation hub replacing the release-notes-only view:
- Handbook pages (docs/handbook) for all features and admin/betrieb
- API reference generated from lib/api-spec/openapi.yaml via
  scripts/src/generate-docs.mjs (replaces sync-release-docs.mjs):
  endpoints, schemas/fields, search index, per-release snapshots
- mkdocs layout: sidebar nav, right TOC with scrollspy, search overlay,
  version dropdown, repo link
- FieldHelp (?) buttons in forms linking to reference field docs
- v0.7.0 release notes backfilled, v0.8.0 release notes added
2026-08-03 22:15:54 +02:00
opencode 520f917723 feat(docs): version-bound release documentation served at /docs
Build & Push Docker Image / build (push) Successful in 2m35s
Adds a docs pipeline so each release has a version-bound Markdown
document (docs/releases/vX.Y.Z.md) rendered publicly in the app:

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

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

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

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

Security/process:
- overrides for body-parser >=2.3.0 (GHSA-v422-hmwv-36x6) + markdown-it/linkify-it/brace-expansion/fast-uri (dev tooling)
- pnpm audit now reports 0 vulnerabilities (prod and full)
- CI audit gate added to build.yaml; docs/dependency-policy.md; renovate.json
- Dockerfile pins node:24.18.1-alpine and pnpm@11.18.0
2026-08-03 12:56:00 +02:00
opencode bcae59626f feat(security): add CSRF protection for all state-changing API routes
Build & Push Docker Image / build (push) Successful in 2m16s
- Synchronizer token stored in session; GET /auth/csrf to obtain it
- csrfProtection middleware requires X-CSRF-Token on non-safe methods
- customFetch injects the header via setCsrfTokenGetter
- toolrate boot loads token; reload after local login (session regenerate)
- OpenAPI GET /auth/csrf + CsrfToken schema, orval regenerated
2026-08-03 10:30:13 +02:00
opencode f851305d78 fix(tools): OR'ed where clauses now AND'd so search excludes soft-deleted tools; localize delete confirm dialog
Build & Push Docker Image / build (push) Successful in 2m25s
2026-08-03 08:48:08 +02:00
opencode 743b177c89 fix(auth): password min length 6 + show/hide toggle on password inputs
Build & Push Docker Image / build (push) Successful in 2m21s
2026-08-03 08:01:52 +02:00
opencode 68a81ec775 feat(auth): password change (self + admin reset) with rate limiting; dedupe watchlist to user menu
Build & Push Docker Image / build (push) Successful in 2m19s
2026-08-03 07:43:24 +02:00
opencode 63f0bdbab6 fix(nav): trim sidebar items, fix collapsed sidebar overflow, add add-tool buttons on home/browse
Build & Push Docker Image / build (push) Successful in 2m18s
2026-08-03 06:12:01 +02:00
opencode 4648614556 docs: replace Replit config/docs with README; drop Replit files
Build & Push Docker Image / build (push) Successful in 2m29s
2026-08-03 05:43:13 +02:00
opencode 53f01acb0c ci: deploy release tags by v*-tag (dev stays sha-based)
Build & Push Docker Image / build (push) Successful in 2m58s
2026-08-02 15:25:15 +02:00
opencode ab24f99b90 fix(nav): restore sidebar collapse state from cookie; cmd+k search shows tools only while typing; mobile header search
Build & Push Docker Image / build (push) Successful in 3m22s
2026-08-02 15:08:37 +02:00
opencode c6755b586b feat(nav): collapsible sidebar + mobile drawer, user menu, breadcrumbs, polished cmd+k, header search
Build & Push Docker Image / build (push) Successful in 2m46s
2026-08-02 14:55:18 +02:00
opencode 2f38889726 fix: compact filter popover; feat: i18n (de/en)
Build & Push Docker Image / build (push) Successful in 2m31s
2026-08-02 13:42:53 +02:00
opencode 95bf49509c fix: white home page - coerce pg numeric strings for top-tools ratings
Build & Push Docker Image / build (push) Successful in 2m32s
2026-08-02 13:10:28 +02:00
opencode d033b20dfb feat: watchlist (premium), mobile bottom nav
Build & Push Docker Image / build (push) Successful in 2m35s
2026-08-02 13:00:10 +02:00
opencode c77610786b feat: compare (premium), rating-history trend, hover previews
Build & Push Docker Image / build (push) Successful in 2m47s
2026-08-02 12:48:02 +02:00
opencode 0c27982098 ci: dev version includes UTC time (dev-YYYYMMDD-HHmm)
Build & Push Docker Image / build (push) Successful in 2m36s
Phase 1: browse power-up
- faceted filters: tags + features (array containment) and min rating
  on listTools; new ?tags=?features=?minRating= URL params with chips
- global Cmd+K command palette (cmdk) with tool search + navigation
- mini usefulness/usability bars on grid cards, wide cards and table rows
2026-08-02 11:51:25 +02:00
opencode 461eca9a0a ci: auto-version every push as dev-DATE with nightly-DATE image tag
Build & Push Docker Image / build (push) Successful in 2m20s
Push builds now bake VERSION=dev-YYYYMMDD and additionally tag the
image nightly-YYYYMMDD, so a dated, identifiable artifact exists without
creating a git tag. Explicit v* tags keep tagging IMAGE:vX.Y.Z.
2026-08-02 11:29:47 +02:00
opencode 620b12d4b6 fix: view toggle stuck after selecting table/rows
Build & Push Docker Image / build (push) Successful in 10m7s
URL param had precedence over the user's toggle choice, so once
?view=table|rows was in the URL the override could never win. User
selection now takes precedence; URL param only applies on first load.
2026-08-02 10:15:43 +02:00
opencode 78244c3197 feat: user-chosen browse views with grid/list toggle and profile sync
Build & Push Docker Image / build (push) Successful in 2m25s
- view modes grid | table | rows + density cozy/compact, persisted via
  localStorage and shareable ?view=?density= URL params (URL wins)
- table view: sortable columns (name, rating, reviews), new sort options
  name_asc/name_desc/recently_updated (backend enum + handler)
- live debounced search, removable filter chips, '/' focuses search
- virtualization via @tanstack/react-virtual for table and rows views
- profile sync: users.preferences jsonb + GET/PUT /api/auth/me/preferences;
  preference precedence URL > server profile > localStorage > default
- add local rollup/lightningcss/tailwindcss oxide native binaries for macos dev
2026-08-02 09:34:53 +02:00
203 changed files with 23913 additions and 2794 deletions
+22 -7
View File
@@ -14,6 +14,14 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install pnpm
run: npm install -g pnpm@11.18.0
- name: Security audit (fails on any prod or high/critical finding)
run: |
pnpm audit --prod
pnpm audit --audit-level high
- name: Install Docker CLI
run: |
apt-get update -qq
@@ -29,29 +37,36 @@ jobs:
SHA=$(git rev-parse --short HEAD)
FULL_SHA=$(git rev-parse HEAD)
IMAGE="git.kubebase.de/${{ gitea.repository }}"
if [ "${{ gitea.ref_type }}" = "tag" ]; then VERSION="${{ gitea.ref_name }}"; else VERSION=""; fi
TAGS="-t ${IMAGE}:sha-${SHA} -t ${IMAGE}:latest"
if [ -n "$VERSION" ]; then TAGS="${TAGS} -t ${IMAGE}:${VERSION}"; fi
DATE_STAMP=$(date -u +"%Y%m%d")
VERSION="dev-$(date -u +"%Y%m%d-%H%M")"
TAGS="-t ${IMAGE}:latest"
if [ "${{ gitea.ref_type }}" = "tag" ]; then
VERSION="${{ gitea.ref_name }}"
TAGS="${TAGS} -t ${IMAGE}:${VERSION}"
fi
docker build --no-cache \
--build-arg COMMIT_SHA="$FULL_SHA" \
--build-arg BUILD_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
--build-arg VERSION="$VERSION" \
$TAGS .
docker push "${IMAGE}:sha-${SHA}"
docker push "${IMAGE}:latest"
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
if: gitea.ref_type == 'tag'
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
SHA=$(git rev-parse --short HEAD)
NEWTAG="${{ gitea.ref_name }}"
git clone "https://admin:${GITEA_TOKEN}@git.kubebase.de/admin/apps.git" /tmp/apps
cd /tmp/apps
cd apps/system/toolrate/overlays/k3s
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.email "actions@git.kubebase.de"
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
+3
View File
@@ -47,3 +47,6 @@ Thumbs.db
# Replit
.cache/
.local/
# Generated release docs (produced by scripts/src/sync-release-docs.mjs during build)
/artifacts/toolrate/public/docs/
+1
View File
@@ -1,2 +1,3 @@
auto-install-peers=false
strict-peer-dependencies=false
save-exact=true
-35
View File
@@ -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"
-5
View File
@@ -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
View File
@@ -1,7 +1,7 @@
FROM node:24-alpine AS builder
FROM node:24.18.1-alpine AS builder
WORKDIR /app
RUN npm install -g pnpm@10.26.1
RUN npm install -g pnpm@11.18.0
# 1. Alle Projektdateien in den Container bringen
COPY . .
@@ -17,10 +17,10 @@ ENV PORT=8080
RUN pnpm -r --if-present run build
FROM node:24-alpine AS runner
FROM node:24.18.1-alpine AS runner
WORKDIR /app
RUN npm install -g pnpm@10.26.1
RUN npm install -g pnpm@11.18.0
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY lib/db/package.json lib/db/
+38 -35
View File
@@ -2,10 +2,30 @@
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 --filter @workspace/toolrate run dev` — run the frontend (port 26015)
- pnpm workspaces, Node.js 24, TypeScript 5.9
- 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 build` — typecheck + build all packages
- `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
- `DATABASE_URL` — Postgres connection string (auto-provisioned)
- `SESSION_SECRET`Session signing secret (already set)
- `DATABASE_URL` — Postgres connection string
- `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:
- `KEYCLOAK_URL` — e.g. `https://auth.example.com`
- `KEYCLOAK_REALM` — realm name
- `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
- 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.
- Login via local admin user (`LOCAL_ADMIN_*`). Keycloak/OIDC support exists as an optional legacy path.
## 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)
- `artifacts/api-server/src/routes/` — Express route handlers
- `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/components/` — shared components (layout, tool-card, category-combobox, feature-input)
- `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.
- 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).
- Grafana can consume `/api/analytics/*` endpoints directly via JSON datasource plugin.
- Grafana can consume `/api/analytics/*` endpoints directly via the JSON datasource plugin.
## 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
- 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
- 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.
- `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
- Auth uses PKCE — the `code_verifier` is stored in the session, not in-memory state.
@@ -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"
+20 -20
View File
@@ -12,29 +12,29 @@
"dependencies": {
"@workspace/api-zod": "workspace:*",
"@workspace/db": "workspace:*",
"bcryptjs": "^3.0.3",
"zod": "catalog:",
"connect-pg-simple": "^10.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"bcryptjs": "3.0.3",
"connect-pg-simple": "10.0.0",
"cookie-parser": "1.4.7",
"cors": "2.8.6",
"drizzle-orm": "catalog:",
"express": "^5.2.1",
"express-session": "^1.19.0",
"openid-client": "^5.7.1",
"pino": "^9.14.0",
"pino-http": "^10.5.0"
"express": "5.2.1",
"express-rate-limit": "8.6.1",
"express-session": "1.19.0",
"openid-client": "6.8.4",
"pino": "10.3.1",
"pino-http": "11.0.0",
"zod": "catalog:"
},
"devDependencies": {
"@types/bcryptjs": "^3.0.0",
"@types/connect-pg-simple": "^7.0.3",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/express-session": "^1.19.0",
"@types/connect-pg-simple": "7.0.3",
"@types/cookie-parser": "1.4.10",
"@types/cors": "2.8.19",
"@types/express": "5.0.6",
"@types/express-session": "1.19.0",
"@types/node": "catalog:",
"esbuild": "0.27.3",
"esbuild-plugin-pino": "^2.3.3",
"pino-pretty": "^13.1.3",
"thread-stream": "3.1.0"
"esbuild": "0.28.1",
"esbuild-plugin-pino": "2.3.3",
"pino-pretty": "13.1.3",
"thread-stream": "4.2.0"
}
}
@@ -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[]> = {
free: ["browse", "rate", "search"],
premium: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash"],
enterprise: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "sso", "audit-export", "api-access"],
premium: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "compare", "watchlist"],
enterprise: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "compare", "watchlist", "sso", "audit-export", "api-access"],
};
export function getEntitlements(tier: string | undefined, role: string | undefined): string[] {
+7 -1
View File
@@ -104,7 +104,13 @@ router.get("/analytics/top-tools", async (req, res): Promise<void> => {
? (Number(avgUsefulness) + Number(avgUsability)) / 2
: null;
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),
ratingCount,
};
+194 -30
View File
@@ -1,14 +1,29 @@
import { Router, type IRouter, type Request } from "express";
import { Issuer, generators, type Client } from "openid-client";
import {
discovery,
randomPKCECodeVerifier,
calculatePKCECodeChallenge,
randomState,
buildAuthorizationUrl,
authorizationCodeGrant,
fetchUserInfo,
buildEndSessionUrl,
skipSubjectCheck,
type Configuration,
} from "openid-client";
import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm";
import { db, usersTable } from "@workspace/db";
import { eq, and, inArray, isNull } from "drizzle-orm";
import { z } from "zod";
import { db, usersTable, toolsTable, ratingsTable } from "@workspace/db";
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();
let cachedClient: Client | null = null;
let cachedConfig: Configuration | null = null;
function isOidcConfigured(): boolean {
return !!(
@@ -35,8 +50,8 @@ function isSafeReturnTo(value: string): boolean {
}
}
async function getClient(): Promise<Client | null> {
if (cachedClient) return cachedClient;
async function getClient(): Promise<Configuration | null> {
if (cachedConfig) return cachedConfig;
const keycloakUrl = process.env.KEYCLOAK_URL;
const realm = process.env.KEYCLOAK_REALM;
@@ -48,14 +63,9 @@ async function getClient(): Promise<Client | null> {
}
try {
const issuerUrl = `${keycloakUrl}/realms/${realm}`;
const issuer = await Issuer.discover(issuerUrl);
cachedClient = new issuer.Client({
client_id: clientId,
client_secret: clientSecret,
response_types: ["code"],
});
return cachedClient;
const issuerUrl = new URL(`${keycloakUrl}/realms/${realm}`);
cachedConfig = await discovery(issuerUrl, clientId, clientSecret);
return cachedConfig;
} catch (err) {
logger.error({ err }, "Failed to discover Keycloak issuer");
return null;
@@ -106,7 +116,11 @@ router.get("/auth/mode", (_req, res): void => {
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()) {
res.status(400).json({ error: "Use OIDC login when Keycloak is configured." });
return;
@@ -168,9 +182,9 @@ router.get("/auth/login", async (req, res): Promise<void> => {
return;
}
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
const state = generators.state();
const codeVerifier = randomPKCECodeVerifier();
const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
const state = randomState();
req.session.codeVerifier = codeVerifier;
req.session.oidcState = state;
@@ -179,7 +193,7 @@ router.get("/auth/login", async (req, res): Promise<void> => {
}
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
const url = client.authorizationUrl({
const url = buildAuthorizationUrl(client, {
scope: "openid email profile",
code_challenge: codeChallenge,
code_challenge_method: "S256",
@@ -187,7 +201,7 @@ router.get("/auth/login", async (req, res): Promise<void> => {
state,
});
res.redirect(url);
res.redirect(url.href);
});
router.get("/auth/callback", async (req, res): Promise<void> => {
@@ -213,13 +227,13 @@ router.get("/auth/callback", async (req, res): Promise<void> => {
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
try {
const params = client.callbackParams(req);
const tokenSet = await client.callback(redirectUri, params, {
code_verifier: codeVerifier,
state,
});
const tokens = await authorizationCodeGrant(
client,
new URL(req.originalUrl ?? "/", getBaseUrl(req)),
{ pkceCodeVerifier: codeVerifier, expectedState: state },
);
const userinfo = await client.userinfo(tokenSet.access_token!);
const userinfo = await fetchUserInfo(client, tokens.access_token, skipSubjectCheck);
const dbUser = await upsertUserFromOidc(userinfo);
await new Promise<void>((resolve, reject) => {
@@ -251,9 +265,9 @@ router.get("/auth/logout", async (req, res): Promise<void> => {
req.session.destroy(() => {});
const client = await getClient();
if (client && client.issuer.metadata.end_session_endpoint) {
const logoutUrl = client.endSessionUrl({ post_logout_redirect_uri: getBaseUrl(req) });
res.redirect(logoutUrl);
if (client && client.serverMetadata().end_session_endpoint) {
const logoutUrl = buildEndSessionUrl(client, { post_logout_redirect_uri: getBaseUrl(req) });
res.redirect(logoutUrl.href);
return;
}
@@ -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;
+3
View File
@@ -8,9 +8,12 @@ import usersRouter from "./users";
import auditRouter from "./audit";
import costsRouter from "./costs";
import adminRouter from "./admin";
import { csrfProtection } from "../middleware/csrf";
const router: IRouter = Router();
router.use(csrfProtection);
router.use(authRouter);
router.use(healthRouter);
router.use(toolsRouter);
+85 -5
View File
@@ -1,5 +1,5 @@
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 { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db";
import {
@@ -52,16 +52,27 @@ router.get("/tools", async (req, res): Promise<void> => {
res.status(400).json({ error: parsed.error.message });
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) {
query = query.where(eq(toolsTable.category, category));
conditions.push(eq(toolsTable.category, category));
}
if (search) {
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));
@@ -87,6 +98,16 @@ router.get("/tools", async (req, res): Promise<void> => {
result = result.sort((a, b) => (b.avgCombined ?? 0) - (a.avgCombined ?? 0));
} else if (sort === "most_reviewed") {
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);
@@ -213,6 +234,65 @@ router.get("/tools/:id", async (req, res): Promise<void> => {
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> => {
const params = UpdateToolParams.safeParse(req.params);
if (!params.success) {
+43 -1
View File
@@ -4,6 +4,7 @@ import bcrypt from "bcryptjs";
import { db, usersTable } from "@workspace/db";
import { requireAdmin } from "../middleware/auth";
import { writeAuditLog } from "../lib/audit";
import { passwordRateLimit } from "../lib/rate-limit";
import { z } from "zod";
const router: IRouter = Router();
@@ -23,6 +24,46 @@ const UserUpdateSchema = z.object({
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> => {
const users = await db
.select({
@@ -31,6 +72,7 @@ router.get("/users", requireAdmin, async (req, res): Promise<void> => {
email: usersTable.email,
role: usersTable.role,
tier: usersTable.tier,
authProvider: usersTable.authProvider,
createdAt: usersTable.createdAt,
})
.from(usersTable)
@@ -56,7 +98,7 @@ router.post("/users", requireAdmin, async (req, res): Promise<void> => {
return;
}
const passwordHash = await bcrypt.hash(parsed.data.password, 10);
const passwordHash = await bcrypt.hash(parsed.data.password, 12);
const [user] = await db
.insert(usersTable)
.values({
+1
View File
@@ -14,5 +14,6 @@ declare module "express-session" {
codeVerifier?: string;
returnTo?: 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"
+44 -43
View File
@@ -10,34 +10,34 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@hookform/resolvers": "5.7.1",
"@radix-ui/react-accordion": "1.2.20",
"@radix-ui/react-alert-dialog": "1.1.23",
"@radix-ui/react-aspect-ratio": "1.1.15",
"@radix-ui/react-avatar": "1.2.6",
"@radix-ui/react-checkbox": "1.3.11",
"@radix-ui/react-collapsible": "1.1.20",
"@radix-ui/react-context-menu": "2.3.7",
"@radix-ui/react-dialog": "1.1.23",
"@radix-ui/react-dropdown-menu": "2.1.24",
"@radix-ui/react-hover-card": "1.1.23",
"@radix-ui/react-label": "2.1.15",
"@radix-ui/react-menubar": "1.1.24",
"@radix-ui/react-navigation-menu": "1.2.22",
"@radix-ui/react-popover": "1.1.23",
"@radix-ui/react-progress": "1.1.16",
"@radix-ui/react-radio-group": "1.4.7",
"@radix-ui/react-scroll-area": "1.2.18",
"@radix-ui/react-select": "2.3.7",
"@radix-ui/react-separator": "1.1.15",
"@radix-ui/react-slider": "1.4.7",
"@radix-ui/react-slot": "1.3.3",
"@radix-ui/react-switch": "1.3.7",
"@radix-ui/react-tabs": "1.1.21",
"@radix-ui/react-toast": "1.2.23",
"@radix-ui/react-toggle": "1.1.18",
"@radix-ui/react-toggle-group": "1.1.19",
"@radix-ui/react-tooltip": "1.2.16",
"@replit/vite-plugin-cartographer": "catalog:",
"@replit/vite-plugin-runtime-error-modal": "catalog:",
"@tailwindcss/vite": "catalog:",
@@ -45,29 +45,30 @@
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "catalog:",
"chokidar": "^4.0.3",
"chokidar": "5.0.0",
"class-variance-authority": "catalog:",
"clsx": "catalog:",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
"fast-glob": "^3.3.3",
"cmdk": "1.1.1",
"date-fns": "4.4.0",
"embla-carousel-react": "8.6.0",
"fast-glob": "3.3.3",
"framer-motion": "catalog:",
"input-otp": "^1.4.2",
"input-otp": "1.4.2",
"lucide-react": "catalog:",
"next-themes": "^0.4.6",
"next-themes": "0.4.6",
"react": "catalog:",
"react-day-picker": "^9.14.0",
"react-day-picker": "10.0.1",
"react-dom": "catalog:",
"react-hook-form": "^7.75.0",
"react-resizable-panels": "^2.1.9",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"react-hook-form": "7.84.0",
"react-is": "19.2.8",
"react-resizable-panels": "4.12.2",
"recharts": "3.10.1",
"sonner": "2.0.7",
"tailwind-merge": "catalog:",
"tailwindcss": "catalog:",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
"tailwindcss-animate": "1.0.7",
"tw-animate-css": "1.4.0",
"vaul": "1.1.2",
"vite": "catalog:",
"zod": "catalog:"
}
@@ -84,7 +84,7 @@ function Calendar({
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
month_grid: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
@@ -3,6 +3,7 @@ import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
@@ -101,7 +102,7 @@ const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
RechartsPrimitive.TooltipContentProps &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
@@ -191,7 +192,7 @@ const ChartTooltipContent = React.forwardRef<
return (
<div
key={item.dataKey}
key={typeof item.dataKey === "string" || typeof item.dataKey === "number" ? item.dataKey : index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
@@ -260,7 +261,7 @@ const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
Pick<RechartsPrimitive.DefaultLegendContentProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
@@ -317,6 +318,7 @@ const ChartLegendContent = React.forwardRef<
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
@@ -8,10 +8,10 @@ import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
}: React.ComponentProps<typeof ResizablePrimitive.Group>) => (
<ResizablePrimitive.Group
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
"flex h-full w-full data-[group-orientation=vertical]:flex-col",
className
)}
{...props}
@@ -24,12 +24,12 @@ const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
<ResizablePrimitive.Separator
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[group-orientation=vertical]:h-px data-[group-orientation=vertical]:w-full data-[group-orientation=vertical]:after:left-0 data-[group-orientation=vertical]:after:h-1 data-[group-orientation=vertical]:after:w-full data-[group-orientation=vertical]:after:-translate-y-1/2 data-[group-orientation=vertical]:after:translate-x-0 [&[data-group-orientation=vertical]>div]:rotate-90",
className
)}
{...props}
@@ -39,7 +39,7 @@ const ResizableHandle = ({
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
</ResizablePrimitive.Separator>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
@@ -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 = "/"
+51 -45
View File
@@ -4,46 +4,47 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite --config vite.config.ts --host 0.0.0.0",
"build": "vite build --config vite.config.ts",
"dev": "node ../../scripts/src/generate-docs.mjs && vite --config vite.config.ts --host 0.0.0.0",
"build": "node ../../scripts/src/generate-docs.mjs && vite build --config vite.config.ts",
"serve": "vite preview --config vite.config.ts --host 0.0.0.0",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "^1.2.4",
"@radix-ui/react-alert-dialog": "^1.1.7",
"@radix-ui/react-aspect-ratio": "^1.1.3",
"@radix-ui/react-avatar": "^1.1.4",
"@radix-ui/react-checkbox": "^1.1.5",
"@radix-ui/react-collapsible": "^1.1.4",
"@radix-ui/react-context-menu": "^2.2.7",
"@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-dropdown-menu": "^2.1.7",
"@radix-ui/react-hover-card": "^1.1.7",
"@radix-ui/react-label": "^2.1.3",
"@radix-ui/react-menubar": "^1.1.7",
"@radix-ui/react-navigation-menu": "^1.2.6",
"@radix-ui/react-popover": "^1.1.7",
"@radix-ui/react-progress": "^1.1.3",
"@radix-ui/react-radio-group": "^1.2.4",
"@radix-ui/react-scroll-area": "^1.2.4",
"@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slider": "^1.2.4",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-switch": "^1.1.4",
"@radix-ui/react-tabs": "^1.1.4",
"@radix-ui/react-toast": "^1.2.7",
"@radix-ui/react-toggle": "^1.1.3",
"@radix-ui/react-toggle-group": "^1.1.3",
"@radix-ui/react-tooltip": "^1.2.0",
"@hookform/resolvers": "5.7.1",
"@radix-ui/react-accordion": "1.2.20",
"@radix-ui/react-alert-dialog": "1.1.23",
"@radix-ui/react-aspect-ratio": "1.1.15",
"@radix-ui/react-avatar": "1.2.6",
"@radix-ui/react-checkbox": "1.3.11",
"@radix-ui/react-collapsible": "1.1.20",
"@radix-ui/react-context-menu": "2.3.7",
"@radix-ui/react-dialog": "1.1.23",
"@radix-ui/react-dropdown-menu": "2.1.24",
"@radix-ui/react-hover-card": "1.1.23",
"@radix-ui/react-label": "2.1.15",
"@radix-ui/react-menubar": "1.1.24",
"@radix-ui/react-navigation-menu": "1.2.22",
"@radix-ui/react-popover": "1.1.23",
"@radix-ui/react-progress": "1.1.16",
"@radix-ui/react-radio-group": "1.4.7",
"@radix-ui/react-scroll-area": "1.2.18",
"@radix-ui/react-select": "2.3.7",
"@radix-ui/react-separator": "1.1.15",
"@radix-ui/react-slider": "1.4.7",
"@radix-ui/react-slot": "1.3.3",
"@radix-ui/react-switch": "1.3.7",
"@radix-ui/react-tabs": "1.1.21",
"@radix-ui/react-toast": "1.2.23",
"@radix-ui/react-toggle": "1.1.18",
"@radix-ui/react-toggle-group": "1.1.19",
"@radix-ui/react-tooltip": "1.2.16",
"@replit/vite-plugin-cartographer": "catalog:",
"@replit/vite-plugin-dev-banner": "catalog:",
"@replit/vite-plugin-runtime-error-modal": "catalog:",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/typography": "0.5.20",
"@tailwindcss/vite": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-virtual": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
@@ -51,27 +52,32 @@
"@workspace/api-client-react": "workspace:*",
"class-variance-authority": "catalog:",
"clsx": "catalog:",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
"cmdk": "1.1.1",
"date-fns": "4.4.0",
"dompurify": "catalog:",
"embla-carousel-react": "8.6.0",
"framer-motion": "catalog:",
"input-otp": "^1.4.2",
"i18next": "26.3.6",
"input-otp": "1.4.2",
"lucide-react": "catalog:",
"next-themes": "^0.4.6",
"marked": "catalog:",
"next-themes": "0.4.6",
"react": "catalog:",
"react-day-picker": "^9.11.1",
"react-day-picker": "10.0.1",
"react-dom": "catalog:",
"react-hook-form": "^7.55.0",
"react-icons": "^5.4.0",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2",
"sonner": "^2.0.7",
"react-hook-form": "7.84.0",
"react-i18next": "17.0.11",
"react-icons": "5.7.0",
"react-is": "19.2.8",
"react-resizable-panels": "4.12.2",
"recharts": "3.10.1",
"sonner": "2.0.7",
"tailwind-merge": "catalog:",
"tailwindcss": "catalog:",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
"tw-animate-css": "1.4.0",
"vaul": "1.1.2",
"vite": "catalog:",
"wouter": "^3.3.5",
"wouter": "catalog:",
"zod": "catalog:"
}
}
+15
View File
@@ -1,5 +1,9 @@
import { Switch, Route, Router as WouterRouter } from "wouter";
import { useEffect } from "react";
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 { TooltipProvider } from "@/components/ui/tooltip";
@@ -13,7 +17,10 @@ import Analytics from "@/pages/analytics";
import Admin from "@/pages/admin";
import Redundancy from "@/pages/redundancy";
import Trash from "@/pages/trash";
import Compare from "@/pages/compare";
import Watchlist from "@/pages/watchlist";
import Login from "@/pages/login";
import Docs from "@/pages/docs";
import NotFound from "@/pages/not-found";
const queryClient = new QueryClient({
@@ -34,17 +41,24 @@ function Router() {
<Route path="/tools/new" component={ToolNew} />
<Route path="/tools/:id/edit" component={ToolEdit} />
<Route path="/tools/:id" component={ToolDetail} />
<Route path="/compare" component={Compare} />
<Route path="/watchlist" component={Watchlist} />
<Route path="/analytics" component={Analytics} />
<Route path="/admin" component={Admin} />
<Route path="/admin/redundancy" component={Redundancy} />
<Route path="/trash" component={Trash} />
<Route path="/docs/*?" component={Docs} />
<Route component={NotFound} />
</Switch>
);
}
function App() {
useEffect(() => {
void loadCsrfToken();
}, []);
return (
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}>
@@ -53,6 +67,7 @@ function App() {
<Toaster />
</TooltipProvider>
</QueryClientProvider>
</I18nextProvider>
);
}
@@ -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>
);
}
@@ -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,28 @@
import { Slider } from "@/components/ui/slider";
import { Maximize2, Minimize2 } from "lucide-react";
export type Density = "cozy" | "compact";
export function DensityToggle({
value,
onValueChange,
}: {
value: Density;
onValueChange: (d: Density) => void;
}) {
return (
<div className="flex items-center gap-2" title={`Density: ${value === "compact" ? "Compact" : "Comfortable"}`}>
<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="List density"
/>
<Minimize2 className="w-4 h-4 text-muted-foreground" aria-hidden />
</div>
);
}
@@ -0,0 +1,32 @@
import { HelpCircle } from "lucide-react";
import { Link } from "wouter";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export function FieldHelp({
schema,
field,
children,
}: {
schema: string;
field: string;
children?: React.ReactNode;
}) {
const label = children ?? field;
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/docs/reference/schemas/${schema}#${field}`}
target="_blank"
rel="noreferrer"
aria-label={`Help: ${label}`}
data-testid={`help-${schema}-${field}`}
className="inline-flex shrink-0 text-muted-foreground hover:text-foreground transition-colors"
>
<HelpCircle className="h-3.5 w-3.5" />
</Link>
</TooltipTrigger>
<TooltipContent>{label} Details in der Dokumentation</TooltipContent>
</Tooltip>
);
}
@@ -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="Minimum rating"
/>
<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,29 @@
import { BookOpen } from "lucide-react";
import { Link } from "wouter";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export function GuideHelp({
guide,
label,
}: {
guide: string;
label: string;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/docs/handbook/${guide}`}
target="_blank"
rel="noreferrer"
aria-label={`Help: ${label}`}
data-testid={`guide-${guide}`}
className="inline-flex shrink-0 text-muted-foreground hover:text-foreground transition-colors"
>
<BookOpen className="h-3.5 w-3.5" />
</Link>
</TooltipTrigger>
<TooltipContent>{label} Anleitung in der Dokumentation</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>
);
}
+140 -100
View File
@@ -1,112 +1,121 @@
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 { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
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 }) {
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({
query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false },
});
const links = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
{ href: "/tools", label: "Browse Tools", icon: Wrench },
{ href: "/tools/new", label: "Add Tool", icon: PlusCircle },
{ href: "/analytics", label: "Analytics", icon: BarChart3 },
...(hasFeature("trash") ? [{ href: "/trash", label: "Trash", icon: Trash2 }] : []),
...(isAdmin ? [{ href: "/admin", label: "Admin", icon: ShieldCheck }] : []),
...(isAdmin ? [{ href: "/admin/redundancy", label: "Redundancy", icon: AlertTriangle }] : []),
const mainLinks = [
{ href: "/", label: t("nav.home"), icon: LayoutDashboard },
{ href: "/tools", label: t("nav.browseTools"), icon: Wrench },
{ href: "/analytics", label: t("nav.analytics"), icon: BarChart3 },
{ href: "/docs", label: t("nav.docs"), icon: FileText },
];
const activeLink = links.find(
(link) => location === link.href || (link.href !== "/" && location.startsWith(link.href)),
);
const adminLinks = [
{ 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 (
<div className="flex min-h-screen bg-background text-foreground">
<aside className="w-64 border-r bg-card flex flex-col hidden md:flex">
<div className="p-6 border-b">
<SidebarProvider>
<CommandPalette />
<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">
<Wrench className="w-6 h-6" />
<span>toolr</span>
<Wrench className="w-6 h-6 shrink-0" />
<span className="group-data-[collapsible=icon]:hidden">toolr</span>
</Link>
</div>
<nav className="flex-1 p-4 space-y-2">
{links.map((link) => {
const isActive = location === link.href || (link.href !== "/" && location.startsWith(link.href));
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{mainLinks.map((link) => {
const Icon = link.icon;
return (
<Link
key={link.href}
href={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"}`}
>
<Icon className="w-5 h-5" />
{link.label}
<SidebarMenuItem key={link.href}>
<SidebarMenuButton asChild isActive={isActive(link.href)} tooltip={link.label}>
<Link href={link.href}>
<Icon />
<span>{link.label}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</nav>
<div className="p-4 border-t">
{isLoading ? (
<div className="flex items-center gap-3 px-3 py-2">
<Skeleton className="w-8 h-8 rounded-full" />
<Skeleton className="h-4 w-24" />
</div>
) : isAuthenticated && user ? (
<div className="space-y-2">
<div className="flex items-center gap-3 px-3 py-2 rounded-md bg-muted/50">
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center shrink-0">
<User className="w-4 h-4 text-primary" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate text-foreground">
{user.name || user.preferredUsername || "User"}
</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] uppercase tracking-wider font-semibold px-1.5 py-0.5 rounded-sm bg-primary/10 text-primary">{tier}</span>
{user.email && <span className="text-xs text-muted-foreground truncate">{user.email}</span>}
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground hover:text-destructive"
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>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{isAdmin && (
<SidebarGroup>
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">
{t("nav.admin")}
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{adminLinks.map((link) => {
const Icon = link.icon;
return (
<SidebarMenuItem key={link.href}>
<SidebarMenuButton asChild isActive={isActive(link.href)} tooltip={link.label}>
<Link href={link.href}>
<Icon />
<span>{link.label}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
</div>
<div className="px-4 py-2.5 border-t flex items-center justify-between text-[11px] text-muted-foreground">
<span className="font-medium">toolr</span>
</SidebarContent>
<SidebarFooter className="gap-2">
<UserMenu />
<div className="border-t pt-2 flex items-center justify-between text-[11px] text-muted-foreground px-2 group-data-[collapsible=icon]:justify-center">
<span className="font-medium group-data-[collapsible=icon]:hidden">toolr</span>
{version?.commitSha ? (
<a
href={`https://git.kubebase.de/admin/tool-evaluator/commit/${version.commitSha}`}
target="_blank"
rel="noreferrer"
className="font-mono hover:underline"
className="font-mono hover:underline truncate group-data-[collapsible=icon]:hidden"
title={version.commitSha}
>
{version.version && version.version !== "dev"
@@ -114,43 +123,74 @@ export function Layout({ children }: { children: React.ReactNode }) {
: `sha-${version.commitSha.slice(0, 7)}`}
</a>
) : version?.version && version.version !== "dev" ? (
<span className="font-mono">{version.version}</span>
<span className="font-mono group-data-[collapsible=icon]:hidden">{version.version}</span>
) : null}
</div>
</aside>
</SidebarFooter>
<SidebarRail />
</Sidebar>
<main className="flex-1 flex flex-col min-w-0">
<header className="hidden md:flex items-center justify-between border-b bg-card px-6 py-3">
<h1 className="text-sm font-semibold text-muted-foreground">
{activeLink?.label ?? "toolr"}
</h1>
<ThemeToggle />
</header>
<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">
<SidebarInset>
<header className="flex items-center justify-between gap-2 border-b bg-card px-3 md:px-5 h-14 shrink-0">
<div className="flex items-center gap-1 min-w-0">
<SidebarTrigger className="h-8 w-8 shrink-0" data-testid="button-sidebar-toggle" />
<Link
href="/"
className="md:hidden flex items-center gap-2 text-primary font-bold text-lg shrink-0 pl-1"
>
<Wrench className="w-5 h-5" />
<span>toolr</span>
</Link>
<div className="flex items-center gap-1">
<div className="hidden md:block min-w-0 pl-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 />
{!isLoading && (
isAuthenticated ? (
<Button variant="ghost" size="sm" onClick={logout} data-testid="button-logout-mobile">
<div className="md:hidden">
{isAuthenticated ? (
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={logout}
aria-label={t("auth.signOut")}
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">
<Button
variant="outline"
size="sm"
onClick={() => login(location)}
data-testid="button-login-mobile"
>
<LogIn className="w-4 h-4 mr-1" />
Sign in
{t("auth.signIn")}
</Button>
)
)}
</div>
)}
</div>
</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}
</div>
</main>
</div>
</SidebarInset>
</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>
);
}
@@ -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 { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card";
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 { 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 (
<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">
@@ -47,7 +57,8 @@ export function ToolCard({ tool }: { tool: ToolWithStats }) {
)}
</div>
</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 justify-between 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" />
@@ -60,6 +71,48 @@ export function ToolCard({ tool }: { tool: ToolWithStats }) {
<span>{tool.ratingCount}</span>
</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>
</Link>
</Card>
@@ -0,0 +1,54 @@
import { Link } from "wouter";
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 }) {
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">{tool.ratingCount} review{tool.ratingCount === 1 ? "" : "s"}</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"
>
View details
</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",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
month_grid: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
@@ -102,7 +102,7 @@ const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
RechartsPrimitive.TooltipContentProps &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
@@ -192,7 +192,7 @@ const ChartTooltipContent = React.forwardRef<
return (
<div
key={item.dataKey}
key={typeof item.dataKey === "string" || typeof item.dataKey === "number" ? item.dataKey : index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
@@ -261,7 +261,7 @@ const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
Pick<RechartsPrimitive.DefaultLegendContentProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
@@ -8,10 +8,10 @@ import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
}: React.ComponentProps<typeof ResizablePrimitive.Group>) => (
<ResizablePrimitive.Group
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
"flex h-full w-full data-[group-orientation=vertical]:flex-col",
className
)}
{...props}
@@ -24,12 +24,12 @@ const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
<ResizablePrimitive.Separator
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[group-orientation=vertical]:h-px data-[group-orientation=vertical]:w-full data-[group-orientation=vertical]:after:left-0 data-[group-orientation=vertical]:after:h-1 data-[group-orientation=vertical]:after:w-full data-[group-orientation=vertical]:after:-translate-y-1/2 data-[group-orientation=vertical]:after:translate-x-0 [&[data-group-orientation=vertical]>div]:rotate-90",
className
)}
{...props}
@@ -39,7 +39,7 @@ const ResizableHandle = ({
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
</ResizablePrimitive.Separator>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
@@ -53,6 +53,15 @@ function useSidebar() {
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({
defaultOpen = true,
open: openProp,
@@ -71,7 +80,10 @@ function SidebarProvider({
// This is the internal state of the sidebar.
// 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 setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
@@ -0,0 +1,256 @@
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 { 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>
<DialogTitle>{t("auth.changePassword")}</DialogTitle>
</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="min. 6 characters"
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,37 @@
import { LayoutGrid, List, Rows3 } from "lucide-react";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
export type ViewMode = "grid" | "table" | "rows";
const OPTIONS: { value: ViewMode; label: string; Icon: typeof LayoutGrid }[] = [
{ value: "grid", label: "Grid", Icon: LayoutGrid },
{ value: "table", label: "Table", Icon: List },
{ value: "rows", label: "Rows", Icon: Rows3 },
];
export function ViewToggle({
value,
onValueChange,
}: {
value: ViewMode;
onValueChange: (v: ViewMode) => void;
}) {
return (
<ToggleGroup
type="single"
value={value}
onValueChange={(v) => {
if (v) onValueChange(v as ViewMode);
}}
variant="outline"
size="sm"
aria-label="View mode"
>
{OPTIONS.map(({ value: v, label, Icon }) => (
<ToggleGroupItem key={v} value={v} aria-label={label} title={label} 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,
};
}
+37
View File
@@ -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;
+221
View File
@@ -0,0 +1,221 @@
{
"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",
"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"
},
"home": {
"welcome": "Willkommen bei toolr",
"tagline": "Die Community-Plattform, auf der Ingenieure die Tools, die sie täglich nutzen, ehrlich bewerten.",
"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",
"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 (AZ)",
"sortNameDesc": "Name (ZA)",
"sortRecentlyUpdated": "Zuletzt aktualisiert"
},
"filter": {
"tags": "Tags",
"features": "Funktionen",
"minRating": "Mindestbewertung",
"any": "Beliebig",
"clearFilters": "Filter zurücksetzen"
},
"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"
},
"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"
},
"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",
"fieldHelpHint": "Hinweis: Formular-Felder verlinken per ?-Icon direkt zu den jeweiligen Zeilen dieser Tabelle."
},
"command": {
"navigate": "Navigation",
"recent": "Zuletzt besucht",
"tools": "Tools",
"noResults": "Keine Tools für „{{query}}“ gefunden.",
"startTyping": "Beginne zu tippen, um Tools zu suchen."
}
}
+221
View File
@@ -0,0 +1,221 @@
{
"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",
"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"
},
"home": {
"welcome": "Welcome to toolr",
"tagline": "The community hub where engineers honestly rate the tools they use daily.",
"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",
"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 (AZ)",
"sortNameDesc": "Name (ZA)",
"sortRecentlyUpdated": "Recently Updated"
},
"filter": {
"tags": "Tags",
"features": "Features",
"minRating": "Min. rating",
"any": "Any",
"clearFilters": "Clear filters"
},
"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"
},
"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"
},
"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": "Guide",
"endpoints": "Endpoints",
"schemas": "Data models",
"releases": "Release notes",
"reference": "API reference",
"referenceIntro": "Generated automatically from the OpenAPI spec — all endpoints and data fields of the current version.",
"onThisPage": "On this page",
"searchPlaceholder": "Search docs…",
"backToApp": "Back to app",
"field": "Field",
"type": "Type",
"required": "Required",
"description": "Description",
"status": "Status",
"schema": "Schema",
"parameter": "Parameter",
"noResults": "No results",
"fields": "Fields",
"fieldHelpHint": "Note: form fields link via the ? icon directly to the respective rows of this table."
},
"command": {
"navigate": "Navigate",
"recent": "Recent",
"tools": "Tools",
"noResults": "No tools found for \"{{query}}\".",
"startTyping": "Start typing to search tools."
}
}
+19
View File
@@ -265,6 +265,25 @@
*/
@layer utilities {
/* Documentation heading anchors (mkdocs style "¶" links) */
.docs-prose :is(h1, h2, h3, h4) {
scroll-margin-top: 6rem;
position: relative;
}
.docs-prose .docs-anchor::after {
content: "¶";
margin-left: 0.35rem;
font-size: 0.8em;
color: hsl(var(--muted-foreground));
opacity: 0;
transition: opacity 0.15s ease;
}
.docs-prose :is(h1, h2, h3, h4):hover .docs-anchor::after {
opacity: 0.8;
}
/* Hide ugly search cancel button in Chrome until we can style it properly */
input[type="search"]::-webkit-search-cancel-button {
@apply hidden;
+27
View File
@@ -0,0 +1,27 @@
import { setCsrfTokenGetter } from "@workspace/api-client-react";
let token: string | null = null;
setCsrfTokenGetter(() => token);
export function getCsrfToken(): string | null {
return token;
}
export async function loadCsrfToken(): Promise<string | null> {
try {
const res = await fetch(`/api/auth/csrf`, {
credentials: "include",
});
if (!res.ok) {
token = null;
return null;
}
const data = (await res.json()) as { token?: string };
token = data.token ?? null;
return token;
} catch {
token = null;
return null;
}
}
@@ -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 */
}
}
+55 -6
View File
@@ -5,6 +5,7 @@ import {
useCreateUser,
useUpdateUser,
useDeleteUser,
useSetUserPassword,
useListAuditLogs,
useGetVersion,
getListUsersQueryKey,
@@ -16,6 +17,7 @@ import { useAuth } from "@/hooks/use-auth";
import { Layout } from "@/components/layout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/password-input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
@@ -35,7 +37,8 @@ export default function Admin() {
const queryClient = useQueryClient();
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 [newUsername, setNewUsername] = useState("");
@@ -58,6 +61,7 @@ export default function Admin() {
const createUser = useCreateUser();
const updateUser = useUpdateUser();
const deleteUser = useDeleteUser();
const setUserPassword = useSetUserPassword();
if (!authLoading && !isAdmin) {
return (
@@ -110,6 +114,26 @@ export default function Admin() {
);
};
const handleSetUserPassword = () => {
if (!editUser || !editPassword) return;
if (editPassword.length < 6) {
toast({ title: "Password too short", description: "Minimum 6 characters.", variant: "destructive" });
return;
}
setUserPassword.mutate(
{ id: editUser.id, data: { password: editPassword } },
{
onSuccess: () => {
toast({ title: "Password updated", description: `Password for ${editUser.username} has been set.` });
setEditPassword("");
},
onError: (err) => {
toast({ title: "Failed to set password", description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" });
},
},
);
};
const handleDeleteUser = () => {
if (!deleteConfirm) return;
deleteUser.mutate(
@@ -206,7 +230,7 @@ export default function Admin() {
variant="ghost"
size="icon"
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" />
</Button>
@@ -344,7 +368,7 @@ export default function Admin() {
</div>
<div className="space-y-2">
<Label>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="min. 6 characters" />
</div>
<div className="space-y-2">
<Label>Email (Optional)</Label>
@@ -385,7 +409,7 @@ export default function Admin() {
</DialogContent>
</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">
<DialogHeader>
<DialogTitle>Edit User {editUser?.username}</DialogTitle>
@@ -422,15 +446,40 @@ export default function Admin() {
</SelectContent>
</Select>
</div>
{editUser?.authProvider !== "oidc" ? (
<div className="space-y-2 border-t pt-4">
<Label>Set Password</Label>
<PasswordInput
value={editPassword}
onChange={(e) => setEditPassword(e.target.value)}
placeholder="min. 6 characters"
data-testid="input-set-password"
/>
<p className="text-xs text-muted-foreground">Resets the user's password immediately.</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditUser(null)}>Cancel</Button>
) : (
<div className="space-y-2 border-t pt-4 text-sm text-muted-foreground">
Password is managed by the identity provider (Keycloak). Reset it there.
</div>
)}
</div>
<DialogFooter className="flex-col sm:flex-row sm:justify-end gap-2">
<Button variant="outline" onClick={() => { setEditUser(null); setEditPassword(""); }}>Cancel</Button>
<Button
onClick={handleUpdateUser}
disabled={updateUser.isPending}
>
Save
</Button>
{editUser?.authProvider !== "oidc" && (
<Button
variant="outline"
onClick={handleSetUserPassword}
disabled={setUserPassword.isPending || !editPassword}
>
{setUserPassword.isPending ? "Setting…" : "Set Password"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
+5 -1
View File
@@ -6,6 +6,7 @@ import {
GetTopToolsMetric
} from "@workspace/api-client-react";
import { Layout } from "@/components/layout";
import { GuideHelp } from "@/components/guide-help";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
@@ -48,7 +49,10 @@ export default function Analytics() {
<Layout>
<div className="space-y-6 pb-10">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Platform Analytics</h1>
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
Platform Analytics
<GuideHelp guide="analytics" label="Platform Analytics" />
</h1>
<p className="text-muted-foreground">Macro-level insights into tool performance and community engagement.</p>
</div>
+224
View File
@@ -0,0 +1,224 @@
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 { GuideHelp } from "@/components/guide-help";
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 flex items-center gap-2">
{t("compare.title")}
<GuideHelp guide="vergleichen" label={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">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">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">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">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
+28 -18
View File
@@ -8,11 +8,13 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/com
import { Skeleton } from "@/components/ui/skeleton";
import { Layout } from "@/components/layout";
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 { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
export default function Home() {
const { t } = useTranslation();
const { data: summary, isLoading: loadingSummary } = useGetAnalyticsSummary();
const { data: topTools, isLoading: loadingTopTools } = useGetTopTools({ limit: 4, metric: GetTopToolsMetric.combined });
const { data: recentTools, isLoading: loadingRecent } = useListTools({ sort: "newest" });
@@ -20,9 +22,17 @@ export default function Home() {
return (
<Layout>
<div className="space-y-8 pb-8">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Welcome to toolr</h1>
<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>
{/* Stats Banner */}
@@ -33,7 +43,7 @@ export default function Home() {
<Wrench className="w-6 h-6" />
</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 ? (
<Skeleton className="h-8 w-16" />
) : (
@@ -49,7 +59,7 @@ export default function Home() {
<MessageSquare className="w-6 h-6" />
</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 ? (
<Skeleton className="h-8 w-16" />
) : (
@@ -65,7 +75,7 @@ export default function Home() {
<Star className="w-6 h-6" />
</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 ? (
<Skeleton className="h-8 w-16" />
) : (
@@ -80,12 +90,12 @@ export default function Home() {
<section>
<div className="flex justify-between items-end mb-4">
<div>
<h2 className="text-2xl font-bold tracking-tight mb-1">Top Rated Tools</h2>
<p className="text-muted-foreground text-sm">Highest combined usefulness and usability.</p>
<h2 className="text-2xl font-bold tracking-tight mb-1">{t("home.topRated")}</h2>
<p className="text-muted-foreground text-sm">{t("home.topRatedSub")}</p>
</div>
<Button variant="ghost" className="text-primary hidden sm:flex" asChild>
<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>
</Button>
</div>
@@ -106,10 +116,10 @@ export default function Home() {
<Card className="bg-muted/30 border-dashed">
<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" />
<h3 className="text-lg font-medium">No ratings yet</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>
<h3 className="text-lg font-medium">{t("home.noRatingsYet")}</h3>
<p className="text-muted-foreground max-w-sm mt-1 mb-4">{t("home.noRatingsSub")}</p>
<Button asChild>
<Link href="/tools">Browse Tools</Link>
<Link href="/tools">{t("common.browseTools")}</Link>
</Button>
</CardContent>
</Card>
@@ -120,12 +130,12 @@ export default function Home() {
<section>
<div className="flex justify-between items-end mb-4">
<div>
<h2 className="text-2xl font-bold tracking-tight mb-1">Recently Added</h2>
<p className="text-muted-foreground text-sm">The latest additions to the catalog.</p>
<h2 className="text-2xl font-bold tracking-tight mb-1">{t("home.recentlyAdded")}</h2>
<p className="text-muted-foreground text-sm">{t("home.recentlyAddedSub")}</p>
</div>
<Button variant="ghost" className="text-primary hidden sm:flex" asChild>
<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>
</Button>
</div>
@@ -146,10 +156,10 @@ export default function Home() {
<Card className="bg-muted/30 border-dashed">
<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" />
<h3 className="text-lg font-medium">No tools found</h3>
<p className="text-muted-foreground max-w-sm mt-1 mb-4">There are no tools in the database yet.</p>
<h3 className="text-lg font-medium">{t("home.noTools")}</h3>
<p className="text-muted-foreground max-w-sm mt-1 mb-4">{t("home.noToolsSub")}</p>
<Button asChild>
<Link href="/tools/new">Add a Tool</Link>
<Link href="/tools/new">{t("browse.addTool")}</Link>
</Button>
</CardContent>
</Card>
+14 -10
View File
@@ -5,11 +5,15 @@ import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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 { ThemeToggle } from "@/components/theme-toggle";
import { Wrench, AlertCircle } from "lucide-react";
import { useTranslation } from "react-i18next";
export default function Login() {
const { t } = useTranslation();
const [, setLocation] = useLocation();
const search = useSearch();
const params = new URLSearchParams(search);
@@ -30,10 +34,11 @@ export default function Login() {
{
onSuccess: () => {
queryClient.invalidateQueries();
void loadCsrfToken();
setLocation(returnTo);
},
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" />
<span>toolr</span>
</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>
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-lg">Sign in</CardTitle>
<CardDescription>Enter your credentials to continue</CardDescription>
<CardTitle className="text-lg">{t("auth.signIn")}</CardTitle>
<CardDescription>{t("auth.loginDescription")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Label htmlFor="username">{t("auth.username")}</Label>
<Input
id="username"
type="text"
@@ -73,10 +78,9 @@ export default function Login() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
<Label htmlFor="password">{t("auth.password")}</Label>
<PasswordInput
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
@@ -95,7 +99,7 @@ export default function Login() {
className="w-full"
disabled={localLogin.isPending}
>
{localLogin.isPending ? "Signing in..." : "Sign in"}
{localLogin.isPending ? t("common.loading") : t("auth.signIn")}
</Button>
</form>
</CardContent>
@@ -103,7 +107,7 @@ export default function Login() {
<div className="text-center">
<Link href="/" className="text-sm text-muted-foreground hover:text-primary transition-colors">
Back to Home
{t("notFound.backHome")}
</Link>
</div>
</div>
+4 -2
View File
@@ -2,8 +2,10 @@ import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Wrench, ArrowLeft } from "lucide-react";
import { Link } from "wouter";
import { useTranslation } from "react-i18next";
export default function NotFound() {
const { t } = useTranslation();
return (
<div className="min-h-screen w-full flex items-center justify-center bg-background">
<Card className="w-full max-w-md mx-4">
@@ -13,10 +15,10 @@ export default function NotFound() {
<span>toolr</span>
</Link>
<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="/">
<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>
</Link>
</CardContent>
+104 -29
View File
@@ -7,6 +7,8 @@ import {
getListToolRatingsQueryKey,
useGetRatingDistribution,
getGetRatingDistributionQueryKey,
useGetToolRatingHistory,
getGetToolRatingHistoryQueryKey,
useCreateRating,
getGetTopToolsQueryKey,
getGetAnalyticsSummaryQueryKey,
@@ -38,10 +40,13 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
import { ExternalLink, Star, ArrowLeft, Plus, Pencil, Trash2, Link as LinkIcon, DollarSign, Euro } from "lucide-react";
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, Bookmark } from "lucide-react";
import { Link } from "wouter";
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 {
AlertDialog,
@@ -54,6 +59,9 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { customFetch } from "@workspace/api-client-react";
import { recordRecentTool } from "@/lib/recent-tools";
import { FieldHelp } from "@/components/field-help";
import { GuideHelp } from "@/components/guide-help";
const ratingSchema = z.object({
usefulness: z.number().min(1).max(5),
@@ -75,6 +83,8 @@ export default function ToolDetail() {
const [deleteOpen, setDeleteOpen] = useState(false);
const { user, isAdmin, hasFeature } = useAuth();
const { t } = useTranslation();
const { isWatched, toggle: toggleWatchlist, canWatchlist } = useWatchlist();
const canManageCosts = hasFeature("costs");
const hasTrash = hasFeature("trash");
const deleteTool = useDeleteTool();
@@ -195,6 +205,10 @@ export default function ToolDetail() {
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, {
query: { enabled: !!id, queryKey: getListToolRatingsQueryKey(id) }
});
@@ -203,6 +217,10 @@ export default function ToolDetail() {
query: { enabled: !!id, queryKey: getGetRatingDistributionQueryKey({ toolId: id }) }
});
const { data: ratingHistory, isLoading: loadingRatingHistory } = useGetToolRatingHistory(id, {
query: { enabled: !!id, queryKey: getGetToolRatingHistoryQueryKey(id) }
});
const createRating = useCreateRating();
const form = useForm<RatingFormValues>({
@@ -346,13 +364,24 @@ export default function ToolDetail() {
<span className="text-muted-foreground self-end mb-1">/ 5</span>
</div>
<div className="text-sm text-muted-foreground">
Based on {tool.ratingCount} reviews
{t("detail.basedOnReview", { count: tool.ratingCount })}
</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 && (
<Button asChild className="w-full mt-2" variant="outline">
<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>
</Button>
)}
@@ -361,7 +390,7 @@ export default function ToolDetail() {
<div className="flex gap-2 w-full mt-1">
<Button asChild variant="outline" size="sm" className="flex-1 gap-2">
<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>
</Button>
<Button
@@ -370,7 +399,7 @@ export default function ToolDetail() {
className="flex-1 gap-2 text-destructive hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="w-3.5 h-3.5" /> Delete
<Trash2 className="w-3.5 h-3.5" /> {t("detail.delete")}
</Button>
</div>
)}
@@ -536,10 +565,10 @@ export default function ToolDetail() {
{tool && (
<div className="space-y-4 mt-8">
<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 && (
<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>
)}
</div>
@@ -587,7 +616,10 @@ export default function ToolDetail() {
<Dialog open={costDialogOpen} onOpenChange={(o) => { if (!o) setCostDialogOpen(false); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editCost ? "Edit Cost" : "Add Cost"}</DialogTitle>
<DialogTitle className="flex items-center gap-2">
{editCost ? t("detail.edit") + " " + t("detail.costs") : t("detail.addCost")}
<GuideHelp guide="kosten" label={t("detail.costs")} />
</DialogTitle>
<DialogDescription>Manage license cost information for this tool.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
@@ -654,19 +686,19 @@ export default function ToolDetail() {
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Rating Breakdown</CardTitle>
<CardTitle>{t("detail.ratingBreakdown")}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div>
<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>
</div>
<Progress value={((tool.avgUsefulness || 0) / 5) * 100} className="h-2" />
</div>
<div>
<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>
</div>
<Progress value={((tool.avgUsability || 0) / 5) * 100} className="h-2" />
@@ -676,7 +708,7 @@ export default function ToolDetail() {
<Card>
<CardHeader>
<CardTitle>Score Distribution</CardTitle>
<CardTitle>{t("detail.scoreDistribution")}</CardTitle>
</CardHeader>
<CardContent>
{loadingDistribution ? (
@@ -695,21 +727,52 @@ export default function ToolDetail() {
)}
</CardContent>
</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="Combined" stroke="hsl(var(--primary))" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="usefulness" name="Usefulness" stroke="hsl(var(--success))" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
<Line type="monotone" dataKey="usability" name="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>
{/* Right Column: Reviews */}
<div className="lg:col-span-2 space-y-6">
<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 && (
<Button onClick={() => setIsReviewFormOpen(true)}>Write a Review</Button>
<Button onClick={() => setIsReviewFormOpen(true)}>{t("detail.addReview")}</Button>
)}
</div>
{isReviewFormOpen && (
<Card className="border-primary shadow-sm">
<CardHeader>
<CardTitle>Write a Review</CardTitle>
<CardTitle className="flex items-center gap-2">
{t("detail.addReview")}
<GuideHelp guide="bewerten" label={t("detail.addReview")} />
</CardTitle>
<CardDescription>Share your experience with {tool.name}</CardDescription>
</CardHeader>
<CardContent>
@@ -721,7 +784,10 @@ export default function ToolDetail() {
name="usefulness"
render={({ field }) => (
<FormItem>
<FormLabel>Usefulness</FormLabel>
<FormLabel className="inline-flex items-center gap-1.5">
{t("detail.usefulness")}
<FieldHelp schema="RatingInput" field="usefulness">{t("detail.usefulness")}</FieldHelp>
</FormLabel>
<div className="py-2">
<RatingStars
value={field.value}
@@ -739,7 +805,10 @@ export default function ToolDetail() {
name="usability"
render={({ field }) => (
<FormItem>
<FormLabel>Usability</FormLabel>
<FormLabel className="inline-flex items-center gap-1.5">
{t("detail.usability")}
<FieldHelp schema="RatingInput" field="usability">{t("detail.usability")}</FieldHelp>
</FormLabel>
<div className="py-2">
<RatingStars
value={field.value}
@@ -759,7 +828,10 @@ export default function ToolDetail() {
name="comment"
render={({ field }) => (
<FormItem>
<FormLabel>Comment (Optional)</FormLabel>
<FormLabel className="inline-flex items-center gap-1.5">
Comment (Optional)
<FieldHelp schema="RatingInput" field="comment">Comment</FieldHelp>
</FormLabel>
<FormControl>
<Textarea
placeholder="What do you think about this tool?"
@@ -777,7 +849,10 @@ export default function ToolDetail() {
name="reviewerName"
render={({ field }) => (
<FormItem>
<FormLabel>Name (Optional)</FormLabel>
<FormLabel className="inline-flex items-center gap-1.5">
Name (Optional)
<FieldHelp schema="RatingInput" field="reviewerName">Name</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder="Anonymous" {...field} />
</FormControl>
@@ -796,7 +871,7 @@ export default function ToolDetail() {
Cancel
</Button>
<Button type="submit" disabled={createRating.isPending}>
{createRating.isPending ? "Submitting..." : "Submit Review"}
{createRating.isPending ? t("common.loading") : t("detail.submit")}
</Button>
</div>
</form>
@@ -825,11 +900,11 @@ export default function ToolDetail() {
<div className="flex gap-6 mb-4">
<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" />
</div>
<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" />
</div>
</div>
@@ -845,7 +920,7 @@ export default function ToolDetail() {
) : (
<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" />
<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>
</div>
)}
@@ -859,23 +934,23 @@ export default function ToolDetail() {
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this tool?</AlertDialogTitle>
<AlertDialogTitle>{t("detail.deleteConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{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>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete}
disabled={deleteTool.isPending}
>
{deleteTool.isPending ? "Deleting" : "Delete"}
{deleteTool.isPending ? t("detail.deleting") : t("detail.deleteAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+31 -7
View File
@@ -30,6 +30,8 @@ import { CategoryCombobox } from "@/components/category-combobox";
import { FeatureInput } from "@/components/feature-input";
import { TagInput } from "@/components/tag-input";
import { useAuth } from "@/hooks/use-auth";
import { FieldHelp } from "@/components/field-help";
import { GuideHelp } from "@/components/guide-help";
const toolSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
@@ -159,6 +161,7 @@ export default function ToolEdit() {
<CardTitle className="flex items-center gap-2">
<Pencil className="w-5 h-5 text-primary" />
Tool Details
<GuideHelp guide="tool-bearbeiten" label="Tool Details" />
</CardTitle>
<CardDescription>Modify the tool information below.</CardDescription>
</CardHeader>
@@ -171,7 +174,10 @@ export default function ToolEdit() {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel className="inline-flex items-center gap-1.5">
Name
<FieldHelp schema="ToolInput" field="name">Name</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder="Tool name" {...field} />
</FormControl>
@@ -184,7 +190,10 @@ export default function ToolEdit() {
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Category</FormLabel>
<FormLabel className="inline-flex items-center gap-1.5">
Category
<FieldHelp schema="ToolInput" field="category">Category</FieldHelp>
</FormLabel>
<FormControl>
<CategoryCombobox value={field.value} onChange={field.onChange} />
</FormControl>
@@ -199,7 +208,10 @@ export default function ToolEdit() {
name="websiteUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Website URL (Optional)</FormLabel>
<FormLabel className="inline-flex items-center gap-1.5">
Website URL (Optional)
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder="https://..." type="url" {...field} />
</FormControl>
@@ -213,7 +225,10 @@ export default function ToolEdit() {
name="iconUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Icon / Logo URL (Optional)</FormLabel>
<FormLabel className="inline-flex items-center gap-1.5">
Icon / Logo URL (Optional)
<FieldHelp schema="ToolInput" field="iconUrl">Icon / Logo URL</FieldHelp>
</FormLabel>
<FormControl>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
@@ -246,7 +261,10 @@ export default function ToolEdit() {
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormLabel className="inline-flex items-center gap-1.5">
Description
<FieldHelp schema="ToolInput" field="description">Description</FieldHelp>
</FormLabel>
<FormControl>
<Textarea
placeholder="What does this tool do?"
@@ -262,7 +280,10 @@ export default function ToolEdit() {
<div className="space-y-4 pt-4 border-t">
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium">Features</h3>
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
Features
<FieldHelp schema="ToolInput" field="features">Features</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">Key capabilities of this tool. Existing features from other tools are selectable.</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => appendFeature({ value: "" })}>
@@ -306,7 +327,10 @@ export default function ToolEdit() {
<div className="space-y-4 pt-4 border-t">
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium">Tags</h3>
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
Tags
<FieldHelp schema="ToolInput" field="tags">Tags</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">Keywords for this tool. Existing tags from other tools are selectable.</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => appendTag({ value: "" })}>
+31 -7
View File
@@ -18,6 +18,8 @@ import { CategoryCombobox } from "@/components/category-combobox";
import { FeatureInput } from "@/components/feature-input";
import { TagInput } from "@/components/tag-input";
import { useAuth } from "@/hooks/use-auth";
import { FieldHelp } from "@/components/field-help";
import { GuideHelp } from "@/components/guide-help";
const toolSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
@@ -122,6 +124,7 @@ export default function ToolNew() {
<CardTitle className="flex items-center gap-2">
<Wrench className="w-5 h-5 text-primary" />
Tool Details
<GuideHelp guide="tool-anlegen" label="Tool Details" />
</CardTitle>
<CardDescription>Provide the basic information about the tool.</CardDescription>
</CardHeader>
@@ -134,7 +137,10 @@ export default function ToolNew() {
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel className="inline-flex items-center gap-1.5">
Name
<FieldHelp schema="ToolInput" field="name">Name</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder="e.g. React, Next.js, Postgres" {...field} data-testid="input-tool-name" />
</FormControl>
@@ -148,7 +154,10 @@ export default function ToolNew() {
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Category</FormLabel>
<FormLabel className="inline-flex items-center gap-1.5">
Category
<FieldHelp schema="ToolInput" field="category">Category</FieldHelp>
</FormLabel>
<FormControl>
<CategoryCombobox
value={field.value}
@@ -166,7 +175,10 @@ export default function ToolNew() {
name="websiteUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Website URL (Optional)</FormLabel>
<FormLabel className="inline-flex items-center gap-1.5">
Website URL (Optional)
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder="https://..." type="url" {...field} data-testid="input-tool-url" />
</FormControl>
@@ -180,7 +192,10 @@ export default function ToolNew() {
name="iconUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Icon / Logo URL (Optional)</FormLabel>
<FormLabel className="inline-flex items-center gap-1.5">
Icon / Logo URL (Optional)
<FieldHelp schema="ToolInput" field="iconUrl">Icon / Logo URL</FieldHelp>
</FormLabel>
<FormControl>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
@@ -214,7 +229,10 @@ export default function ToolNew() {
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormLabel className="inline-flex items-center gap-1.5">
Description
<FieldHelp schema="ToolInput" field="description">Description</FieldHelp>
</FormLabel>
<FormControl>
<Textarea
placeholder="What does this tool do? Why do people use it?"
@@ -231,7 +249,10 @@ export default function ToolNew() {
<div className="space-y-4 pt-4 border-t">
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium">Features</h3>
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
Features
<FieldHelp schema="ToolInput" field="features">Features</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">List key capabilities. Existing features from other tools are selectable.</p>
</div>
<Button
@@ -284,7 +305,10 @@ export default function ToolNew() {
<div className="space-y-4 pt-4 border-t">
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium">Tags</h3>
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
Tags
<FieldHelp schema="ToolInput" field="tags">Tags</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">Keywords to help find this tool. Existing tags from other tools are selectable.</p>
</div>
<Button
+445 -46
View File
@@ -1,25 +1,86 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef, useMemo } from "react";
import {
useListTools,
useListCategories,
ListToolsSort
ListToolsSort,
} from "@workspace/api-client-react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { Layout } from "@/components/layout";
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 { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
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 { 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() {
const [, navigate] = useLocation();
const urlSearch = useSearch();
const { t } = useTranslation();
const initialParams = new URLSearchParams(urlSearch);
const initialParams = useMemo(() => new URLSearchParams(urlSearch), [urlSearch]);
const initialSearch = initialParams.get("search") ?? "";
const initialCategory = initialParams.get("category") ?? "all";
const initialSortParam = initialParams.get("sort") ?? "";
@@ -27,69 +88,172 @@ export default function ToolsBrowse() {
? (initialSortParam as ListToolsSort)
: 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 [searchInput, setSearchInput] = useState(initialSearch);
const [category, setCategory] = useState<string>(initialCategory);
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(() => {
const p = new URLSearchParams();
if (search) p.set("search", search);
if (category && category !== "all") p.set("category", category);
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();
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 queryParams = {
...(search ? { search } : {}),
...(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 allTools = tools ?? [];
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearch(searchInput);
};
function setView(v: ViewMode) {
setOverrideView(v);
persist(v, density);
}
function setDensity(d: Density) {
setOverrideDensity(d);
persist(view, d);
}
const clearFilters = () => {
setSearch("");
setSearchInput("");
setCategory("all");
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 (
<Layout>
<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>
<h1 className="text-3xl font-bold tracking-tight mb-2">Browse Tools</h1>
<p className="text-muted-foreground">Discover and evaluate the best tools for your stack.</p>
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("nav.browseTools")}</h1>
<p className="text-muted-foreground">{t("browse.subtitle")}</p>
</div>
<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>
</div>
{/* Filters */}
<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" />
<Input
placeholder="Search tools..."
ref={searchInputRef}
placeholder={`${t("common.search")}… (press "/" to focus)`}
className="pl-9 w-full"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
data-testid="input-search"
/>
</form>
</div>
<div className="flex flex-wrap sm:flex-nowrap gap-4 shrink-0">
<Select value={category} onValueChange={setCategory}>
@@ -97,74 +261,309 @@ export default function ToolsBrowse() {
<SelectValue placeholder="All Categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
{!loadingCategories && categories?.map((c) => (
<SelectItem key={c} value={c}>{c}</SelectItem>
<SelectItem value="all">{t("browse.allCategories")}</SelectItem>
{!loadingCategories &&
categories?.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={sort} onValueChange={(val) => setSort(val as ListToolsSort)}>
<SelectTrigger className="w-full sm:w-[180px]" data-testid="select-sort">
<SelectValue placeholder="Sort By" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ListToolsSort.newest}>Newest</SelectItem>
<SelectItem value={ListToolsSort.top_rated}>Top Rated</SelectItem>
<SelectItem value={ListToolsSort.most_reviewed}>Most Reviewed</SelectItem>
{Object.values(ListToolsSort).map((v) => (
<SelectItem key={v} value={v}>
{t(SORT_KEYS[v])}
</SelectItem>
))}
</SelectContent>
</Select>
<FilterPopover
tags={tags}
features={features}
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 && (
<Button variant="ghost" size="icon" onClick={clearFilters} className="shrink-0" title="Clear filters">
<X className="w-4 h-4" />
</Button>
<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={`Remove 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="Remove min rating"
>
<X className="w-3.5 h-3.5" />
</button>
</Badge>
)}
<Button variant="link" size="sm" className="px-1 text-muted-foreground" onClick={clearFilters}>
Clear all
</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>
{/* Results */}
<div>
<div className="flex items-center text-sm text-muted-foreground mb-4">
<SlidersHorizontal className="w-4 h-4 mr-2" />
{loadingTools ? "Loading tools..." : `Showing ${tools?.length || 0} tools`}
</div>
{loadingTools ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{[1, 2, 3, 4, 5, 6, 7, 8].map(i => (
<Skeleton key={`sk-tools-${i}`} className="h-[200px] w-full rounded-xl" />
<Skeletons view={view} density={density} />
) : allTools.length > 0 ? (
view === "grid" ? (
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4", gridPad)}>
{allTools.map((tool) => (
<ToolCard
key={tool.id}
tool={tool}
compare={{ selected: compareIds.includes(tool.id), onToggle: () => toggleCompare(tool.id) }}
watchlist={canWatchlist ? { watched: isWatched(tool.id), onToggle: () => toggleWatchlist(tool.id) } : undefined}
/>
))}
</div>
) : tools && tools.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{tools.map((tool) => (
<ToolCard key={tool.id} tool={tool} />
) : view === "rows" ? (
<div ref={rowsParentRef} className="overflow-auto max-h-[72vh]">
<div className="relative" style={{ height: rowsVirtualizer.getTotalSize() }}>
{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 ref={tableParentRef} className="overflow-auto max-h-[72vh] rounded-xl border bg-background">
<div className={cn("grid items-center gap-3 border-b bg-muted sticky top-0 z-10 px-4", TABLE_GRID)}>
<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 className="relative px-4" style={{ height: tableVirtualizer.getTotalSize() }}>
{tableVirtualizer.getVirtualItems().map((vi) => {
const tool = allTools[vi.index];
return (
<div
key={tool.id}
ref={tableVirtualizer.measureElement}
data-index={vi.index}
className="absolute top-0 left-0 w-full"
style={{ transform: `translateY(${vi.start}px)` }}
>
<HoverCard openDelay={250} closeDelay={100}>
<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 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">No tools found</h3>
<h3 className="text-xl font-medium mb-2">{t("browse.noToolsFound")}</h3>
<p className="text-muted-foreground max-w-md mx-auto mb-6">
We couldn't find any tools matching your current filters. Try adjusting your search criteria or add a new tool.
{t("browse.noToolsMatch")}
</p>
<div className="flex gap-4">
{hasFilters && (
<Button variant="outline" onClick={clearFilters}>
Clear Filters
{t("filter.clearFilters")}
</Button>
)}
<Button asChild>
<Link href="/tools/new">Add Tool</Link>
<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>Compare is a Premium feature</AlertDialogTitle>
<AlertDialogDescription>
Comparing tools side-by-side is available to Premium and Enterprise users. Upgrade your plan to unlock it.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Not now</AlertDialogCancel>
<AlertDialogAction asChild>
<a href="/admin?tab=plan">Upgrade to Premium</a>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</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>
);
}
+18 -16
View File
@@ -27,8 +27,10 @@ import { useToast } from "@/hooks/use-toast";
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 { format } from "date-fns";
import { useTranslation } from "react-i18next";
export default function Trash() {
const { t } = useTranslation();
const { user, isAdmin, hasFeature, isLoading: authLoading } = useAuth();
const { toast } = useToast();
const queryClient = useQueryClient();
@@ -139,10 +141,10 @@ export default function Trash() {
<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">Trash requires a higher tier</h2>
<p className="text-muted-foreground">The trash is available to Premium and Enterprise users.</p>
<h2 className="text-2xl font-bold">{t("trash.requiresPremium")}</h2>
<p className="text-muted-foreground">{t("trash.requiresPremiumSub")}</p>
<Button variant="outline" asChild>
<a href="/tools">Back to tools</a>
<a href="/tools">{t("common.backToTools")}</a>
</Button>
</div>
</Layout>
@@ -155,16 +157,16 @@ export default function Trash() {
<Layout>
<div className="space-y-6 pb-10">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Trash</h1>
<p className="text-muted-foreground">Deleted tools are kept here until they are restored or permanently removed.</p>
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("trash.title")}</h1>
<p className="text-muted-foreground">{t("trash.subtitle")}</p>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4 flex-wrap">
<div className="space-y-1.5">
<CardTitle>Trashed Tools</CardTitle>
<CardTitle>{t("trash.trashedTools")}</CardTitle>
<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>
</div>
<div className="flex items-center gap-2 flex-wrap">
@@ -172,7 +174,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" />
<Input
className="pl-8 w-56"
placeholder="Search trash…"
placeholder={`${t("common.search")}`}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
@@ -183,7 +185,7 @@ export default function Trash() {
disabled={selectedIds.length === 0 || restore.isPending}
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>
{isAdmin && (
<>
@@ -193,7 +195,7 @@ export default function Trash() {
disabled={selectedIds.length === 0 || deletePermanent.isPending}
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
size="sm"
@@ -201,7 +203,7 @@ export default function Trash() {
disabled={trashed.length === 0 || empty.isPending}
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>
</>
)}
@@ -215,7 +217,7 @@ export default function Trash() {
) : trashed.length === 0 ? (
<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" />
{search ? "No tools match your search." : "The trash is empty."}
{search ? t("trash.noMatch") : t("trash.empty")}
</div>
) : (
<Table>
@@ -230,8 +232,8 @@ export default function Trash() {
</TableHead>
<TableHead>Name</TableHead>
<TableHead>Category</TableHead>
<TableHead>Deleted at</TableHead>
<TableHead>Deleted by</TableHead>
<TableHead>{t("trash.deletedAt")}</TableHead>
<TableHead>{t("trash.deletedBy")}</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -294,7 +296,7 @@ export default function Trash() {
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => handleDeletePermanent(selectedIds)}
>
Delete permanently
{t("trash.deletePermanently")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -314,7 +316,7 @@ export default function Trash() {
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleEmpty}
>
Empty trash
{t("trash.emptyTrash")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -0,0 +1,82 @@
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 { GuideHelp } from "@/components/guide-help";
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 flex items-center gap-2">
{t("watchlist.title")}
<GuideHelp guide="watchlist" label={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">Browse tools</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>
);
}
+51
View File
@@ -0,0 +1,51 @@
# Dokumentation (Handbuch, API-Referenz, Release-Notes)
Die App zeigt unter `/docs` eine MkDocs-artige Doku-Seite mit drei Bereichen:
- **Handbuch** (`docs/handbook/*.md`) — von Hand gepflegte Anleitungen
- **API-Referenz** (`lib/api-spec/openapi.yaml`) — automatisch generierte
Endpunkte & Datenfelder (Schema-Detailseiten mit Feld-Ankern; die `?`-Icons
in Formularen verlinken auf diese Felder)
- **Release-Notes** (`docs/releases/vX.Y.Z.md`) — pro Release
## Struktur
- `docs/handbook/` — Handbuch-Seiten mit Frontmatter (`title`, `order`)
- `docs/releases/TEMPLATE.md` — Vorlage für neue Releases
- `docs/releases/vX.Y.Z.md` — Notes pro Release
- `docs/releases/vX.Y.Z/reference.json` — API-Snapshot der jeweiligen Version
## Generator
`scripts/src/generate-docs.mjs` wird beim Frontend-Build (und `dev`) automatisch
ausgeführt und schreibt die Artefakte nach `artifacts/toolrate/public/docs/`:
- `reference.json` (aktuelle API), `search.json` (Suchindex),
`index.json` (Releases), `handbook/*.md` + `handbook/index.json`
- `releases/vX.Y.Z.md` und `versions/vX.Y.Z.json` (API-Snapshots alter Versionen)
Manuell aufrufbar:
```sh
node scripts/src/generate-docs.mjs # Build-Modus
node scripts/src/generate-docs.mjs --snapshot v0.9.0 # Snapshot für neue Version
```
## Workflow beim Release
1. **Version taggen** wie bisher (`git tag vX.Y.Z`, CI baut und deployed).
2. **`docs/releases/vX.Y.Z.md` anlegen** — Vorlage aus `TEMPLATE.md` kopieren,
Entwurf aus der Git-Historie ableiten:
```sh
git log --oneline vX.Y.Z-1..vX.Y.Z
```
(API-Delta anhand `lib/api-spec/openapi.yaml` prüfen.)
3. **API-Snapshot erzeugen:** `node scripts/src/generate-docs.mjs --snapshot vX.Y.Z`
erzeugt `docs/releases/vX.Y.Z/reference.json`.
4. **Committen & pushen.** Der Build kopiert die Dokumentation automatisch nach
`artifacts/toolrate/public/docs/` und generiert `index.json`.
> Hinweis: Alle Dateien unter `artifacts/toolrate/public/docs/` sind
> Build-Artefakte und werden bei jedem Build neu generiert — nicht von Hand
> bearbeiten. Einzige Quellen sind `docs/handbook/`, `docs/releases/` und
> `lib/api-spec/openapi.yaml`.
+54
View File
@@ -0,0 +1,54 @@
# Dependency Policy
How this workspace keeps npm dependencies current, safe and reproducible.
## Principles
1. **Exact pins** Every direct dependency in `package.json` is pinned to an
exact version (`1.2.3`, never `^1.2.3`). `save-exact=true` is set in
`.npmrc` so `pnpm add` follows this rule automatically.
2. **Committed lockfile** `pnpm-lock.yaml` is committed. CI and the Docker
build install with `--frozen-lockfile`, so builds are reproducible.
3. **Security gates** CI runs `pnpm audit --prod` (fails on any finding) and
`pnpm audit --audit-level high` (fails on high/critical). A non-zero exit
blocks the release pipeline.
4. **Supply-chain protection** `minimumReleaseAge: 1440` (1 day) in
`pnpm-workspace.yaml` blocks freshly published versions. Do not lower or
disable it; only allowlist trusted publishers via
`minimumReleaseAgeExclude` for urgent security fixes.
5. **Pinned critical packages** `react`, `react-dom` and `esbuild` are
intentionally excluded from automated updates (see `renovate.json`). Bump
them deliberately, one release at a time, with a test pass.
## Update cadence
| Frequency | Scope | Who |
|-----------|-------|-----|
| Weekly | Patch + minor (grouped by Renovate) | Renovate PR, human merge after green CI |
| Monthly | One major version at a time | Human, own commit + release tag |
| As needed | Security advisories | Immediate fix + patch release |
| Yearly | Infrastructure review (Node LTS, Postgres major, k3s) | Human |
## Rules
- **Patch/minor**: merge freely once CI (typecheck + build + audit) is green.
- **Major**: never bundle multiple majors into one release. One major per
commit so regressions can be bisected to the responsible change.
- **"Safe intermediate"**: if a package has a newer major that is not yet
absorbed, stay on the latest patch/minor of the *current* major line. The
exact-pin guarantees we never float into a new major by accident.
- **Node/Infra**: Node base image and pnpm version in the `Dockerfile` are
pinned exactly. Update them together with a build + live smoke test.
- **Postgres**: image tag is managed in the `admin/apps` repository
(`apps/system/toolrate`). Major upgrades run through a backup/restore flow.
## Process for applying updates
1. `pnpm install` to refresh the lockfile.
2. Regenerate clients if the API changed: `pnpm --filter @workspace/api-spec run codegen`.
3. Run `pnpm run typecheck`, `pnpm run build` (with `PORT=8080 BASE_PATH=/`),
and `pnpm audit`.
4. Fix any code that the new majors require (the common ones are
`openid-client`, `recharts`, `react-day-picker`, `date-fns`,
`@hookform/resolvers`, `react-resizable-panels`).
5. Commit, tag `vX.Y.Z`, push. CI builds, audits and deploys to k3s.
+65
View File
@@ -0,0 +1,65 @@
---
title: Administration
order: 13
---
# Administration
Der Bereich **Admin** (`/admin`) ist ausschließlich für Admins zugänglich.
Ohne Admin-Rolle erscheint eine Zugriffsverweigerung.
> Oben rechts führt die Schaltfläche **Redundanz-Dashboard** zur automatischen
> Doppelungs-Erkennung (siehe [Redundanz](/docs/handbook/redundanz)).
## Tab „Nutzer"
Verwaltung der lokalen Konten.
- **Nutzer hinzufügen:** Benutzername (Pflicht), Passwort (mind. 6 Zeichen),
E-Mail (optional), **Rolle** (User/Admin), **Tarif** (Free/Premium/Enterprise).
- **Nutzer bearbeiten:** Rolle, Tarif und (für lokale Konten) ein neues Passwort
setzen. Für OIDC-Konten wird die Passwortverwaltung im Identitätsanbieter
(z. B. Keycloak) angeboten.
- **Nutzer löschen:** Entfernt das Konto endgültig (nicht für das eigene Konto).
API-Referenz:
[`POST /users`](/docs/reference/endpoints/users#createUser),
[`PATCH /users/{id}`](/docs/reference/endpoints/users#updateUser),
[`DELETE /users/{id}`](/docs/reference/endpoints/users#deleteUser).
## Tab „Tools"
Zentraler Zugriff auf den Tool-Katalog.
- **Suchen** nach Tools.
- Tools einzeln ansehen, bearbeiten oder in den Papierkorb verschieben.
- **Massenaktion:** mehrere Tools auswählen und in den Papierkorb verschieben
(Bestätigungsdialog; soft gelöschte Tools sind aus allen öffentlichen Ansichten
entfernt und können wiederhergestellt oder endgültig gelöscht werden).
## Tab „Audit-Log"
Chronologisches Protokoll aller Anlage-, Änderungs- und Löschvorgänge
(max. 100 Einträge): Aktion, Entität + ID, Zeitstempel, ausführende Person und
geänderte Felder.
API-Referenz: [`GET /audit-logs`](/docs/reference/endpoints/audit#listAuditLogs).
## Tab „System"
Versionsinformationen der laufenden Instanz:
- **Version** (z. B. `v0.8.1`),
- **Commit** (7-stelliger SHA, verlinkt zum Repository),
- **Build-Datum**,
- **Papierkorb-Aufbewahrung** („N Tage" oder „Für immer").
## Tool-Verknüpfungen (Admin)
Auf der Detailseite eines Tools kannst du als Admin **Verknüpfungen**
(eigene/„manual" sowie automatisch erkannte) verwalten:
- **Tool verknüpfen:** Dialog mit Tool-ID, **Beziehungstyp**
(Ähnlich / Ersetzt / Abgelöst durch) und optionalen Notizen.
- Beziehungstypen werden als Badges auf der Detailseite angezeigt.
- Manuelle Verknüpfungen lassen sich per Papierkorb-Icon wieder entfernen.
+33
View File
@@ -0,0 +1,33 @@
---
title: Analytics
order: 10
---
# Analytics
Der Bereich **Analytics** (`/analytics`) ist ein öffentliches Dashboard mit
Kennzahlen und Diagrammen auf Basis aller Tools und Bewertungen.
## Kennzahlen (KPI-Karten)
- **Anzahl Tools** — wie viele Tools sind im Katalog erfasst.
- **Anzahl Bewertungen** — wie viele Bewertungen wurden insgesamt abgegeben.
- **Aktive Kategorien** — wie viele Kategorien existieren.
- **Durchschnittliche Bewertung** — globaler kombinierter Wert.
## Diagramme
| Diagramm | Inhalt |
| --- | --- |
| **Top 8 Tools** | Balkendiagramm der Tools mit der höchsten kombinierten Punktzahl (05) |
| **Tools je Kategorie** | Radar-Diagramm der Tool-Anzahl pro Kategorie |
| **Punkteverteilung** | Zwei horizontale Balken-Diagramme (Nützlichkeit & Bedienbarkeit) pro Stern |
Die Diagramme sind interaktiv (Tooltips beim Überfahren).
## API
- [`GET /analytics/summary`](/docs/reference/endpoints/analytics#getAnalyticsSummary)
- [`GET /analytics/top-tools`](/docs/reference/endpoints/analytics#getTopTools)
- [`GET /analytics/by-category`](/docs/reference/endpoints/analytics#getAnalyticsByCategory)
- [`GET /analytics/rating-distribution`](/docs/reference/endpoints/analytics#getRatingDistribution)
+43
View File
@@ -0,0 +1,43 @@
---
title: Bewerten
order: 7
---
# Bewerten
Auf der Detailseite eines Tools kannst du deine Erfahrung teilen. Klicke auf
**Bewertung abgeben** (erfordert ein Konto).
## Formularfelder
| Feld | Pflicht | Hinweise |
| --- | --- | --- |
| **Nützlichkeit** | Ja | 15 Sterne |
| **Bedienbarkeit** | Ja | 15 Sterne |
| **Kommentar** | Nein | Freitext |
| **Name** | Nein | Standard „Anonym" |
Neben den Feldern führt das **?Icon** direkt zur zugehörigen Feldbeschreibung
in der [Datenmodell-Referenz](/docs/reference/schemas/ratinginput).
## Was passiert nach dem Abgeben?
- Deine Bewertung wird sofort gespeichert und erscheint in der
**Bewertungsliste** der Detailseite.
- Die **Durchschnittswerte** (Nützlichkeit, Bedienbarkeit, Kombiniert) und die
**Punkteverteilung** werden aktualisiert.
- Die **Statistiken** im Bereich [Analytics](/docs/handbook/analytics) werden
neu berechnet.
## Statistik-Bereiche auf der Detailseite
- **Bewertungsübersicht:** Nützlichkeit & Bedienbarkeit als Durchschnitt mit
Fortschrittsbalken.
- **Punkteverteilung:** Anzahl der Bewertungen pro Stern (1★–5★).
- **Verlauf:** Linienchart der kombinierten/Teilwerte über die Zeit
(erst ab mehreren Bewertungen sichtbar).
## API
- [`POST /tools/{id}/ratings`](/docs/reference/endpoints/ratings#createRating) — Bewertung abgeben
- [`GET /tools/{id}/ratings`](/docs/reference/endpoints/ratings#listToolRatings) — Bewertungen eines Tools
+74
View File
@@ -0,0 +1,74 @@
---
title: Datenmodell
order: 17
---
# Datenmodell
Dieses Kapitel erklärt die zentralen Datenobjekte von toolr auf Ebene der
Anwendung. Die vollständige, automatisch generierte Referenz aller Felder,
Typen und Constraints findest du in der
[API-Referenz](/docs/reference/schemas/tool).
## Tool
Das Herzstück: ein im Katalog erfasstes Werkzeug.
| Eigenschaft | Beschreibung |
| --- | --- |
| `id` | Eindeutige Kennung |
| `name` | Anzeigename |
| `description` | Beschreibung (Was macht das Tool?) |
| `category` | Kategorie-Zuordnung |
| `websiteUrl` | Offizielle Website (optional) |
| `iconUrl` | Logo-/Icon-URL (optional) |
| `features` | Liste von Fähigkeiten |
| `tags` | Liste von Schlagwörtern |
| `createdAt` / `updatedAt` | Zeitstempel |
| `createdBy` | Erstellende Person |
| `deletedAt` / `deletedBy` | Soft-Löschung (Papierkorb) |
Eingabe-Formulare verwenden die abgeleiteten Schemas
[`ToolInput`](/docs/reference/schemas/toolinput) und
[`ToolUpdate`](/docs/reference/schemas/toolupdate).
Aggregierte Ansichten liefert [`ToolWithStats`](/docs/reference/schemas/toolwithstats)
(z. B. mit Durchschnittsbewertung).
## Rating (Bewertung)
Eine einzelne Bewertung zu einem Tool:
- `usefulness` (Nützlichkeit, 15) und `usability` (Bedienbarkeit, 15)
- optional `comment` und ein Anzeigename (`reviewerName`)
- Zeitstempel
Eingabe-Schema: [`RatingInput`](/docs/reference/schemas/ratinginput).
## User & Auth
- [`User`](/docs/reference/schemas/user) — Benutzerkonto mit Rolle (User/Admin)
und Tarif (Free/Premium/Enterprise).
- [`AuthUser`](/docs/reference/schemas/authuser) — das angemeldete Profil
inklusive `entitlements` (verfügbare Features).
- [`UserPreferences`](/docs/reference/schemas/userpreferences) — Ansichts- und
Dichte-Präferenzen sowie die `watchlist` (Liste von Tool-IDs).
## Analytics
Die Statistik-Endpunkte liefern aggregierte Daten:
- [`AnalyticsSummary`](/docs/reference/schemas/analyticssummary) — globale
Kennzahlen (Anzahl Tools/Bewertungen, Kategorien, Durchschnitt).
- [`TopToolEntry`](/docs/reference/schemas/TopToolEntry) — ein Eintrag der
Top-Tools.- [`CategoryStats`](/docs/reference/schemas/categorystats) — Tool-Anzahl je
Kategorie.
- [`RatingDistribution`](/docs/reference/schemas/ratingdistribution) —
Punkteverteilung (Nützlichkeit & Bedienbarkeit).
- [`ScoreBucket`](/docs/reference/schemas/scorebucket) — ein Werte-Bucket.
## Weitere
- [`VersionInfo`](/docs/reference/schemas/versioninfo) — Version, Commit-SHA,
Build-Datum und Papierkorb-Aufbewahrung der laufenden Instanz.
- [`AuditLog`](/docs/reference/schemas/auditlog) — ein Protokolleintrag
(Aktion, Entität, Zeitstempel, Akteur, Änderungen).
+57
View File
@@ -0,0 +1,57 @@
---
title: Erste Schritte
order: 2
---
# Erste Schritte
Diese Seite führt dich durch die wichtigsten Abläufe in toolr — vom ersten
Besuch bis zum Anlegen und Bewerten eines Tools.
## 1. Anmelden
Die meisten Aktionen (Tool anlegen, bewerten, Watchlist, Vergleichen) erfordern
ein Konto. Klicke unten links auf **Anmelden**. Je nach Konfiguration der
Instanz hast du zwei Möglichkeiten:
- **Lokale Konten:** Benutzername + Passwort. Der Zugang wird von einem Admin
angelegt (siehe [Administration](/docs/handbook/administration)).
- **OIDC (SSO):** Anmelden mit dem konfigurierten Identitätsanbieter (z. B.
Keycloak).
Welcher Modus aktiv ist, steht im Endpunkt
[`GET /auth/mode`](/docs/reference/endpoints/auth#getAuthMode). Details findest
du im Abschnitt [Anmelden & Konto](/docs/handbook/konto).
## 2. Tools finden
Öffne den Bereich **Tools durchsuchen**:
- **Suchen** — Volltextsuche über Name & Beschreibung (Tastenkürzel `/`).
- **Filtern** — nach Kategorie, Tags, Features und Mindestbewertung
(`minRating`).
- **Sortieren** — nach Aktualität, Top-Bewertung, meistbewertet, Name
(auf-/absteigend) oder letztem Update.
Alle Optionen im Detail: [Tools finden & durchsuchen](/docs/handbook/tools-finden).
## 3. Tool anlegen
Gehe auf **Tool hinzufügen** und fülle das Formular aus. Details zu jedem Feld
findest du im Abschnitt [Tool anlegen](/docs/handbook/tool-anlegen) und in der
[Feld-Referenz](/docs/reference/schemas/toolinput).
## 4. Bewerten
Auf der Detailseite eines Tools kannst du **Nützlichkeit** und **Bedienbarkeit**
(jeweils 15) vergeben und optional einen Kommentar hinterlassen. Deine
Bewertung fließt sofort in die Statistiken ein.
Siehe [Bewerten](/docs/handbook/bewerten).
## 5. Weiterführend
- [Tools vergleichen](/docs/handbook/vergleichen)
- [Watchlist](/docs/handbook/watchlist)
- [Analytics](/docs/handbook/analytics)
- [Pläne & Berechtigungen](/docs/handbook/plaene)
- [Administration](/docs/handbook/administration)
+56
View File
@@ -0,0 +1,56 @@
---
title: Überblick
order: 1
---
# Willkommen bei toolr
toolr ist eine Plattform zum **Entdecken, Bewerten und Vergleichen von
Entwicklungstools**. Nutzer:innen pflegen einen gemeinsamen Katalog von Tools,
vergeben Bewertungen (Nützlichkeit & Bedienbarkeit) und nutzen Statistiken, um
die richtige Wahl zu treffen.
## Was kannst du mit toolr tun?
| Funktion | Beschreibung | Sichtbarkeit |
| --- | --- | --- |
| **Tools durchsuchen** | Katalog filtern, sortieren und durchsuchen | Alle |
| **Tool anlegen** | Neues Tool mit Beschreibung, Kategorie, Features & Tags eintragen | Angemeldet |
| **Tool bearbeiten/löschen** | Eigene Tools pflegen (Ersteller:in oder Admin) | Angemeldet |
| **Bewerten** | Nützlichkeit & Bedienbarkeit (15) plus Kommentar vergeben | Angemeldet |
| **Watchlist** | Tools als Favoriten speichern | Premium |
| **Vergleichen** | Tools nebeneinander gegenüberstellen | Premium |
| **Kosten erfassen** | Lizenz- und Kostenmodelle je Tool eintragen | Premium |
| **Analytics** | Statistiken, Top-Tools, Verteilungen | Alle |
| **Papierkorb** | Soft-gelöschte Tools wiederherstellen oder endgültig löschen | Premium |
| **Admin** | Nutzerverwaltung, Audit-Log, Systeminformationen | Admin |
| **Redundanz** | Automatische Doppelungs-Erkennung | Admin |
## Wie diese Doku aufgebaut ist
- **User Guide** (diese Seiten): Schritt-für-Schritt-Anleitungen für alle
Funktionen — von den [Ersten Schritten](/docs/handbook/getting-started) bis
zur [Administration](/docs/handbook/administration).
- **API-Referenz**: automatisch aus der OpenAPI-Spezifikation generiert — alle
[Endpunkte](/docs/reference/endpoints/tools) und
[Datenfelder](/docs/reference/schemas/toolinput) der aktuellen Version.
- **Release-Notes**: Was ist in welcher [Version](/docs/releases/v0.8.1) neu.
## Der Einstieg
Der schnellste Weg:
1. **Anmelden** — ohne Konto kannst du nur stöbern
(siehe [Erste Schritte](/docs/handbook/getting-started#1-anmelden)).
2. **Tools finden** — Suche, Filter und Sortierung im Bereich
[Tools durchsuchen](/docs/handbook/tools-finden).
3. **Tool anlegen** — über „Tool hinzufügen"
([Anleitung](/docs/handbook/tool-anlegen)).
4. **Bewerten** — auf der Detailseite eines Tools
([Anleitung](/docs/handbook/bewerten)).
## Kontakt & Quellcode
Der Quellcode liegt unter
[git.kubebase.de/admin/tool-evaluator](https://git.kubebase.de/admin/tool-evaluator) —
über das Repository-Icon oben rechts erreichst du ihn jederzeit.
+57
View File
@@ -0,0 +1,57 @@
---
title: Anmelden & Konto
order: 3
---
# Anmelden & Konto
## Anmelden
Klicke unten links in der Seitenleiste auf **Anmelden**. Je nach Konfiguration
der Instanz:
- **Lokale Konten:** Benutzername und Passwort eingeben. Die Konten werden von
einem Admin angelegt (siehe [Administration](/docs/handbook/administration)).
- **OIDC (SSO):** Du wirst an den konfigurierten Identitätsanbieter
weitergeleitet und meldest dich dort an.
Der aktive Modus steht im Endpunkt
[`GET /auth/mode`](/docs/reference/endpoints/auth#getAuthMode).
> Die Login-Seite erreichst du direkt unter `/login`. Nach erfolgreicher
> Anmeldung wirst du zur ursprünglich aufgerufenen Seite zurückgeleitet.
## Benutzerprofil
Dein Profil (Avatar, Name, E-Mail, Tarif) siehst du unten links im
Benutzermenü. Dort stehen dir folgende Aktionen zur Verfügung:
- **Watchlist** — deine gespeicherten Tools (nur mit dem entsprechenden Tarif).
- **Papierkorb** — wiederherstellbare, gelöschte Tools (Premium/Enterprise).
- **Passwort ändern** — für lokale Konten direkt in toolr; für OIDC-Konten wird
die Passwortverwaltung im Identitätsanbieter angeboten.
- **Abmelden** — beendet deine Sitzung.
## Passwort ändern (lokales Konto)
1. Öffne das Benutzermenü unten links.
2. Wähle **Passwort ändern**.
3. Gib das **aktuelle** sowie ein **neues** Passwort ein (mind. 6 Zeichen) und
bestätige es.
4. Speichern — das Passwort wird sofort übernommen.
API-Referenz: [`POST /auth/me/password`](/docs/reference/endpoints/auth#changeMyPassword).
## Anzeigeeinstellungen
Über die Schaltflächen oben rechts kannst du:
- **Sprache** wechseln (Deutsch / Englisch),
- **Theme** umschalten (Hell / Dunkel / System),
- die **Listenansicht** und **Dichte** im Bereich Tools durchsuchen anpassen
(siehe [Tools finden & durchsuchen](/docs/handbook/tools-finden)).
Deine Präferenzen (inkl. Watchlist) werden im Endpunkt
[`GET /auth/me/preferences`](/docs/reference/endpoints/auth#getMePreferences)
gespeichert und über [`PUT /auth/me/preferences`](/docs/reference/endpoints/auth#updateMePreferences)
aktualisiert.
+39
View File
@@ -0,0 +1,39 @@
---
title: Kosten erfassen
order: 12
---
# Kosten erfassen
Auf der Detailseite eines Tools kannst du Kosten- und Lizenzmodelle eintragen,
damit die Gesamtkosten je Tool transparent werden.
> Kosten ist ein **Premium-Feature** (`costs`, Premium/Enterprise). Admins
> haben immer Zugriff.
## Kosten hinzufügen
Klicke auf **Kosten hinzufügen** im Kosten-Bereich der Detailseite und fülle
das Formular aus:
| Feld | Hinweise |
| --- | --- |
| **Lizenztyp** | Free / Subscription / One-Time / Usage-Based |
| **Abrechnungszeitraum** | Nur für „Subscription": Monatlich / Quartalsweise / Jährlich |
| **Kosten** | Betrag als Zahl |
| **Währung** | EUR / USD / GBP / CHF |
| **Notizen** | Optionaler Freitext |
Speichern legt den Eintrag an. Jeder Kosten-Eintrag wird als Karte mit
Lizenz-Badge, Abrechnungszeitraum, Betrag (`Betrag Währung` bzw. „Free") und
Notizen angezeigt.
## Kosten bearbeiten & löschen
Beim Überfahren einer Kosten-Karte erscheinen die Aktionen **Bearbeiten**
(Bleistift) und **Löschen** (Papierkorb).
## API
Die Kosten-Daten werden über die Tool-Endpunkte verwaltet
(siehe [API-Referenz](/docs/reference/endpoints/tools)).
+45
View File
@@ -0,0 +1,45 @@
---
title: Papierkorb
order: 15
---
# Papierkorb
Der **Papierkorb** (`/trash`) enthält soft gelöschte Tools. Mit Papierkorb-Zugang
können sie wiederhergestellt werden; endgültiges Löschen ist Admins vorbehalten.
> Der Papierkorb ist ein **Premium-Feature** (`trash`, Premium/Enterprise).
> Admins haben immer Zugriff.
## Zugang
Der Papierkorb ist über das Benutzermenü oder die Seitenleiste erreichbar.
Ohne `trash`-Berechtigung erscheint ein Hinweis auf den Tarifwechsel.
## Wiederherstellen
- Markiere ein oder mehrere Tools (Checkboxen).
- Klicke auf **Wiederherstellen (N)** — die Tools erscheinen wieder in allen
öffentlichen Ansichten.
> Wiederherstellen steht jeder Person mit Papierkorb-Zugang zur Verfügung.
## Endgültig löschen (nur Admin)
- **Löschen (N)** entfernt die ausgewählten Tools **endgültig** — inklusive
aller Bewertungen, Kosten und Verknüpfungen. Das kann nicht rückgängig
gemacht werden.
- **Papierkorb leeren** entfernt alle soft gelöschten Tools endgültig.
## Tabelle
Der Papierkorb listet: Name, Kategorie, **Gelöscht am** (`tt.MM.jjjj HH:mm`),
**Gelöscht von** sowie Aktionen (Wiederherstellen; Löschen nur Admin). Die Suche
filtert nach Namen.
## API
- [`GET /tools/trash`](/docs/reference/endpoints/tools#listTrashedTools) — Liste
- [`POST /tools/trash/restore`](/docs/reference/endpoints/tools#restoreTools) — Wiederherstellen
- [`DELETE /tools/trash`](/docs/reference/endpoints/tools#deleteTrashedTools) — Endgültig löschen (Admin)
- [`POST /tools/trash/empty`](/docs/reference/endpoints/tools#emptyTrash) — Papierkorb leeren (Admin)
+45
View File
@@ -0,0 +1,45 @@
---
title: Pläne & Berechtigungen
order: 11
---
# Pläne & Berechtigungen
toolr unterscheidet **Tarife** (Tier) und **Rollen**. Admins umgehen alle
Feature-Beschränkungen.
## Tarife
| Tarif | Beschreibung |
| --- | --- |
| **Free** | Grundfunktionen: suchen, filtern, ansehen, Analytics |
| **Premium** | Zusätzlich Watchlist, Vergleichen, Papierkorb, Kosten |
| **Enterprise** | Alle Premium-Features + erweiterter Support |
### Feature-Berechtigungen
Premium/Enterprise schalten folgende Features frei:
| Feature | Funktion | Mehr erfahren |
| --- | --- | --- |
| `compare` | Tools vergleichen | [Vergleichen](/docs/handbook/vergleichen) |
| `watchlist` | Favoritenliste | [Watchlist](/docs/handbook/watchlist) |
| `trash` | Papierkorb (soft gelöschte Tools) | [Papierkorb](/docs/handbook/papierkorb) |
| `costs` | Kosten-/Lizenzmodelle erfassen | [Kosten erfassen](/docs/handbook/kosten) |
Fehlt dir ein Feature, zeigt die App einen **Upgrade-Hinweis** mit Link zur
Tarifverwaltung.
## Rollen
| Rolle | Berechtigungen |
| --- | --- |
| **User** | Standard-Konto: Tools anlegen/bewerten, eigene Tools bearbeiten |
| **Admin** | Alle User-Rechte + Verwaltung, Audit-Log, Redundanz, Papierkorb leeren, Tool-Verknüpfungen |
Admins passieren **alle** Feature-Checks — auch ohne Premium-Tarif.
## Tarif-/Rollenverwaltung
Die Zuordnung von Rolle und Tarif wird durch Admins im Bereich
[Administration](/docs/handbook/administration) (Tab „Nutzer") verwaltet.
+42
View File
@@ -0,0 +1,42 @@
---
title: Redundanz-Dashboard
order: 14
---
# Redundanz-Dashboard
Das **Redundanz-Dashboard** (`/admin/redundancy`) ist ein Admin-Werkzeug zur
automatischen Erkennung doppelter oder stark überlappender Tools — jeweils
pro Kategorie — inklusive Kosten- und Bewertungsvergleich.
> Der Zugriff ist ausschließlich Admins vorbehalten (die API ist
> admin-geschützt).
## Aufbau
- **Pro Kategorie** wird eine Gruppe angezeigt: Name der Kategorie,
Anzahl Tools und Vergleiche sowie ggf. die **gesamten monatlichen Kosten**
(z. B. `€X.XX/mo gesamt`).
- Jedes Tool wird als Karte dargestellt: Name, monatliche Kosten, Anzahl der
Bewertungen, kombinierte Bewertung, Lizenz-Badges und Feature-Anzahl.
## Vergleiche & Empfehlungen
Für jedes Tool-Paar erscheint:
- Tool A vs. Tool B, jeweils mit Bewertung (`X.X ★`) und monatlichen Kosten.
- **Überlappung** in Prozent (Fortschrittsbalken in der Mitte).
- Eine **Empfehlung** mit Konfidenz-Farbe:
- **hoch** (grün), **mittel** (gelb), **niedrig** (grau)
- Das empfohlene, bessere Tool wird mit „Daumen hoch" markiert und begründet.
## Manuelle Bewertung
Du kannst ein Paar manuell bewerten: Klicke auf Tool A oder Tool B, um
festzuhalten, welches besser ist. Die Auswahl wird gespeichert und die
Darstellung aktualisiert.
## API
- [`GET /api/admin/redundancy`](#) — Daten laden (admin-geschützt)
- [`POST /api/admin/redundancy/evaluate`](#) — manuelle Bewertung speichern
+40
View File
@@ -0,0 +1,40 @@
---
title: Tastenkürzel & Kommandopalette
order: 16
---
# Tastenkürzel & Kommandopalette
## Kommandopalette
Die Kommandopalette ist die zentrale Schnellnavigation:
- Öffnen mit **`⌘K`** (macOS) bzw. **`Ctrl+K`** (Windows/Linux).
- Alternativ über die Suchleiste oben rechts („Tools suchen… ⌘K") oder das
Such-Icon auf Mobilgeräten.
### Leerer Zustand
Ohne Eingabe zeigt die Palette:
- **Zuletzt angesehen** — die letzten 5 Tools, die du besucht hast.
- **Navigation** — Tools durchsuchen, Tool hinzufügen, Analytics sowie
(abhängig von Berechtigungen) Watchlist, Papierkorb und Admin.
### Suche
Tippe, um live nach Tools zu suchen (max. 10 Ergebnisse, inkl. Bewertung
`X.X★`).
## Tastenkürzel im Überblick
| Kürzel | Aktion |
| --- | --- |
| `⌘K` / `Ctrl+K` | Kommandopalette öffnen |
| `/` | Suche im Bereich „Tools durchsuchen" fokussieren |
## Weitere Hinweise
- **Zuletzt angesehen** wird lokal im Browser gespeichert (max. 5 Einträge).
- Die Seitenleiste (linke Navigation) ist auf Desktop einklappbar; der
Breadcrumb oben zeigt deinen aktuellen Ort.
+50
View File
@@ -0,0 +1,50 @@
---
title: Tool anlegen
order: 5
---
# Tool anlegen
Um ein neues Tool zum Katalog hinzuzufügen, klicke auf **Tool hinzufügen**
(`/tools/new`). Das Anlegen erfordert ein Konto — ohne Anmeldung erscheint ein
Hinweis mit Login-Button.
## Formularfelder
| Feld | Pflicht | Hinweise |
| --- | --- | --- |
| **Name** | Ja | Mind. 2 Zeichen |
| **Kategorie** | Ja | Auswahlliste; neue Kategorien lassen sich direkt anlegen |
| **Website URL** | Nein | Gültige URL (z. B. `https://...`) |
| **Icon / Logo URL** | Nein | Gültige URL; Vorschau wird live angezeigt |
| **Beschreibung** | Ja | Mind. 10 Zeichen; beschreibe, was das Tool tut |
| **Features** | Nein | Dynamische Liste mit Autovervollständigung (max. 6) |
| **Tags** | Nein | Dynamische Liste mit Autovervollständigung |
Neben jedem Feld führt das **?Icon** direkt zur zugehörigen Feldbeschreibung
in der [Datenmodell-Referenz](/docs/reference/schemas/toolinput).
### Kategorie
- Tippe, um nach bestehenden Kategorien zu suchen.
- Wähle **+ Erstelle „..."**, um eine neue Kategorie anzulegen.
### Features & Tags
- **Feature hinzufügen** / **Tag hinzufügen** hängt eine neue Zeile an.
- Die Eingabefelder schlagen bestehende Features/Tags vor
(Autovervollständigung, max. 6 Vorschläge).
- Mit dem **×**Button entfernst du einzelne Zeilen.
- Features und Tags helfen beim Filtern und Wiederfinden.
## Speichern
Klicke auf **Tool hinzufügen**. Nach erfolgreicher Anlage wirst du auf die
Detailseite des neuen Tools weitergeleitet.
## API
- [`POST /tools`](/docs/reference/endpoints/tools#createTool) — Tool anlegen
- [`GET /categories`](/docs/reference/endpoints/tools#listCategories) — Kategorien
- [`GET /features/all`](/docs/reference/endpoints/tools#listAllFeatures) — Features
- [`GET /tags/all`](/docs/reference/endpoints/tools#listAllTags) — Tags
+37
View File
@@ -0,0 +1,37 @@
---
title: Tool bearbeiten & löschen
order: 6
---
# Tool bearbeiten & löschen
## Bearbeiten
Auf der Detailseite eines Tools findest du die Schaltfläche **Bearbeiten**
(nur für die Person, die das Tool angelegt hat, sowie für Admins).
Die Bearbeitungsseite (`/tools/:id/edit`) enthält dieselben Felder wie beim
Anlegen (Name, Kategorie, Website/Icon-URL, Beschreibung, Features, Tags) —
bereits mit den aktuellen Werten befüllt.
- **Speichern** übernimmt die Änderungen.
- **Abbrechen** führt zurück zur Detailseite.
API-Referenz: [`PATCH /tools/{id}`](/docs/reference/endpoints/tools#updateTool).
## Löschen
Über **Löschen** auf der Detailseite wird das Tool entfernt. Das Verhalten
hängt von deinem Tarif ab:
- **Mit Papierkorb-Zugang** (Premium/Enterprise oder Admin): Das Tool wird
**soft gelöscht** — es verschwindet aus allen öffentlichen Ansichten, kann
aber im [Papierkorb](/docs/handbook/papierkorb) wiederhergestellt oder
endgültig gelöscht werden.
- **Ohne Papierkorb-Zugang:** Das Tool wird **endgültig** gelöscht und kann
nicht wiederhergestellt werden.
Die Löschung ist nur für die Person, die das Tool angelegt hat, sowie für
Admins möglich.
API-Referenz: [`DELETE /tools/{id}`](/docs/reference/endpoints/tools#deleteTool).
+71
View File
@@ -0,0 +1,71 @@
---
title: Tools finden & durchsuchen
order: 4
---
# Tools finden & durchsuchen
Der Bereich **Tools durchsuchen** (`/tools`) ist der Einstieg in den Katalog.
Hier kombinierst du Suche, Filter und Sortierung, um genau die Tools zu finden,
die dich interessieren.
## Suche
- Die **Suchleiste** durchsucht Name und Beschreibung (Volltext).
- Tastenkürzel: Drücke **`/`**, um die Suche zu fokussieren.
- Die Eingabe ist deaktiviert (Debounce), damit bei jedem Tastendruck sofort
nachgefiltert wird.
## Filtern
Über die Schaltfläche **Filter** (mit Badge für die Anzahl aktiver Filter)
öffnest du den Filter-Popover mit:
- **Tags** — Auswahl über Checkboxen (scrollbare Liste).
- **Features** — Auswahl über Checkboxen.
- **Mindestbewertung** — Schieberegler von 0 bis 5 (Schritte von 0,5); zeigt
z. B. „3.0+" an.
Aktive Filter erscheinen als **entfernbare Chips** über der Ergebnisliste.
Mit **Filter zurücksetzen** bzw. **Alle entfernen** räumst du sie wieder auf.
## Sortieren
Über das Dropdown **Sortieren** stehen folgende Optionen zur Verfügung:
| Sortierung | Beschreibung |
| --- | --- |
| Neueste | Neue Tools zuerst |
| Top bewertet | Nach kombinierter Bewertung |
| Meistbewertet | Nach Anzahl der Bewertungen |
| Name (AZ) | Alphabetisch aufsteigend |
| Name (ZA) | Alphabetisch absteigend |
| Zuletzt aktualisiert | Nach letztem Update |
## Ansicht & Dichte
- **Ansicht wechseln:** Raster / Tabelle / Zeilen.
- **Dichte:** gemütlich / kompakt (Schieberegler).
Deine Auswahl wird gespeichert — lokal im Browser und für angemeldete Nutzer:innen
zusätzlich serverseitig in den Präferenzen. Ansicht, Dichte, Suche, Filter und
Sortierung werden dabei in die URL übernommen, sodass du Ergebnisse teilen
kannst.
## Tabellenansicht
In der Tabellenansicht sind die Spalten **Tool**, **Bewertung** und **Anzahl
Bewertungen** sortierbar. Beim Überfahren einer Zeile erscheint eine Vorschau
mit Bewertungsdetails, Tags und Mini-Balken.
## Auswählen für Vergleich & Watchlist
- Auf jeder Karte/Zeile findest du ein **Vergleichs-Icon**, mit dem du Tools zur
[Vergleichsleiste](/docs/handbook/vergleichen) hinzufügst.
- Das **Lesezeichen-Icon** speichert Tools in deiner
[Watchlist](/docs/handbook/watchlist) (nur mit dem entsprechenden Tarif).
## API
Alle Such-, Filter- und Sortierparameter entsprechen den Query-Parametern von
[`GET /tools`](/docs/reference/endpoints/tools#listTools).
+46
View File
@@ -0,0 +1,46 @@
---
title: Vergleichen
order: 9
---
# Vergleichen
Mit der Vergleichsfunktion stellst du mehrere Tools **nebeneinander** gegenüber —
ideal, um eine fundierte Entscheidung zu treffen.
> Vergleichen ist ein **Premium-Feature** (Premium/Enterprise) und steht Admins
> immer zur Verfügung.
## Tools auswählen
1. Im Bereich **Tools durchsuchen** klickst du auf jeder Karte/Zeile auf das
**Vergleichs-Icon** (Waage).
2. Unten erscheint die **Vergleichsleiste** mit den ausgewählten Tools als
Chips. Du kannst einzelne Tools entfernen (×) oder die Auswahl leeren.
3. Klicke auf **Vergleichen (N)**, um zur Vergleichsansicht zu gelangen.
> Ohne Premium-Tarif ist der Button gesperrt (Schloss-Icon). Über den
> Dialog gelangst du zum Tarifwechsel
> (siehe [Pläne & Berechtigungen](/docs/handbook/plaene)).
## Die Vergleichsansicht
Die Ansicht zeigt eine Tabelle mit einer Spalte pro Tool. Zeilen:
| Zeile | Inhalt |
| --- | --- |
| **Bewertung** | Sterne + Wert (z. B. `4.2/5`) |
| **Nützlichkeit** | Wert (X.X/5) |
| **Bedienbarkeit** | Wert (X.X/5) |
| **Anzahl Bewertungen** | Anzahl |
| **Beschreibung** | Text |
| **Features** | Badges |
| **Tags** | Badges |
| **Zuletzt aktualisiert** | Datum |
Der **beste Wert** pro Zeile wird hervorgehoben (mit Trophäen-Icon).
## API
Die Vergleichsansicht liest die Daten über
[`GET /compare`](/docs/reference/endpoints/tools#listCompareTools).
+38
View File
@@ -0,0 +1,38 @@
---
title: Watchlist
order: 8
---
# Watchlist
Die **Watchlist** ist eine persönliche Favoritenliste. Tools darin kannst du
jederzeit per Klick wieder aufrufen und vergleichen.
> Die Watchlist ist ein **Premium-Feature** (Premium/Enterprise) und steht
> Admins immer zur Verfügung.
## Voraussetzung
Du benötigst einen Tarif mit `watchlist`-Berechtigung. Fehlt diese, erscheint
beim Lesezeichen ein Hinweis auf den Tarifwechsel
(siehe [Pläne & Berechtigungen](/docs/handbook/plaene)).
## Tool speichern
- Auf jeder Karte/Zeile im Bereich **Tools durchsuchen** findest du das
**Lesezeichen-Icon**.
- Ein Klick speichert das Tool in deiner Watchlist — das Icon wird gefüllt.
- Ein erneuter Klick entfernt es wieder.
## Watchlist ansehen
Öffne die Watchlist über das Benutzermenü oder die Seitenleiste. Sie zeigt alle
gespeicherten Tools als Karten. Das gefüllte Lesezeichen auf einer Karte
entfernt das Tool aus der Liste.
## Wo wird die Watchlist gespeichert?
Die Watchlist ist eine Liste von Tool-IDs in deinen **Benutzerpräferenzen**.
Damit ist sie geräteübergreifend mit deinem Konto verbunden.
API-Referenz: [`GET /auth/me/watchlist`](/docs/reference/endpoints/auth#getMeWatchlist).
+35
View File
@@ -0,0 +1,35 @@
# vX.Y.Z — Release Notes
> Template für neue Release-Dokumentationen. Eine Kopie pro Release unter
> `docs/releases/vX.Y.Z.md` anlegen, Platzhalter ersetzen, Abschnitte die
> nicht zutreffen entfernen. Die Seite wird unter `/docs/vX.Y.Z` in der App
> angezeigt.
**Datum:** YYYY-MM-DD · **Tag:** [`vX.Y.Z`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/vX.Y.Z)
## Neue Features
- ...
## Fixes & Verbesserungen
- ...
## API-Änderungen
- ... (neue/geänderte/entfernte Endpunkte — siehe `lib/api-spec/openapi.yaml`)
## Betrieb / Upgrade
- **Env-Vars:** ... (neu/geändert/entfernt)
- **Migration:** ... (Datenbank-/Schema-Änderungen, Schritte für den Betreiber)
- **Breaking Changes:** ... (nur wenn vorhanden)
## Bekannte Einschränkungen
- ...
## Links
- Commit: [`<short-sha>`](https://git.kubebase.de/admin/tool-evaluator/commit/<short-sha>)
- Tag: [`vX.Y.Z`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/vX.Y.Z)
+37
View File
@@ -0,0 +1,37 @@
# v0.6.0 — Release Notes
**Datum:** 2026-08-03 · **Tag:** [`v0.6.0`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.6.0)
## Neue Features
- Vollständige Modernisierung aller Abhängigkeiten auf die aktuellen Hauptversionen
(TypeScript 7, Vite 8, React 19.2, openid-client 6, zod 4, recharts 3, react-day-picker 10).
## Fixes & Verbesserungen
- CI-Build durch `allowBuilds`-Konfiguration für pnpm 11 repariert
(Build-Scripts für esbuild & Co. werden nicht mehr blockiert).
- Image-Tagging vereinfacht: nur noch `latest` und `v*`-Tags, keine `nightly-*`/`sha-*`-Tags.
- Alle Dependencies exakt gepinnt; automatische Updates via Renovate vorbereitet
(`renovate.json`, `docs/dependency-policy.md`).
## API-Änderungen
- Keine Breaking Changes an der API. openid-client intern auf v6 migriert
(auth-Fluss verhält sich identisch).
## Betrieb / Upgrade
- **Env-Vars:** unverändert. Node-Image auf `node:24.18.1-alpine` gepinnt.
- **Migration:** keine Datenbank-Migration erforderlich.
- **Breaking Changes:** keine.
## Bekannte Einschränkungen
- `typedoc` (indirekte orval-Abhängigkeit) zeigt eine Peer-Dependency-Warnung
(erwartet TypeScript 5.x/6.x, installiert ist 7.x) — harmlos für Build & Laufzeit.
## Links
- Commit: [`2f66fff`](https://git.kubebase.de/admin/tool-evaluator/commit/2f66fff)
- Tag: [`v0.6.0`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.6.0)
+35
View File
@@ -0,0 +1,35 @@
# v0.7.0 — Release Notes
**Datum:** 2026-08-03 · **Tag:** [`v0.7.0`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.7.0)
## Neue Features
- Version-gebundene **Release-Dokumentation** in der App unter `/docs`
(Index + Detailseite je Version, Markdown aus `docs/releases/`).
- Generiertes Release-Vorlage (`docs/releases/TEMPLATE.md`) und
Sync-Schritt für den Frontend-Build.
## Fixes & Verbesserungen
- `tsx` auf 4.23.4 angehoben — letzte veraltete Abhängigkeit im Workspace
(`pnpm outdated -r` ist jetzt leer).
## API-Änderungen
- Keine Breaking Changes an der API.
## Betrieb / Upgrade
- **Env-Vars:** unverändert.
- **Migration:** keine.
- **Breaking Changes:** keine.
## Bekannte Einschränkungen
- Die Doku ist bisher auf Release-Notes beschränkt; eine vollständige
API-/Feld-Referenz folgt in v0.8.0.
## Links
- Commit: [`520f917`](https://git.kubebase.de/admin/tool-evaluator/commit/520f917)
- Tag: [`v0.7.0`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.7.0)
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
# v0.8.0 — Release Notes
**Datum:** 2026-08-03 · **Tag:** [`v0.8.0`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.0)
## Neue Features
- **Vollständige Dokumentations-Site** in mkdocs-Optik unter `/docs`:
- **Handbuch** mit verständlichen Erklärungen zu allen Features
(Erste Schritte, Tool anlegen, Bewertungen, Vergleichen, Watchlist,
Analytics, Administration, Datenmodell).
- **Automatisch generierte Referenz** aus `lib/api-spec/openapi.yaml`:
alle Endpunkte und Datenfelder (Typ, Pflichtstatus, Constraints) —
damit ist garantiert, dass *jedes* Feature dokumentiert ist.
- **Suche** über Handbuch, Endpunkte und Felder.
- **Versions-Dropdown**: ältere Releases behalten ihre vollständige
Feld-/Endpunkt-Referenz als Snapshot.
- **Repo-Link** oben rechts zur Quelle.
- **Hilfe-Buttons (?) in Formularen** (NetBox-Stil): neben jedem Feld
springt ein Icon direkt zur Feldbeschreibung in der Doku.
## Fixes & Verbesserungen
- Doku-Generator `scripts/src/generate-docs.mjs` ersetzt den bisherigen
`sync-release-docs.mjs` (OpenAPI-Parsing, Handbuch, Suchindex, Snapshots).
- Dokumentation für v0.7.0 nachgezogen.
## API-Änderungen
- Keine Breaking Changes an der API.
## Betrieb / Upgrade
- **Env-Vars:** unverändert.
- **Migration:** keine.
- **Breaking Changes:** keine.
## Bekannte Einschränkungen
- Handbuch & Referenz gelten für die aktuelle Version; ältere Versionen
zeigen ihre Release-Notes und einen Referenz-Snapshot, sofern beim Release
erzeugt (`node scripts/src/generate-docs.mjs --snapshot vX.Y.Z`).
## Links
- Commit: [`6c92b63`](https://git.kubebase.de/admin/tool-evaluator/commit/6c92b63)
- Tag: [`v0.8.0`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.0)
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
# v0.8.1 — Release Notes
**Datum:** 2026-08-03 · **Tag:** [`v0.8.1`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.1)
## Neue Features
- **Standalone-Doku-Seite**: Die Dokumentation steht jetzt als eigene,
mkdocs-artige Seite unter `toolr.kubebase.de/docs` — ohne die App-Shell
(eigene Kopfzeile mit Repo-Link, Versions-Dropdown, Suche, Theme-Umschalter
und Link zurück zur App).
- **Navigation umbenannt**: Der Seitenleisten-Eintrag heißt jetzt **„Hilfe"**
und führt zur Standalone-Doku.
- **User Guide komplett überarbeitet**: 17 Handbuch-Seiten mit
Schritt-für-Schritt-Anleitungen für alle Funktionen (Tools finden, Tool
anlegen, Bewerten, Watchlist, Vergleichen, Kosten, Analytics, Pläne,
Administration, Redundanz, Papierkorb, Tastenkürzel, Datenmodell).
## Fixes & Verbesserungen
- Referenz-Links sind jetzt unabhängig von Groß-/Kleinschreibung
(Schema-/Endpoint-Slugs wie `toolinput` und `ToolInput` funktionieren beide).
- Handbuch-Links auf Endpunkt-Anker korrigiert (PascalCase-OperationIds).
- Veraltete, kaputte Handbuch-Links (`vergleichen`, `watchlist` …) ersetzt.
- Dokumentations-Tabellenkopfzeilen und Hinweistexte in der Doku-Seite über
i18n internationalisiert (de/en).
## API-Änderungen
- Keine Änderungen an der API.
## Betrieb / Upgrade
- **Env-Vars:** unverändert.
- **Migration:** keine.
- **Breaking Changes:** keine. Die Doku-Seite ist unter `/docs` erreichbar wie
bisher; lediglich die Darstellung ist nun eigenständig.
## Bekannte Einschränkungen
- Handbuch & Referenz gelten für die aktuelle Version; ältere Versionen zeigen
ihre Release-Notes und einen Referenz-Snapshot, sofern beim Release erzeugt
(`node scripts/src/generate-docs.mjs --snapshot vX.Y.Z`).
## Links
- Commit: [`fdf2d74`](https://git.kubebase.de/admin/tool-evaluator/commit/fdf2d74)
- Tag: [`v0.8.1`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.1)
@@ -0,0 +1,61 @@
# Administration
Der Bereich **Admin** (`/admin`) ist ausschließlich für Admins zugänglich.
Ohne Admin-Rolle erscheint eine Zugriffsverweigerung.
> Oben rechts führt die Schaltfläche **Redundanz-Dashboard** zur automatischen
> Doppelungs-Erkennung (siehe [Redundanz](/docs/handbook/redundanz)).
## Tab „Nutzer"
Verwaltung der lokalen Konten.
- **Nutzer hinzufügen:** Benutzername (Pflicht), Passwort (mind. 6 Zeichen),
E-Mail (optional), **Rolle** (User/Admin), **Tarif** (Free/Premium/Enterprise).
- **Nutzer bearbeiten:** Rolle, Tarif und (für lokale Konten) ein neues Passwort
setzen. Für OIDC-Konten wird die Passwortverwaltung im Identitätsanbieter
(z. B. Keycloak) angeboten.
- **Nutzer löschen:** Entfernt das Konto endgültig (nicht für das eigene Konto).
API-Referenz:
[`POST /users`](/docs/reference/endpoints/users#createUser),
[`PATCH /users/{id}`](/docs/reference/endpoints/users#updateUser),
[`DELETE /users/{id}`](/docs/reference/endpoints/users#deleteUser).
## Tab „Tools"
Zentraler Zugriff auf den Tool-Katalog.
- **Suchen** nach Tools.
- Tools einzeln ansehen, bearbeiten oder in den Papierkorb verschieben.
- **Massenaktion:** mehrere Tools auswählen und in den Papierkorb verschieben
(Bestätigungsdialog; soft gelöschte Tools sind aus allen öffentlichen Ansichten
entfernt und können wiederhergestellt oder endgültig gelöscht werden).
## Tab „Audit-Log"
Chronologisches Protokoll aller Anlage-, Änderungs- und Löschvorgänge
(max. 100 Einträge): Aktion, Entität + ID, Zeitstempel, ausführende Person und
geänderte Felder.
API-Referenz: [`GET /audit-logs`](/docs/reference/endpoints/audit#listAuditLogs).
## Tab „System"
Versionsinformationen der laufenden Instanz:
- **Version** (z. B. `v0.8.1`),
- **Commit** (7-stelliger SHA, verlinkt zum Repository),
- **Build-Datum**,
- **Papierkorb-Aufbewahrung** („N Tage" oder „Für immer").
## Tool-Verknüpfungen (Admin)
Auf der Detailseite eines Tools kannst du als Admin **Verknüpfungen**
(eigene/„manual" sowie automatisch erkannte) verwalten:
- **Tool verknüpfen:** Dialog mit Tool-ID, **Beziehungstyp**
(Ähnlich / Ersetzt / Abgelöst durch) und optionalen Notizen.
- Beziehungstypen werden als Badges auf der Detailseite angezeigt.
- Manuelle Verknüpfungen lassen sich per Papierkorb-Icon wieder entfernen.
@@ -0,0 +1,29 @@
# Analytics
Der Bereich **Analytics** (`/analytics`) ist ein öffentliches Dashboard mit
Kennzahlen und Diagrammen auf Basis aller Tools und Bewertungen.
## Kennzahlen (KPI-Karten)
- **Anzahl Tools** — wie viele Tools sind im Katalog erfasst.
- **Anzahl Bewertungen** — wie viele Bewertungen wurden insgesamt abgegeben.
- **Aktive Kategorien** — wie viele Kategorien existieren.
- **Durchschnittliche Bewertung** — globaler kombinierter Wert.
## Diagramme
| Diagramm | Inhalt |
| --- | --- |
| **Top 8 Tools** | Balkendiagramm der Tools mit der höchsten kombinierten Punktzahl (05) |
| **Tools je Kategorie** | Radar-Diagramm der Tool-Anzahl pro Kategorie |
| **Punkteverteilung** | Zwei horizontale Balken-Diagramme (Nützlichkeit & Bedienbarkeit) pro Stern |
Die Diagramme sind interaktiv (Tooltips beim Überfahren).
## API
- [`GET /analytics/summary`](/docs/reference/endpoints/analytics#getAnalyticsSummary)
- [`GET /analytics/top-tools`](/docs/reference/endpoints/analytics#getTopTools)
- [`GET /analytics/by-category`](/docs/reference/endpoints/analytics#getAnalyticsByCategory)
- [`GET /analytics/rating-distribution`](/docs/reference/endpoints/analytics#getRatingDistribution)
+39
View File
@@ -0,0 +1,39 @@
# Bewerten
Auf der Detailseite eines Tools kannst du deine Erfahrung teilen. Klicke auf
**Bewertung abgeben** (erfordert ein Konto).
## Formularfelder
| Feld | Pflicht | Hinweise |
| --- | --- | --- |
| **Nützlichkeit** | Ja | 15 Sterne |
| **Bedienbarkeit** | Ja | 15 Sterne |
| **Kommentar** | Nein | Freitext |
| **Name** | Nein | Standard „Anonym" |
Neben den Feldern führt das **?Icon** direkt zur zugehörigen Feldbeschreibung
in der [Datenmodell-Referenz](/docs/reference/schemas/ratinginput).
## Was passiert nach dem Abgeben?
- Deine Bewertung wird sofort gespeichert und erscheint in der
**Bewertungsliste** der Detailseite.
- Die **Durchschnittswerte** (Nützlichkeit, Bedienbarkeit, Kombiniert) und die
**Punkteverteilung** werden aktualisiert.
- Die **Statistiken** im Bereich [Analytics](/docs/handbook/analytics) werden
neu berechnet.
## Statistik-Bereiche auf der Detailseite
- **Bewertungsübersicht:** Nützlichkeit & Bedienbarkeit als Durchschnitt mit
Fortschrittsbalken.
- **Punkteverteilung:** Anzahl der Bewertungen pro Stern (1★–5★).
- **Verlauf:** Linienchart der kombinierten/Teilwerte über die Zeit
(erst ab mehreren Bewertungen sichtbar).
## API
- [`POST /tools/{id}/ratings`](/docs/reference/endpoints/ratings#createRating) — Bewertung abgeben
- [`GET /tools/{id}/ratings`](/docs/reference/endpoints/ratings#listToolRatings) — Bewertungen eines Tools
@@ -0,0 +1,70 @@
# Datenmodell
Dieses Kapitel erklärt die zentralen Datenobjekte von toolr auf Ebene der
Anwendung. Die vollständige, automatisch generierte Referenz aller Felder,
Typen und Constraints findest du in der
[API-Referenz](/docs/reference/schemas/tool).
## Tool
Das Herzstück: ein im Katalog erfasstes Werkzeug.
| Eigenschaft | Beschreibung |
| --- | --- |
| `id` | Eindeutige Kennung |
| `name` | Anzeigename |
| `description` | Beschreibung (Was macht das Tool?) |
| `category` | Kategorie-Zuordnung |
| `websiteUrl` | Offizielle Website (optional) |
| `iconUrl` | Logo-/Icon-URL (optional) |
| `features` | Liste von Fähigkeiten |
| `tags` | Liste von Schlagwörtern |
| `createdAt` / `updatedAt` | Zeitstempel |
| `createdBy` | Erstellende Person |
| `deletedAt` / `deletedBy` | Soft-Löschung (Papierkorb) |
Eingabe-Formulare verwenden die abgeleiteten Schemas
[`ToolInput`](/docs/reference/schemas/toolinput) und
[`ToolUpdate`](/docs/reference/schemas/toolupdate).
Aggregierte Ansichten liefert [`ToolWithStats`](/docs/reference/schemas/toolwithstats)
(z. B. mit Durchschnittsbewertung).
## Rating (Bewertung)
Eine einzelne Bewertung zu einem Tool:
- `usefulness` (Nützlichkeit, 15) und `usability` (Bedienbarkeit, 15)
- optional `comment` und ein Anzeigename (`reviewerName`)
- Zeitstempel
Eingabe-Schema: [`RatingInput`](/docs/reference/schemas/ratinginput).
## User & Auth
- [`User`](/docs/reference/schemas/user) — Benutzerkonto mit Rolle (User/Admin)
und Tarif (Free/Premium/Enterprise).
- [`AuthUser`](/docs/reference/schemas/authuser) — das angemeldete Profil
inklusive `entitlements` (verfügbare Features).
- [`UserPreferences`](/docs/reference/schemas/userpreferences) — Ansichts- und
Dichte-Präferenzen sowie die `watchlist` (Liste von Tool-IDs).
## Analytics
Die Statistik-Endpunkte liefern aggregierte Daten:
- [`AnalyticsSummary`](/docs/reference/schemas/analyticssummary) — globale
Kennzahlen (Anzahl Tools/Bewertungen, Kategorien, Durchschnitt).
- [`TopToolEntry`](/docs/reference/schemas/TopToolEntry) — ein Eintrag der
Top-Tools.- [`CategoryStats`](/docs/reference/schemas/categorystats) — Tool-Anzahl je
Kategorie.
- [`RatingDistribution`](/docs/reference/schemas/ratingdistribution) —
Punkteverteilung (Nützlichkeit & Bedienbarkeit).
- [`ScoreBucket`](/docs/reference/schemas/scorebucket) — ein Werte-Bucket.
## Weitere
- [`VersionInfo`](/docs/reference/schemas/versioninfo) — Version, Commit-SHA,
Build-Datum und Papierkorb-Aufbewahrung der laufenden Instanz.
- [`AuditLog`](/docs/reference/schemas/auditlog) — ein Protokolleintrag
(Aktion, Entität, Zeitstempel, Akteur, Änderungen).
@@ -0,0 +1,53 @@
# Erste Schritte
Diese Seite führt dich durch die wichtigsten Abläufe in toolr — vom ersten
Besuch bis zum Anlegen und Bewerten eines Tools.
## 1. Anmelden
Die meisten Aktionen (Tool anlegen, bewerten, Watchlist, Vergleichen) erfordern
ein Konto. Klicke unten links auf **Anmelden**. Je nach Konfiguration der
Instanz hast du zwei Möglichkeiten:
- **Lokale Konten:** Benutzername + Passwort. Der Zugang wird von einem Admin
angelegt (siehe [Administration](/docs/handbook/administration)).
- **OIDC (SSO):** Anmelden mit dem konfigurierten Identitätsanbieter (z. B.
Keycloak).
Welcher Modus aktiv ist, steht im Endpunkt
[`GET /auth/mode`](/docs/reference/endpoints/auth#getAuthMode). Details findest
du im Abschnitt [Anmelden & Konto](/docs/handbook/konto).
## 2. Tools finden
Öffne den Bereich **Tools durchsuchen**:
- **Suchen** — Volltextsuche über Name & Beschreibung (Tastenkürzel `/`).
- **Filtern** — nach Kategorie, Tags, Features und Mindestbewertung
(`minRating`).
- **Sortieren** — nach Aktualität, Top-Bewertung, meistbewertet, Name
(auf-/absteigend) oder letztem Update.
Alle Optionen im Detail: [Tools finden & durchsuchen](/docs/handbook/tools-finden).
## 3. Tool anlegen
Gehe auf **Tool hinzufügen** und fülle das Formular aus. Details zu jedem Feld
findest du im Abschnitt [Tool anlegen](/docs/handbook/tool-anlegen) und in der
[Feld-Referenz](/docs/reference/schemas/toolinput).
## 4. Bewerten
Auf der Detailseite eines Tools kannst du **Nützlichkeit** und **Bedienbarkeit**
(jeweils 15) vergeben und optional einen Kommentar hinterlassen. Deine
Bewertung fließt sofort in die Statistiken ein.
Siehe [Bewerten](/docs/handbook/bewerten).
## 5. Weiterführend
- [Tools vergleichen](/docs/handbook/vergleichen)
- [Watchlist](/docs/handbook/watchlist)
- [Analytics](/docs/handbook/analytics)
- [Pläne & Berechtigungen](/docs/handbook/plaene)
- [Administration](/docs/handbook/administration)

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