Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68a81ec775 | |||
| 63f0bdbab6 | |||
| 4648614556 | |||
| 53f01acb0c | |||
| ab24f99b90 | |||
| c6755b586b | |||
| 2f38889726 | |||
| 95bf49509c | |||
| d033b20dfb | |||
| c77610786b | |||
| 0c27982098 | |||
| 461eca9a0a | |||
| 620b12d4b6 | |||
| 78244c3197 |
@@ -29,9 +29,15 @@ jobs:
|
|||||||
SHA=$(git rev-parse --short HEAD)
|
SHA=$(git rev-parse --short HEAD)
|
||||||
FULL_SHA=$(git rev-parse HEAD)
|
FULL_SHA=$(git rev-parse HEAD)
|
||||||
IMAGE="git.kubebase.de/${{ gitea.repository }}"
|
IMAGE="git.kubebase.de/${{ gitea.repository }}"
|
||||||
if [ "${{ gitea.ref_type }}" = "tag" ]; then VERSION="${{ gitea.ref_name }}"; else VERSION=""; fi
|
DATE_STAMP=$(date -u +"%Y%m%d")
|
||||||
TAGS="-t ${IMAGE}:sha-${SHA} -t ${IMAGE}:latest"
|
if [ "${{ gitea.ref_type }}" = "tag" ]; then
|
||||||
if [ -n "$VERSION" ]; then TAGS="${TAGS} -t ${IMAGE}:${VERSION}"; fi
|
VERSION="${{ gitea.ref_name }}"
|
||||||
|
VERSION_TAG="${{ gitea.ref_name }}"
|
||||||
|
else
|
||||||
|
VERSION="dev-$(date -u +"%Y%m%d-%H%M")"
|
||||||
|
VERSION_TAG="nightly-${DATE_STAMP}"
|
||||||
|
fi
|
||||||
|
TAGS="-t ${IMAGE}:sha-${SHA} -t ${IMAGE}:latest -t ${IMAGE}:${VERSION_TAG}"
|
||||||
docker build --no-cache \
|
docker build --no-cache \
|
||||||
--build-arg COMMIT_SHA="$FULL_SHA" \
|
--build-arg COMMIT_SHA="$FULL_SHA" \
|
||||||
--build-arg BUILD_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
|
--build-arg BUILD_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
|
||||||
@@ -39,19 +45,24 @@ jobs:
|
|||||||
$TAGS .
|
$TAGS .
|
||||||
docker push "${IMAGE}:sha-${SHA}"
|
docker push "${IMAGE}:sha-${SHA}"
|
||||||
docker push "${IMAGE}:latest"
|
docker push "${IMAGE}:latest"
|
||||||
if [ -n "$VERSION" ]; then docker push "${IMAGE}:${VERSION}"; fi
|
docker push "${IMAGE}:${VERSION_TAG}"
|
||||||
|
|
||||||
- name: Update k8s manifest in admin/apps
|
- name: Update k8s manifest in admin/apps
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
SHA=$(git rev-parse --short HEAD)
|
SHA=$(git rev-parse --short HEAD)
|
||||||
|
if [ "${{ gitea.ref_type }}" = "tag" ]; then
|
||||||
|
NEWTAG="${{ gitea.ref_name }}"
|
||||||
|
else
|
||||||
|
NEWTAG="sha-${SHA}"
|
||||||
|
fi
|
||||||
git clone "https://admin:${GITEA_TOKEN}@git.kubebase.de/admin/apps.git" /tmp/apps
|
git clone "https://admin:${GITEA_TOKEN}@git.kubebase.de/admin/apps.git" /tmp/apps
|
||||||
cd /tmp/apps
|
cd /tmp/apps
|
||||||
cd apps/system/toolrate/overlays/k3s
|
cd apps/system/toolrate/overlays/k3s
|
||||||
sed -i "s|newTag: .*|newTag: sha-${SHA}|" kustomization.yaml
|
sed -i "s|newTag: .*|newTag: ${NEWTAG}|" kustomization.yaml
|
||||||
git config user.name "Gitea Actions"
|
git config user.name "Gitea Actions"
|
||||||
git config user.email "actions@git.kubebase.de"
|
git config user.email "actions@git.kubebase.de"
|
||||||
git add -A
|
git add -A
|
||||||
git diff --cached --quiet || git commit -m "chore: update toolrate image to sha-${SHA}"
|
git diff --cached --quiet || git commit -m "chore: update toolrate image to ${NEWTAG}"
|
||||||
git push
|
git push
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
modules = ["nodejs-24", "postgresql-16"]
|
|
||||||
|
|
||||||
[deployment]
|
|
||||||
router = "application"
|
|
||||||
deploymentTarget = "autoscale"
|
|
||||||
|
|
||||||
[deployment.postBuild]
|
|
||||||
args = ["pnpm", "store", "prune"]
|
|
||||||
env = { "CI" = "true" }
|
|
||||||
|
|
||||||
[workflows]
|
|
||||||
runButton = "Project"
|
|
||||||
|
|
||||||
[agent]
|
|
||||||
stack = "PNPM_WORKSPACE"
|
|
||||||
expertMode = true
|
|
||||||
|
|
||||||
[postMerge]
|
|
||||||
path = "scripts/post-merge.sh"
|
|
||||||
timeoutMs = 20000
|
|
||||||
|
|
||||||
[[ports]]
|
|
||||||
localPort = 8080
|
|
||||||
externalPort = 80
|
|
||||||
|
|
||||||
[[ports]]
|
|
||||||
localPort = 8081
|
|
||||||
externalPort = 8081
|
|
||||||
|
|
||||||
[[ports]]
|
|
||||||
localPort = 26015
|
|
||||||
externalPort = 3000
|
|
||||||
|
|
||||||
[nix]
|
|
||||||
channel = "stable-25_05"
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# The format of this file is identical to `.dockerignore`.
|
|
||||||
# It is used to reduce the size of deployed images to make the process of publishing faster.
|
|
||||||
|
|
||||||
# No need to store the pnpm store twice.
|
|
||||||
.local
|
|
||||||
+38
-35
@@ -2,10 +2,30 @@
|
|||||||
|
|
||||||
Community platform for listing and rating developer/productivity tools by usefulness and usability.
|
Community platform for listing and rating developer/productivity tools by usefulness and usability.
|
||||||
|
|
||||||
## Run & Operate
|
## Stack
|
||||||
|
|
||||||
- `pnpm --filter @workspace/api-server run dev` — run the API server (port 8080)
|
- pnpm workspaces, Node.js 24, TypeScript 5.9
|
||||||
- `pnpm --filter @workspace/toolrate run dev` — run the frontend (port 26015)
|
- API: Express 5 + express-session + connect-pg-simple
|
||||||
|
- DB: PostgreSQL + Drizzle ORM
|
||||||
|
- Validation: Zod (`zod/v4`), `drizzle-zod`
|
||||||
|
- API codegen: Orval (from OpenAPI spec)
|
||||||
|
- Build: esbuild (CJS bundle)
|
||||||
|
- Frontend: React 19 + Vite + TanStack Query + wouter + shadcn/ui + recharts
|
||||||
|
|
||||||
|
## Packages
|
||||||
|
|
||||||
|
- `lib/api-spec` — OpenAPI contract (source of truth)
|
||||||
|
- `lib/api-zod` — generated Zod schemas from the spec
|
||||||
|
- `lib/api-client-react` — generated React Query hooks
|
||||||
|
- `lib/db` — Drizzle schema + DB access
|
||||||
|
- `artifacts/api-server` — Express backend
|
||||||
|
- `artifacts/toolrate` — React frontend
|
||||||
|
- `artifacts/mockup-sandbox` — component preview
|
||||||
|
|
||||||
|
## Run & develop
|
||||||
|
|
||||||
|
- `pnpm --filter @workspace/api-server run dev` — run the API server
|
||||||
|
- `pnpm --filter @workspace/toolrate run dev` — run the frontend
|
||||||
- `pnpm run typecheck` — full typecheck across all packages
|
- `pnpm run typecheck` — full typecheck across all packages
|
||||||
- `pnpm run build` — typecheck + build all packages
|
- `pnpm run build` — typecheck + build all packages
|
||||||
- `pnpm --filter @workspace/api-spec run codegen` — regenerate API hooks and Zod schemas from the OpenAPI spec
|
- `pnpm --filter @workspace/api-spec run codegen` — regenerate API hooks and Zod schemas from the OpenAPI spec
|
||||||
@@ -13,29 +33,16 @@ Community platform for listing and rating developer/productivity tools by useful
|
|||||||
|
|
||||||
## Required env vars
|
## Required env vars
|
||||||
|
|
||||||
- `DATABASE_URL` — Postgres connection string (auto-provisioned)
|
- `DATABASE_URL` — Postgres connection string
|
||||||
- `SESSION_SECRET` — Session signing secret (already set)
|
- `SESSION_SECRET` — session signing secret
|
||||||
|
- `VOTER_SECRET` — secret for voter submissions
|
||||||
|
- `LOCAL_ADMIN_USERNAME` / `LOCAL_ADMIN_PASSWORD` — local admin login
|
||||||
|
|
||||||
## Keycloak (optional)
|
## Auth
|
||||||
|
|
||||||
Set these to enable login:
|
- Session-based auth (not JWT), sessions stored in Postgres via connect-pg-simple.
|
||||||
- `KEYCLOAK_URL` — e.g. `https://auth.example.com`
|
- Write operations (create/update/delete tools, submit ratings) require auth; reads are public.
|
||||||
- `KEYCLOAK_REALM` — realm name
|
- Login via local admin user (`LOCAL_ADMIN_*`). Keycloak/OIDC support exists as an optional legacy path.
|
||||||
- `KEYCLOAK_CLIENT_ID` — client ID
|
|
||||||
- `KEYCLOAK_CLIENT_SECRET` — client secret
|
|
||||||
- `APP_URL` — public base URL for OAuth callback (optional, auto-detected if omitted)
|
|
||||||
|
|
||||||
Without these, the app runs in read-only mode (browsing and viewing ratings works, submitting tools/ratings requires login).
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- pnpm workspaces, Node.js 24, TypeScript 5.9
|
|
||||||
- API: Express 5 + openid-client (Keycloak OIDC) + express-session + connect-pg-simple
|
|
||||||
- DB: PostgreSQL + Drizzle ORM
|
|
||||||
- Validation: Zod (`zod/v4`), `drizzle-zod`
|
|
||||||
- API codegen: Orval (from OpenAPI spec)
|
|
||||||
- Build: esbuild (CJS bundle)
|
|
||||||
- Frontend: React 19 + Vite + TanStack Query + wouter + shadcn/ui + recharts
|
|
||||||
|
|
||||||
## Where things live
|
## Where things live
|
||||||
|
|
||||||
@@ -45,7 +52,7 @@ Without these, the app runs in read-only mode (browsing and viewing ratings work
|
|||||||
- `lib/api-zod/src/generated/` — generated Zod schemas (do not edit)
|
- `lib/api-zod/src/generated/` — generated Zod schemas (do not edit)
|
||||||
- `artifacts/api-server/src/routes/` — Express route handlers
|
- `artifacts/api-server/src/routes/` — Express route handlers
|
||||||
- `artifacts/api-server/src/middleware/auth.ts` — `requireAuth` middleware
|
- `artifacts/api-server/src/middleware/auth.ts` — `requireAuth` middleware
|
||||||
- `artifacts/api-server/src/routes/auth.ts` — Keycloak OIDC login/callback/logout/me
|
- `artifacts/api-server/src/routes/auth.ts` — login/logout/me
|
||||||
- `artifacts/toolrate/src/pages/` — frontend pages
|
- `artifacts/toolrate/src/pages/` — frontend pages
|
||||||
- `artifacts/toolrate/src/components/` — shared components (layout, tool-card, category-combobox, feature-input)
|
- `artifacts/toolrate/src/components/` — shared components (layout, tool-card, category-combobox, feature-input)
|
||||||
- `artifacts/toolrate/src/hooks/use-auth.ts` — auth state hook
|
- `artifacts/toolrate/src/hooks/use-auth.ts` — auth state hook
|
||||||
@@ -54,9 +61,9 @@ Without these, the app runs in read-only mode (browsing and viewing ratings work
|
|||||||
|
|
||||||
- Contract-first: OpenAPI spec → codegen → typed hooks + Zod schemas. Never hand-write fetch calls.
|
- Contract-first: OpenAPI spec → codegen → typed hooks + Zod schemas. Never hand-write fetch calls.
|
||||||
- Session-based auth (not JWT) — sessions stored in Postgres via connect-pg-simple.
|
- Session-based auth (not JWT) — sessions stored in Postgres via connect-pg-simple.
|
||||||
- Write operations (create/update/delete tools, submit ratings) require auth. Reads are public.
|
- Write operations (create/update/delete tools, submit votes) require auth. Reads are public.
|
||||||
- Category and feature autocomplete are client-side filtered against live API data (no separate index).
|
- Category and feature autocomplete are client-side filtered against live API data (no separate index).
|
||||||
- Grafana can consume `/api/analytics/*` endpoints directly via JSON datasource plugin.
|
- Grafana can consume `/api/analytics/*` endpoints directly via the JSON datasource plugin.
|
||||||
|
|
||||||
## Product
|
## Product
|
||||||
|
|
||||||
@@ -66,16 +73,12 @@ Without these, the app runs in read-only mode (browsing and viewing ratings work
|
|||||||
- Analytics dashboard: top tools chart, category breakdown, score distribution histograms
|
- Analytics dashboard: top tools chart, category breakdown, score distribution histograms
|
||||||
- Grafana integration: all `/api/analytics/*` endpoints return clean JSON
|
- Grafana integration: all `/api/analytics/*` endpoints return clean JSON
|
||||||
|
|
||||||
## User preferences
|
## Deployment
|
||||||
|
|
||||||
_Populate as you build — explicit user instructions worth remembering across sessions._
|
- Container image: `git.kubebase.de/admin/tool-evaluator:<tag>` (pushed by CI)
|
||||||
|
- Deployed to k3s via the `apps` repo (kustomize), served at `https://toolr.kubebase.de`
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Orval clears the output folder during codegen — transient HMR errors in the dev server are normal and auto-recover.
|
- Orval clears the output folder during codegen — transient HMR errors in the dev server are normal and auto-recover.
|
||||||
- Auth routes use PKCE — the code_verifier is stored in the session, not in-memory state.
|
- Auth uses PKCE — the `code_verifier` is stored in the session, not in-memory state.
|
||||||
- `trust proxy: 1` is set on Express so that session cookies work correctly behind Replit's reverse proxy.
|
|
||||||
|
|
||||||
## Pointers
|
|
||||||
|
|
||||||
- See the `pnpm-workspace` skill for workspace structure, TypeScript setup, and package details
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
kind = "api"
|
|
||||||
previewPath = "/api" # TODO - should be excluded from preview in the first place
|
|
||||||
title = "API Server"
|
|
||||||
version = "1.0.0"
|
|
||||||
id = "3B4_FFSkEVBkAeYMFRJ2e"
|
|
||||||
|
|
||||||
[[services]]
|
|
||||||
localPort = 8080
|
|
||||||
name = "API Server"
|
|
||||||
paths = ["/api"]
|
|
||||||
|
|
||||||
[services.development]
|
|
||||||
run = "pnpm --filter @workspace/api-server run dev"
|
|
||||||
|
|
||||||
[services.production]
|
|
||||||
|
|
||||||
[services.production.build]
|
|
||||||
args = ["pnpm", "--filter", "@workspace/api-server", "run", "build"]
|
|
||||||
|
|
||||||
[services.production.build.env]
|
|
||||||
NODE_ENV = "production"
|
|
||||||
|
|
||||||
[services.production.run]
|
|
||||||
# we don't run through pnpm to make startup faster in production
|
|
||||||
args = ["node", "--enable-source-maps", "artifacts/api-server/dist/index.mjs"]
|
|
||||||
|
|
||||||
[services.production.run.env]
|
|
||||||
PORT = "8080"
|
|
||||||
NODE_ENV = "production"
|
|
||||||
|
|
||||||
[services.production.health.startup]
|
|
||||||
path = "/api/healthz"
|
|
||||||
@@ -13,16 +13,17 @@
|
|||||||
"@workspace/api-zod": "workspace:*",
|
"@workspace/api-zod": "workspace:*",
|
||||||
"@workspace/db": "workspace:*",
|
"@workspace/db": "workspace:*",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"zod": "catalog:",
|
|
||||||
"connect-pg-simple": "^10.0.0",
|
"connect-pg-simple": "^10.0.0",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"express-rate-limit": "^8.6.1",
|
||||||
"express-session": "^1.19.0",
|
"express-session": "^1.19.0",
|
||||||
"openid-client": "^5.7.1",
|
"openid-client": "^5.7.1",
|
||||||
"pino": "^9.14.0",
|
"pino": "^9.14.0",
|
||||||
"pino-http": "^10.5.0"
|
"pino-http": "^10.5.0",
|
||||||
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/bcryptjs": "^3.0.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." },
|
||||||
|
});
|
||||||
@@ -2,8 +2,8 @@ import { type Request, type Response, type NextFunction } from "express";
|
|||||||
|
|
||||||
const TIER_FEATURES: Record<string, string[]> = {
|
const TIER_FEATURES: Record<string, string[]> = {
|
||||||
free: ["browse", "rate", "search"],
|
free: ["browse", "rate", "search"],
|
||||||
premium: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash"],
|
premium: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "compare", "watchlist"],
|
||||||
enterprise: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "sso", "audit-export", "api-access"],
|
enterprise: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "compare", "watchlist", "sso", "audit-export", "api-access"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getEntitlements(tier: string | undefined, role: string | undefined): string[] {
|
export function getEntitlements(tier: string | undefined, role: string | undefined): string[] {
|
||||||
|
|||||||
@@ -104,7 +104,13 @@ router.get("/analytics/top-tools", async (req, res): Promise<void> => {
|
|||||||
? (Number(avgUsefulness) + Number(avgUsability)) / 2
|
? (Number(avgUsefulness) + Number(avgUsability)) / 2
|
||||||
: null;
|
: null;
|
||||||
return {
|
return {
|
||||||
tool: { ...tool, ratingCount, avgUsefulness, avgUsability, avgCombined },
|
tool: {
|
||||||
|
...tool,
|
||||||
|
ratingCount,
|
||||||
|
avgUsefulness: avgUsefulness != null ? Number(avgUsefulness) : null,
|
||||||
|
avgUsability: avgUsability != null ? Number(avgUsability) : null,
|
||||||
|
avgCombined,
|
||||||
|
},
|
||||||
score: Number(score),
|
score: Number(score),
|
||||||
ratingCount,
|
ratingCount,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Router, type IRouter, type Request } from "express";
|
import { Router, type IRouter, type Request } from "express";
|
||||||
import { Issuer, generators, type Client } from "openid-client";
|
import { Issuer, generators, type Client } from "openid-client";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, and, inArray, isNull } from "drizzle-orm";
|
||||||
import { db, usersTable } from "@workspace/db";
|
import { z } from "zod";
|
||||||
|
import { db, usersTable, toolsTable, ratingsTable } from "@workspace/db";
|
||||||
import { logger } from "../lib/logger";
|
import { logger } from "../lib/logger";
|
||||||
import { getEntitlements } from "../middleware/feature";
|
import { writeAuditLog } from "../lib/audit";
|
||||||
|
import { loginRateLimit, passwordRateLimit } from "../lib/rate-limit";
|
||||||
|
import { getEntitlements, requireFeature } from "../middleware/feature";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
@@ -106,7 +109,7 @@ router.get("/auth/mode", (_req, res): void => {
|
|||||||
res.json({ mode: isOidcConfigured() ? "oidc" : "local" });
|
res.json({ mode: isOidcConfigured() ? "oidc" : "local" });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/auth/login", async (req, res): Promise<void> => {
|
router.post("/auth/login", loginRateLimit, async (req, res): Promise<void> => {
|
||||||
if (isOidcConfigured()) {
|
if (isOidcConfigured()) {
|
||||||
res.status(400).json({ error: "Use OIDC login when Keycloak is configured." });
|
res.status(400).json({ error: "Use OIDC login when Keycloak is configured." });
|
||||||
return;
|
return;
|
||||||
@@ -278,4 +281,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.issuer.metadata.issuer ?? "";
|
||||||
|
res.json({ url: `${realm}/account/password` });
|
||||||
|
});
|
||||||
|
|
||||||
|
const ChangePasswordSchema = z.object({
|
||||||
|
currentPassword: z.string().min(1),
|
||||||
|
newPassword: z.string().min(8),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/auth/me/password", passwordRateLimit, async (req, res): Promise<void> => {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ error: "Not authenticated" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dbUser = await resolveDbUser(req.session.user);
|
||||||
|
if (!dbUser) {
|
||||||
|
res.status(401).json({ error: "User not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dbUser.authProvider !== "local") {
|
||||||
|
res.status(422).json({ error: "oidc" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = ChangePasswordSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { currentPassword, newPassword } = parsed.data;
|
||||||
|
if (currentPassword === newPassword) {
|
||||||
|
res.status(400).json({ error: "New password must differ from current password" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!dbUser.passwordHash || !(await bcrypt.compare(currentPassword, dbUser.passwordHash))) {
|
||||||
|
res.status(400).json({ error: "Current password is incorrect" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||||
|
await db.update(usersTable).set({ passwordHash }).where(eq(usersTable.id, dbUser.id));
|
||||||
|
await writeAuditLog(req, "user", dbUser.id, "change_password", {});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
req.session.regenerate((err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
type SessionUser = NonNullable<import("express-session").SessionData["user"]>;
|
||||||
|
|
||||||
|
async function resolveDbUser(u: SessionUser) {
|
||||||
|
if (u.isLocal) {
|
||||||
|
const id = Number(u.sub);
|
||||||
|
if (Number.isFinite(id)) {
|
||||||
|
const [byId] = await db.select().from(usersTable).where(eq(usersTable.id, id)).limit(1);
|
||||||
|
if (byId) return byId;
|
||||||
|
}
|
||||||
|
if (u.preferred_username) {
|
||||||
|
const [byName] = await db.select().from(usersTable).where(eq(usersTable.username, u.preferred_username)).limit(1);
|
||||||
|
if (byName) return byName;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const [byProvider] = await db.select().from(usersTable).where(eq(usersTable.authProviderId, u.sub)).limit(1);
|
||||||
|
return byProvider ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PreferenceSchema = z.object({
|
||||||
|
view: z.enum(["grid", "table", "rows"]).optional(),
|
||||||
|
density: z.enum(["cozy", "compact"]).optional(),
|
||||||
|
watchlist: z.array(z.number().int().positive()).max(50).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/auth/me/preferences", async (req, res): Promise<void> => {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ error: "Not authenticated" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dbUser = await resolveDbUser(req.session.user);
|
||||||
|
if (!dbUser) {
|
||||||
|
res.status(401).json({ error: "User not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(dbUser.preferences ?? {});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/auth/me/watchlist", requireFeature("watchlist"), async (req, res): Promise<void> => {
|
||||||
|
const dbUser = await resolveDbUser(req.session.user!);
|
||||||
|
if (!dbUser) {
|
||||||
|
res.status(401).json({ error: "User not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ids = (dbUser.preferences?.watchlist ?? []).filter((id) => Number.isInteger(id));
|
||||||
|
if (ids.length === 0) {
|
||||||
|
res.json([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tools = await db
|
||||||
|
.select()
|
||||||
|
.from(toolsTable)
|
||||||
|
.where(and(isNull(toolsTable.deletedAt), inArray(toolsTable.id, ids)));
|
||||||
|
const ratings = await db
|
||||||
|
.select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
|
||||||
|
.from(ratingsTable)
|
||||||
|
.where(inArray(ratingsTable.toolId, tools.map((t) => t.id)));
|
||||||
|
const byTool = new Map<number, { usefulness: number; usability: number }[]>();
|
||||||
|
for (const r of ratings) {
|
||||||
|
const list = byTool.get(r.toolId) ?? [];
|
||||||
|
list.push({ usefulness: r.usefulness, usability: r.usability });
|
||||||
|
byTool.set(r.toolId, list);
|
||||||
|
}
|
||||||
|
const byId = new Map(tools.map((t) => [t.id, t]));
|
||||||
|
const ordered = ids
|
||||||
|
.filter((id) => byId.has(id))
|
||||||
|
.map((id) => {
|
||||||
|
const tool = byId.get(id)!;
|
||||||
|
const toolRatings = byTool.get(id) ?? [];
|
||||||
|
const ratingCount = toolRatings.length;
|
||||||
|
const avgUsefulness = ratingCount > 0 ? toolRatings.reduce((s, r) => s + r.usefulness, 0) / ratingCount : null;
|
||||||
|
const avgUsability = ratingCount > 0 ? toolRatings.reduce((s, r) => s + r.usability, 0) / ratingCount : null;
|
||||||
|
const avgCombined = avgUsefulness != null && avgUsability != null ? (avgUsefulness + avgUsability) / 2 : null;
|
||||||
|
return { ...tool, ratingCount, avgUsefulness, avgUsability, avgCombined };
|
||||||
|
});
|
||||||
|
res.json(ordered);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put("/auth/me/preferences", async (req, res): Promise<void> => {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ error: "Not authenticated" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = PreferenceSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dbUser = await resolveDbUser(req.session.user);
|
||||||
|
if (!dbUser) {
|
||||||
|
res.status(401).json({ error: "User not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const merged = { ...(dbUser.preferences ?? {}), ...parsed.data };
|
||||||
|
await db.update(usersTable).set({ preferences: merged }).where(eq(usersTable.id, dbUser.id));
|
||||||
|
res.json(merged);
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router, type IRouter } from "express";
|
import { Router, type IRouter } from "express";
|
||||||
import { eq, desc, sql, and, not, isNull, inArray } from "drizzle-orm";
|
import { eq, desc, asc, sql, and, not, isNull, inArray } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db";
|
import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db";
|
||||||
import {
|
import {
|
||||||
@@ -52,7 +52,10 @@ router.get("/tools", async (req, res): Promise<void> => {
|
|||||||
res.status(400).json({ error: parsed.error.message });
|
res.status(400).json({ error: parsed.error.message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { category, search, sort } = parsed.data;
|
const { category, search, sort, tags, features, minRating } = parsed.data;
|
||||||
|
|
||||||
|
const tagList = (tags ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
||||||
|
const featureList = (features ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
||||||
|
|
||||||
let query = db.select().from(toolsTable).where(isNull(toolsTable.deletedAt)).$dynamic();
|
let query = db.select().from(toolsTable).where(isNull(toolsTable.deletedAt)).$dynamic();
|
||||||
if (category) {
|
if (category) {
|
||||||
@@ -62,6 +65,12 @@ router.get("/tools", async (req, res): Promise<void> => {
|
|||||||
const escaped = search.replace(/[%_\\]/g, (m) => `\\${m}`);
|
const escaped = search.replace(/[%_\\]/g, (m) => `\\${m}`);
|
||||||
query = query.where(sql`${toolsTable.name} ilike ${`%${escaped}%`} escape '\\'`);
|
query = query.where(sql`${toolsTable.name} ilike ${`%${escaped}%`} escape '\\'`);
|
||||||
}
|
}
|
||||||
|
if (tagList.length > 0) {
|
||||||
|
query = query.where(sql`${toolsTable.tags} @> ARRAY[${sql.join(tagList.map((t) => sql`${t}`), sql`, `)}]::text[]`);
|
||||||
|
}
|
||||||
|
if (featureList.length > 0) {
|
||||||
|
query = query.where(sql`${toolsTable.features} @> ARRAY[${sql.join(featureList.map((f) => sql`${f}`), sql`, `)}]::text[]`);
|
||||||
|
}
|
||||||
|
|
||||||
const tools = await query.orderBy(desc(toolsTable.createdAt));
|
const tools = await query.orderBy(desc(toolsTable.createdAt));
|
||||||
|
|
||||||
@@ -87,6 +96,16 @@ router.get("/tools", async (req, res): Promise<void> => {
|
|||||||
result = result.sort((a, b) => (b.avgCombined ?? 0) - (a.avgCombined ?? 0));
|
result = result.sort((a, b) => (b.avgCombined ?? 0) - (a.avgCombined ?? 0));
|
||||||
} else if (sort === "most_reviewed") {
|
} else if (sort === "most_reviewed") {
|
||||||
result = result.sort((a, b) => b.ratingCount - a.ratingCount);
|
result = result.sort((a, b) => b.ratingCount - a.ratingCount);
|
||||||
|
} else if (sort === "name_asc") {
|
||||||
|
result = result.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
|
||||||
|
} else if (sort === "name_desc") {
|
||||||
|
result = result.sort((a, b) => b.name.localeCompare(a.name, undefined, { sensitivity: "base" }));
|
||||||
|
} else if (sort === "recently_updated") {
|
||||||
|
result = result.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minRating != null) {
|
||||||
|
result = result.filter((t) => (t.avgCombined ?? 0) >= minRating);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(result);
|
res.json(result);
|
||||||
@@ -213,6 +232,65 @@ router.get("/tools/:id", async (req, res): Promise<void> => {
|
|||||||
res.json(buildToolWithStats(tool, ratings));
|
res.json(buildToolWithStats(tool, ratings));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/tools/:id/rating-history", async (req, res): Promise<void> => {
|
||||||
|
const params = GetToolParams.safeParse(req.params);
|
||||||
|
if (!params.success) {
|
||||||
|
res.status(400).json({ error: params.error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ratings = await db
|
||||||
|
.select({
|
||||||
|
date: ratingsTable.createdAt,
|
||||||
|
usefulness: ratingsTable.usefulness,
|
||||||
|
usability: ratingsTable.usability,
|
||||||
|
})
|
||||||
|
.from(ratingsTable)
|
||||||
|
.where(eq(ratingsTable.toolId, params.data.id))
|
||||||
|
.orderBy(asc(ratingsTable.createdAt));
|
||||||
|
res.json(
|
||||||
|
ratings.map((r) => ({
|
||||||
|
date: r.date,
|
||||||
|
usefulness: r.usefulness,
|
||||||
|
usability: r.usability,
|
||||||
|
combined: (r.usefulness + r.usability) / 2,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/compare", requireFeature("compare"), async (req, res): Promise<void> => {
|
||||||
|
const ids = String(req.query.ids ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((s) => Number(s.trim()))
|
||||||
|
.filter((n) => Number.isInteger(n) && n > 0);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
res.status(400).json({ error: "Provide at least one ids value, e.g. ?ids=1,2,3" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ids.length > 8) {
|
||||||
|
res.status(400).json({ error: "Maximum of 8 tools can be compared" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tools = await db.select().from(toolsTable).where(and(isNull(toolsTable.deletedAt), inArray(toolsTable.id, ids)));
|
||||||
|
if (tools.length === 0) {
|
||||||
|
res.status(404).json({ error: "No tools found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toolIds = tools.map((t) => t.id);
|
||||||
|
const ratings = await db
|
||||||
|
.select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
|
||||||
|
.from(ratingsTable)
|
||||||
|
.where(inArray(ratingsTable.toolId, toolIds));
|
||||||
|
const byTool = new Map<number, { usefulness: number; usability: number }[]>();
|
||||||
|
for (const r of ratings) {
|
||||||
|
const list = byTool.get(r.toolId) ?? [];
|
||||||
|
list.push({ usefulness: r.usefulness, usability: r.usability });
|
||||||
|
byTool.set(r.toolId, list);
|
||||||
|
}
|
||||||
|
const byId = new Map(tools.map((t) => [t.id, t]));
|
||||||
|
const ordered = ids.filter((id) => byId.has(id)).map((id) => buildToolWithStats(byId.get(id)!, byTool.get(id) ?? []));
|
||||||
|
res.json(ordered);
|
||||||
|
});
|
||||||
|
|
||||||
router.patch("/tools/:id", requireAuth, async (req, res): Promise<void> => {
|
router.patch("/tools/:id", requireAuth, async (req, res): Promise<void> => {
|
||||||
const params = UpdateToolParams.safeParse(req.params);
|
const params = UpdateToolParams.safeParse(req.params);
|
||||||
if (!params.success) {
|
if (!params.success) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import bcrypt from "bcryptjs";
|
|||||||
import { db, usersTable } from "@workspace/db";
|
import { db, usersTable } from "@workspace/db";
|
||||||
import { requireAdmin } from "../middleware/auth";
|
import { requireAdmin } from "../middleware/auth";
|
||||||
import { writeAuditLog } from "../lib/audit";
|
import { writeAuditLog } from "../lib/audit";
|
||||||
|
import { passwordRateLimit } from "../lib/rate-limit";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
@@ -23,6 +24,46 @@ const UserUpdateSchema = z.object({
|
|||||||
tier: Tier.optional(),
|
tier: Tier.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const SetPasswordSchema = z.object({
|
||||||
|
password: z.string().min(8),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch("/users/:id/password", requireAdmin, passwordRateLimit, async (req, res): Promise<void> => {
|
||||||
|
const id = parseInt(String(req.params.id), 10);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
res.status(400).json({ error: "Invalid user id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = SetPasswordSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [target] = await db
|
||||||
|
.select({ id: usersTable.id, authProvider: usersTable.authProvider, username: usersTable.username })
|
||||||
|
.from(usersTable)
|
||||||
|
.where(eq(usersTable.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
res.status(404).json({ error: "User not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.authProvider !== "local") {
|
||||||
|
res.status(422).json({ error: "oidc" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(parsed.data.password, 12);
|
||||||
|
await db.update(usersTable).set({ passwordHash }).where(eq(usersTable.id, id));
|
||||||
|
|
||||||
|
await writeAuditLog(req, "user", id, "set_password", { username: target.username });
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||||
const users = await db
|
const users = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -31,6 +72,7 @@ router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
|||||||
email: usersTable.email,
|
email: usersTable.email,
|
||||||
role: usersTable.role,
|
role: usersTable.role,
|
||||||
tier: usersTable.tier,
|
tier: usersTable.tier,
|
||||||
|
authProvider: usersTable.authProvider,
|
||||||
createdAt: usersTable.createdAt,
|
createdAt: usersTable.createdAt,
|
||||||
})
|
})
|
||||||
.from(usersTable)
|
.from(usersTable)
|
||||||
@@ -56,7 +98,7 @@ router.post("/users", requireAdmin, async (req, res): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(parsed.data.password, 10);
|
const passwordHash = await bcrypt.hash(parsed.data.password, 12);
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
.insert(usersTable)
|
.insert(usersTable)
|
||||||
.values({
|
.values({
|
||||||
|
|||||||
@@ -1,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"
|
|
||||||
@@ -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 = "/"
|
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@tailwindcss/vite": "catalog:",
|
"@tailwindcss/vite": "catalog:",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
|
"@tanstack/react-virtual": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"@types/react-dom": "catalog:",
|
"@types/react-dom": "catalog:",
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "catalog:",
|
"framer-motion": "catalog:",
|
||||||
|
"i18next": "^26.3.6",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "catalog:",
|
"lucide-react": "catalog:",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@@ -62,6 +64,7 @@
|
|||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "^9.11.1",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
"react-hook-form": "^7.55.0",
|
"react-hook-form": "^7.55.0",
|
||||||
|
"react-i18next": "^17.0.11",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
"recharts": "^2.15.2",
|
"recharts": "^2.15.2",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Switch, Route, Router as WouterRouter } from "wouter";
|
import { Switch, Route, Router as WouterRouter } from "wouter";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { I18nextProvider } from "react-i18next";
|
||||||
|
import i18n from "@/i18n";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
@@ -13,6 +15,8 @@ import Analytics from "@/pages/analytics";
|
|||||||
import Admin from "@/pages/admin";
|
import Admin from "@/pages/admin";
|
||||||
import Redundancy from "@/pages/redundancy";
|
import Redundancy from "@/pages/redundancy";
|
||||||
import Trash from "@/pages/trash";
|
import Trash from "@/pages/trash";
|
||||||
|
import Compare from "@/pages/compare";
|
||||||
|
import Watchlist from "@/pages/watchlist";
|
||||||
import Login from "@/pages/login";
|
import Login from "@/pages/login";
|
||||||
import NotFound from "@/pages/not-found";
|
import NotFound from "@/pages/not-found";
|
||||||
|
|
||||||
@@ -34,6 +38,8 @@ function Router() {
|
|||||||
<Route path="/tools/new" component={ToolNew} />
|
<Route path="/tools/new" component={ToolNew} />
|
||||||
<Route path="/tools/:id/edit" component={ToolEdit} />
|
<Route path="/tools/:id/edit" component={ToolEdit} />
|
||||||
<Route path="/tools/:id" component={ToolDetail} />
|
<Route path="/tools/:id" component={ToolDetail} />
|
||||||
|
<Route path="/compare" component={Compare} />
|
||||||
|
<Route path="/watchlist" component={Watchlist} />
|
||||||
<Route path="/analytics" component={Analytics} />
|
<Route path="/analytics" component={Analytics} />
|
||||||
<Route path="/admin" component={Admin} />
|
<Route path="/admin" component={Admin} />
|
||||||
<Route path="/admin/redundancy" component={Redundancy} />
|
<Route path="/admin/redundancy" component={Redundancy} />
|
||||||
@@ -45,14 +51,16 @@ function Router() {
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<TooltipProvider>
|
<QueryClientProvider client={queryClient}>
|
||||||
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}>
|
<TooltipProvider>
|
||||||
<Router />
|
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}>
|
||||||
</WouterRouter>
|
<Router />
|
||||||
<Toaster />
|
</WouterRouter>
|
||||||
</TooltipProvider>
|
<Toaster />
|
||||||
</QueryClientProvider>
|
</TooltipProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</I18nextProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,24 @@
|
|||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Kbd } from "@/components/ui/kbd";
|
||||||
|
import { requestOpenCommandPalette } from "@/components/command-palette";
|
||||||
|
|
||||||
|
export function HeaderSearch() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden md:inline-flex justify-start gap-2 w-56 text-muted-foreground"
|
||||||
|
onClick={requestOpenCommandPalette}
|
||||||
|
data-testid="button-header-search"
|
||||||
|
>
|
||||||
|
<Search className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="flex-1 text-left text-sm truncate">{t("nav.search")}</span>
|
||||||
|
<Kbd className="hidden lg:inline-flex">
|
||||||
|
<span>⌘</span>K
|
||||||
|
</Kbd>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Languages, Check } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { SUPPORTED_LANGUAGES, setStoredLanguage, type LanguageCode } from "@/i18n";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function LanguageSwitcher() {
|
||||||
|
const { i18n, t } = useTranslation();
|
||||||
|
const current = (i18n.language?.split("-")[0] ?? "en") as LanguageCode;
|
||||||
|
|
||||||
|
function change(lang: LanguageCode) {
|
||||||
|
setStoredLanguage(lang);
|
||||||
|
void i18n.changeLanguage(lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="gap-1.5" aria-label={t("common.language")} data-testid="button-language">
|
||||||
|
<Languages className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline text-xs uppercase font-semibold">{current}</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>{t("common.language")}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{SUPPORTED_LANGUAGES.map(({ code, label }) => (
|
||||||
|
<DropdownMenuItem key={code} onClick={() => change(code)} className="justify-between">
|
||||||
|
{label}
|
||||||
|
{current === code && <Check className="w-4 h-4 text-primary" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,156 +1,195 @@
|
|||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, User, ShieldCheck, AlertTriangle, Trash2 } from "lucide-react";
|
import { LayoutDashboard, Wrench, BarChart3, LogIn, LogOut, ShieldCheck, AlertTriangle, Search } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
|
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
import { CommandPalette, requestOpenCommandPalette } from "@/components/command-palette";
|
||||||
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
|
import { UserMenu } from "@/components/user-menu";
|
||||||
|
import { Breadcrumbs } from "@/components/breadcrumbs";
|
||||||
|
import { HeaderSearch } from "@/components/header-search";
|
||||||
|
import {
|
||||||
|
SidebarProvider,
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarRail,
|
||||||
|
SidebarTrigger,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
const { user, isLoading, isAuthenticated, isAdmin, isLocalMode, tier, hasFeature, login, logout } = useAuth();
|
const { t } = useTranslation();
|
||||||
|
const { isAuthenticated, isLoading, isAdmin, login, logout } = useAuth();
|
||||||
const { data: version } = useGetVersion({
|
const { data: version } = useGetVersion({
|
||||||
query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false },
|
query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const links = [
|
const mainLinks = [
|
||||||
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
{ href: "/", label: t("nav.home"), icon: LayoutDashboard },
|
||||||
{ href: "/tools", label: "Browse Tools", icon: Wrench },
|
{ href: "/tools", label: t("nav.browseTools"), icon: Wrench },
|
||||||
{ href: "/tools/new", label: "Add Tool", icon: PlusCircle },
|
{ href: "/analytics", label: t("nav.analytics"), icon: BarChart3 },
|
||||||
{ href: "/analytics", label: "Analytics", icon: BarChart3 },
|
|
||||||
...(hasFeature("trash") ? [{ href: "/trash", label: "Trash", icon: Trash2 }] : []),
|
|
||||||
...(isAdmin ? [{ href: "/admin", label: "Admin", icon: ShieldCheck }] : []),
|
|
||||||
...(isAdmin ? [{ href: "/admin/redundancy", label: "Redundancy", icon: AlertTriangle }] : []),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const activeLink = links.find(
|
const adminLinks = [
|
||||||
(link) => location === link.href || (link.href !== "/" && location.startsWith(link.href)),
|
{ href: "/admin", label: t("nav.admin"), icon: ShieldCheck },
|
||||||
);
|
{ href: "/admin/redundancy", label: t("nav.redundancy"), icon: AlertTriangle },
|
||||||
|
];
|
||||||
|
|
||||||
|
function isActive(href: string) {
|
||||||
|
return location === href || (href !== "/" && location.startsWith(href));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-background text-foreground">
|
<SidebarProvider>
|
||||||
<aside className="w-64 border-r bg-card flex flex-col hidden md:flex">
|
<CommandPalette />
|
||||||
<div className="p-6 border-b">
|
<Sidebar collapsible="icon">
|
||||||
|
<SidebarHeader className="flex h-14 items-center justify-between border-b px-4">
|
||||||
<Link href="/" className="flex items-center gap-2 text-primary font-bold text-xl">
|
<Link href="/" className="flex items-center gap-2 text-primary font-bold text-xl">
|
||||||
<Wrench className="w-6 h-6" />
|
<Wrench className="w-6 h-6 shrink-0" />
|
||||||
<span>toolr</span>
|
<span className="group-data-[collapsible=icon]:hidden">toolr</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</SidebarHeader>
|
||||||
<nav className="flex-1 p-4 space-y-2">
|
<SidebarContent>
|
||||||
{links.map((link) => {
|
<SidebarGroup>
|
||||||
const isActive = location === link.href || (link.href !== "/" && location.startsWith(link.href));
|
<SidebarGroupContent>
|
||||||
const Icon = link.icon;
|
<SidebarMenu>
|
||||||
return (
|
{mainLinks.map((link) => {
|
||||||
<Link
|
const Icon = link.icon;
|
||||||
key={link.href}
|
return (
|
||||||
href={link.href}
|
<SidebarMenuItem key={link.href}>
|
||||||
className={`flex items-center gap-3 px-3 py-2 rounded-md transition-colors ${isActive ? "bg-primary text-primary-foreground font-medium" : "text-muted-foreground hover:bg-muted hover:text-foreground"}`}
|
<SidebarMenuButton asChild isActive={isActive(link.href)} tooltip={link.label}>
|
||||||
>
|
<Link href={link.href}>
|
||||||
<Icon className="w-5 h-5" />
|
<Icon />
|
||||||
{link.label}
|
<span>{link.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
</SidebarMenuButton>
|
||||||
})}
|
</SidebarMenuItem>
|
||||||
</nav>
|
);
|
||||||
|
})}
|
||||||
<div className="p-4 border-t">
|
</SidebarMenu>
|
||||||
{isLoading ? (
|
</SidebarGroupContent>
|
||||||
<div className="flex items-center gap-3 px-3 py-2">
|
</SidebarGroup>
|
||||||
<Skeleton className="w-8 h-8 rounded-full" />
|
{isAdmin && (
|
||||||
<Skeleton className="h-4 w-24" />
|
<SidebarGroup>
|
||||||
</div>
|
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">
|
||||||
) : isAuthenticated && user ? (
|
{t("nav.admin")}
|
||||||
<div className="space-y-2">
|
</SidebarGroupLabel>
|
||||||
<div className="flex items-center gap-3 px-3 py-2 rounded-md bg-muted/50">
|
<SidebarGroupContent>
|
||||||
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center shrink-0">
|
<SidebarMenu>
|
||||||
<User className="w-4 h-4 text-primary" />
|
{adminLinks.map((link) => {
|
||||||
</div>
|
const Icon = link.icon;
|
||||||
<div className="min-w-0">
|
return (
|
||||||
<p className="text-sm font-medium truncate text-foreground">
|
<SidebarMenuItem key={link.href}>
|
||||||
{user.name || user.preferredUsername || "User"}
|
<SidebarMenuButton asChild isActive={isActive(link.href)} tooltip={link.label}>
|
||||||
</p>
|
<Link href={link.href}>
|
||||||
<div className="flex items-center gap-1.5 mt-0.5">
|
<Icon />
|
||||||
<span className="text-[10px] uppercase tracking-wider font-semibold px-1.5 py-0.5 rounded-sm bg-primary/10 text-primary">{tier}</span>
|
<span>{link.label}</span>
|
||||||
{user.email && <span className="text-xs text-muted-foreground truncate">{user.email}</span>}
|
</Link>
|
||||||
</div>
|
</SidebarMenuButton>
|
||||||
</div>
|
</SidebarMenuItem>
|
||||||
</div>
|
);
|
||||||
<Button
|
})}
|
||||||
variant="ghost"
|
</SidebarMenu>
|
||||||
size="sm"
|
</SidebarGroupContent>
|
||||||
className="w-full justify-start gap-2 text-muted-foreground hover:text-destructive"
|
</SidebarGroup>
|
||||||
onClick={logout}
|
|
||||||
data-testid="button-logout"
|
|
||||||
>
|
|
||||||
<LogOut className="w-4 h-4" />
|
|
||||||
Sign out
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-full gap-2"
|
|
||||||
onClick={() => login(location)}
|
|
||||||
data-testid="button-login"
|
|
||||||
>
|
|
||||||
<LogIn className="w-4 h-4" />
|
|
||||||
{isLocalMode ? "Sign in" : "Sign in with Keycloak"}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</SidebarContent>
|
||||||
<div className="px-4 py-2.5 border-t flex items-center justify-between text-[11px] text-muted-foreground">
|
<SidebarFooter className="gap-2">
|
||||||
<span className="font-medium">toolr</span>
|
<UserMenu />
|
||||||
{version?.commitSha ? (
|
<div className="border-t pt-2 flex items-center justify-between text-[11px] text-muted-foreground px-2 group-data-[collapsible=icon]:justify-center">
|
||||||
<a
|
<span className="font-medium group-data-[collapsible=icon]:hidden">toolr</span>
|
||||||
href={`https://git.kubebase.de/admin/tool-evaluator/commit/${version.commitSha}`}
|
{version?.commitSha ? (
|
||||||
target="_blank"
|
<a
|
||||||
rel="noreferrer"
|
href={`https://git.kubebase.de/admin/tool-evaluator/commit/${version.commitSha}`}
|
||||||
className="font-mono hover:underline"
|
target="_blank"
|
||||||
title={version.commitSha}
|
rel="noreferrer"
|
||||||
>
|
className="font-mono hover:underline truncate group-data-[collapsible=icon]:hidden"
|
||||||
{version.version && version.version !== "dev"
|
title={version.commitSha}
|
||||||
? version.version
|
>
|
||||||
: `sha-${version.commitSha.slice(0, 7)}`}
|
{version.version && version.version !== "dev"
|
||||||
</a>
|
? version.version
|
||||||
) : version?.version && version.version !== "dev" ? (
|
: `sha-${version.commitSha.slice(0, 7)}`}
|
||||||
<span className="font-mono">{version.version}</span>
|
</a>
|
||||||
) : null}
|
) : version?.version && version.version !== "dev" ? (
|
||||||
</div>
|
<span className="font-mono group-data-[collapsible=icon]:hidden">{version.version}</span>
|
||||||
</aside>
|
) : null}
|
||||||
|
</div>
|
||||||
|
</SidebarFooter>
|
||||||
|
<SidebarRail />
|
||||||
|
</Sidebar>
|
||||||
|
|
||||||
<main className="flex-1 flex flex-col min-w-0">
|
<SidebarInset>
|
||||||
<header className="hidden md:flex items-center justify-between border-b bg-card px-6 py-3">
|
<header className="flex items-center justify-between gap-2 border-b bg-card px-3 md:px-5 h-14 shrink-0">
|
||||||
<h1 className="text-sm font-semibold text-muted-foreground">
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
{activeLink?.label ?? "toolr"}
|
<SidebarTrigger className="h-8 w-8 shrink-0" data-testid="button-sidebar-toggle" />
|
||||||
</h1>
|
<Link
|
||||||
<ThemeToggle />
|
href="/"
|
||||||
</header>
|
className="md:hidden flex items-center gap-2 text-primary font-bold text-lg shrink-0 pl-1"
|
||||||
<header className="md:hidden border-b p-4 flex items-center justify-between bg-card">
|
>
|
||||||
<Link href="/" className="flex items-center gap-2 text-primary font-bold text-lg">
|
<Wrench className="w-5 h-5" />
|
||||||
<Wrench className="w-5 h-5" />
|
<span>toolr</span>
|
||||||
<span>toolr</span>
|
</Link>
|
||||||
</Link>
|
<div className="hidden md:block min-w-0 pl-1">
|
||||||
<div className="flex items-center gap-1">
|
<Breadcrumbs />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<HeaderSearch />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 md:hidden"
|
||||||
|
onClick={requestOpenCommandPalette}
|
||||||
|
aria-label={t("nav.search")}
|
||||||
|
data-testid="button-search-mobile"
|
||||||
|
>
|
||||||
|
<Search className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<LanguageSwitcher />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
isAuthenticated ? (
|
<div className="md:hidden">
|
||||||
<Button variant="ghost" size="sm" onClick={logout} data-testid="button-logout-mobile">
|
{isAuthenticated ? (
|
||||||
<LogOut className="w-4 h-4" />
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
) : (
|
size="icon"
|
||||||
<Button variant="outline" size="sm" onClick={() => login(location)} data-testid="button-login-mobile">
|
className="h-9 w-9"
|
||||||
<LogIn className="w-4 h-4 mr-1" />
|
onClick={logout}
|
||||||
Sign in
|
aria-label={t("auth.signOut")}
|
||||||
</Button>
|
data-testid="button-logout-mobile"
|
||||||
)
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => login(location)}
|
||||||
|
data-testid="button-login-mobile"
|
||||||
|
>
|
||||||
|
<LogIn className="w-4 h-4 mr-1" />
|
||||||
|
{t("auth.signIn")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="flex-1 p-6 md:p-8 overflow-auto">
|
<div className="flex-1 overflow-auto p-4 md:p-6 lg:p-8">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</SidebarInset>
|
||||||
</div>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function num(v: number | string | null | undefined): number | null {
|
||||||
|
if (v == null || v === "") return null;
|
||||||
|
const n = typeof v === "string" ? Number(v) : v;
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(v: number | string | null | undefined): number {
|
||||||
|
const n = num(v);
|
||||||
|
if (n == null) return 0;
|
||||||
|
return Math.max(0, Math.min(5, n)) / 5 * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MiniBars({
|
||||||
|
usefulness,
|
||||||
|
usability,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
usefulness: number | string | null | undefined;
|
||||||
|
usability: number | string | null | undefined;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const rows = [
|
||||||
|
{ label: "Usefulness", value: usefulness },
|
||||||
|
{ label: "Usability", value: usability },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-1 w-full", className)}>
|
||||||
|
{rows.map(({ label, value }) => {
|
||||||
|
const n = num(value);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
title={`${label}: ${n != null ? n.toFixed(1) : "N/A"} / 5`}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] uppercase tracking-wide text-muted-foreground w-[3.5rem] shrink-0">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary/70"
|
||||||
|
style={{ width: `${pct(value)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground tabular-nums w-7 text-right shrink-0">
|
||||||
|
{n != null ? n.toFixed(1) : "–"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MiniBarStack({
|
||||||
|
usefulness,
|
||||||
|
usability,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
usefulness: number | string | null | undefined;
|
||||||
|
usability: number | string | null | undefined;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const u = num(usefulness);
|
||||||
|
const a = num(usability);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("h-1.5 w-16 rounded-full bg-muted overflow-hidden flex", className)}
|
||||||
|
title={`Usefulness ${u != null ? u.toFixed(1) : "N/A"} / Usability ${
|
||||||
|
a != null ? a.toFixed(1) : "N/A"
|
||||||
|
} (of 5)`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary/80"
|
||||||
|
style={{ width: `${pct(usefulness)}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary/40"
|
||||||
|
style={{ width: `${pct(usability)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Link } from "wouter";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Star, MessageSquare, Wrench, ChevronRight, Scale } from "lucide-react";
|
||||||
|
import { ToolWithStats } from "@workspace/api-client-react";
|
||||||
|
import { MiniBarStack } from "@/components/mini-bars";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function ToolCardWide({
|
||||||
|
tool,
|
||||||
|
density,
|
||||||
|
compare,
|
||||||
|
}: {
|
||||||
|
tool: ToolWithStats;
|
||||||
|
density: "cozy" | "compact";
|
||||||
|
compare?: { selected: boolean; onToggle: () => void };
|
||||||
|
}) {
|
||||||
|
const compact = density === "compact";
|
||||||
|
return (
|
||||||
|
<Card className="hover-elevate transition-all cursor-pointer hover:border-primary/50">
|
||||||
|
<Link href={`/tools/${tool.id}`} className="flex items-center gap-4 p-4">
|
||||||
|
<div className="shrink-0 w-10 h-10 rounded-md border bg-muted flex items-center justify-center overflow-hidden">
|
||||||
|
{tool.iconUrl ? (
|
||||||
|
<img
|
||||||
|
src={tool.iconUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-contain p-0.5"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Wrench className="w-5 h-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-semibold truncate">{tool.name}</span>
|
||||||
|
<Badge variant="outline" className="text-xs shrink-0">
|
||||||
|
{tool.category}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{!compact && <p className="text-sm text-muted-foreground line-clamp-2">{tool.description}</p>}
|
||||||
|
{!compact && tool.tags && tool.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||||
|
{tool.tags.slice(0, 3).map((tag) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="text-xs px-1.5 py-0">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{tool.tags.length > 3 && (
|
||||||
|
<Badge variant="secondary" className="text-xs px-1.5 py-0 text-muted-foreground">
|
||||||
|
+{tool.tags.length - 3}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 flex flex-col items-end gap-2 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Star className="w-4 h-4 fill-primary text-primary" />
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{tool.avgCombined ? tool.avgCombined.toFixed(1) : "N/A"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<MessageSquare className="w-4 h-4" />
|
||||||
|
<span>{tool.ratingCount}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
{!compact && <MiniBarStack usefulness={tool.avgUsefulness} usability={tool.avgUsability} />}
|
||||||
|
{compare && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
compare.onToggle();
|
||||||
|
}}
|
||||||
|
title={compare.selected ? "Remove from compare" : "Add to compare"}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 w-7 h-7 rounded-md border flex items-center justify-center transition-colors",
|
||||||
|
compare.selected
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Scale className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,20 @@
|
|||||||
import { ToolWithStats } from "@workspace/api-client-react";
|
import { ToolWithStats } from "@workspace/api-client-react";
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card";
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Star, MessageSquare, Wrench } from "lucide-react";
|
import { Star, MessageSquare, Wrench, Scale, Bookmark } from "lucide-react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
|
import { MiniBars } from "@/components/mini-bars";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function ToolCard({ tool }: { tool: ToolWithStats }) {
|
export function ToolCard({
|
||||||
|
tool,
|
||||||
|
compare,
|
||||||
|
watchlist,
|
||||||
|
}: {
|
||||||
|
tool: ToolWithStats;
|
||||||
|
compare?: { selected: boolean; onToggle: () => void };
|
||||||
|
watchlist?: { watched: boolean; onToggle: () => void };
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card className="hover-elevate transition-all flex flex-col h-full cursor-pointer hover:border-primary/50">
|
<Card className="hover-elevate transition-all flex flex-col h-full cursor-pointer hover:border-primary/50">
|
||||||
<Link href={`/tools/${tool.id}`} className="flex flex-col h-full p-0 m-0">
|
<Link href={`/tools/${tool.id}`} className="flex flex-col h-full p-0 m-0">
|
||||||
@@ -47,19 +57,62 @@ export function ToolCard({ tool }: { tool: ToolWithStats }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="pt-0 flex justify-between items-center text-sm text-muted-foreground border-t p-4 mt-auto">
|
<CardFooter className="pt-0 flex flex-col gap-3 border-t p-4 mt-auto">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-4">
|
||||||
<Star className="w-4 h-4 fill-primary text-primary" />
|
<div className="flex items-center gap-1">
|
||||||
<span className="font-medium text-foreground">
|
<Star className="w-4 h-4 fill-primary text-primary" />
|
||||||
{tool.avgCombined ? tool.avgCombined.toFixed(1) : "N/A"}
|
<span className="font-medium text-foreground">
|
||||||
</span>
|
{tool.avgCombined ? tool.avgCombined.toFixed(1) : "N/A"}
|
||||||
</div>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
</div>
|
||||||
<MessageSquare className="w-4 h-4" />
|
<div className="flex items-center gap-1">
|
||||||
<span>{tool.ratingCount}</span>
|
<MessageSquare className="w-4 h-4" />
|
||||||
|
<span>{tool.ratingCount}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={cn("items-center gap-3", (compare || watchlist) ? "flex" : "")}>
|
||||||
|
<MiniBars usefulness={tool.avgUsefulness} usability={tool.avgUsability} className={(compare || watchlist) ? "flex-1" : undefined} />
|
||||||
|
{watchlist && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
watchlist.onToggle();
|
||||||
|
}}
|
||||||
|
title={watchlist.watched ? "Remove from watchlist" : "Add to watchlist"}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 w-8 h-8 rounded-md border flex items-center justify-center transition-colors",
|
||||||
|
watchlist.watched
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Bookmark className={cn("w-4 h-4", watchlist.watched && "fill-current")} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{compare && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
compare.onToggle();
|
||||||
|
}}
|
||||||
|
title={compare.selected ? "Remove from compare" : "Add to compare"}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 w-8 h-8 rounded-md border flex items-center justify-center transition-colors",
|
||||||
|
compare.selected
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Scale className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Link>
|
</Link>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -53,6 +53,15 @@ function useSidebar() {
|
|||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSidebarCookie(): boolean | null {
|
||||||
|
if (typeof document === "undefined") return null;
|
||||||
|
const match = document.cookie.match(
|
||||||
|
new RegExp(`(?:^|; )${SIDEBAR_COOKIE_NAME}=([^;]*)`)
|
||||||
|
);
|
||||||
|
if (!match) return null;
|
||||||
|
return match[1] === "true";
|
||||||
|
}
|
||||||
|
|
||||||
function SidebarProvider({
|
function SidebarProvider({
|
||||||
defaultOpen = true,
|
defaultOpen = true,
|
||||||
open: openProp,
|
open: openProp,
|
||||||
@@ -71,7 +80,10 @@ function SidebarProvider({
|
|||||||
|
|
||||||
// This is the internal state of the sidebar.
|
// This is the internal state of the sidebar.
|
||||||
// We use openProp and setOpenProp for control from outside the component.
|
// We use openProp and setOpenProp for control from outside the component.
|
||||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
const [_open, _setOpen] = React.useState(() => {
|
||||||
|
const stored = getSidebarCookie()
|
||||||
|
return stored !== null ? stored : defaultOpen
|
||||||
|
})
|
||||||
const open = openProp ?? _open
|
const open = openProp ?? _open
|
||||||
const setOpen = React.useCallback(
|
const setOpen = React.useCallback(
|
||||||
(value: boolean | ((value: boolean) => boolean)) => {
|
(value: boolean | ((value: boolean) => boolean)) => {
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
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 { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
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>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
data-testid="input-current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("auth.newPassword")}</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="min. 8 characters"
|
||||||
|
data-testid="input-new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("auth.confirmPassword")}</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
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 < 8) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import i18n from "i18next";
|
||||||
|
import { initReactI18next } from "react-i18next";
|
||||||
|
import en from "./locales/en.json";
|
||||||
|
import de from "./locales/de.json";
|
||||||
|
|
||||||
|
export const SUPPORTED_LANGUAGES = [
|
||||||
|
{ code: "en", label: "English" },
|
||||||
|
{ code: "de", label: "Deutsch" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]["code"];
|
||||||
|
|
||||||
|
const STORAGE_KEY = "toolr-language";
|
||||||
|
|
||||||
|
export function getStoredLanguage(): LanguageCode {
|
||||||
|
if (typeof window === "undefined") return "en";
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored === "en" || stored === "de") return stored;
|
||||||
|
const nav = window.navigator.language?.toLowerCase() ?? "";
|
||||||
|
return nav.startsWith("de") ? "de" : "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredLanguage(lang: LanguageCode) {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
void i18n.use(initReactI18next).init({
|
||||||
|
resources: {
|
||||||
|
en: { translation: en },
|
||||||
|
de: { translation: de },
|
||||||
|
},
|
||||||
|
lng: getStoredLanguage(),
|
||||||
|
fallbackLng: "en",
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"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 (A–Z)",
|
||||||
|
"sortNameDesc": "Name (Z–A)",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"navigate": "Navigation",
|
||||||
|
"recent": "Zuletzt besucht",
|
||||||
|
"tools": "Tools",
|
||||||
|
"noResults": "Keine Tools für „{{query}}“ gefunden.",
|
||||||
|
"startTyping": "Beginne zu tippen, um Tools zu suchen."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"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 (A–Z)",
|
||||||
|
"sortNameDesc": "Name (Z–A)",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"navigate": "Navigate",
|
||||||
|
"recent": "Recent",
|
||||||
|
"tools": "Tools",
|
||||||
|
"noResults": "No tools found for \"{{query}}\".",
|
||||||
|
"startTyping": "Start typing to search tools."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
const STORAGE_KEY = "toolrate-recent";
|
||||||
|
const MAX_ITEMS = 5;
|
||||||
|
|
||||||
|
export type RecentTool = { id: number; name: string };
|
||||||
|
|
||||||
|
export function getRecentTools(): RecentTool[] {
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed
|
||||||
|
.filter(
|
||||||
|
(item): item is RecentTool =>
|
||||||
|
!!item && typeof item.id === "number" && typeof item.name === "string",
|
||||||
|
)
|
||||||
|
.slice(0, MAX_ITEMS);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordRecentTool(id: number, name: string) {
|
||||||
|
if (typeof window === "undefined" || !Number.isFinite(id) || !name) return;
|
||||||
|
try {
|
||||||
|
const next = [{ id, name }, ...getRecentTools().filter((t) => t.id !== id)].slice(0, MAX_ITEMS);
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
useCreateUser,
|
useCreateUser,
|
||||||
useUpdateUser,
|
useUpdateUser,
|
||||||
useDeleteUser,
|
useDeleteUser,
|
||||||
|
useSetUserPassword,
|
||||||
useListAuditLogs,
|
useListAuditLogs,
|
||||||
useGetVersion,
|
useGetVersion,
|
||||||
getListUsersQueryKey,
|
getListUsersQueryKey,
|
||||||
@@ -35,7 +36,8 @@ export default function Admin() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editUser, setEditUser] = useState<{ id: number; username: string; role: string; tier: string } | null>(null);
|
const [editUser, setEditUser] = useState<{ id: number; username: string; role: string; tier: string; authProvider?: string } | null>(null);
|
||||||
|
const [editPassword, setEditPassword] = useState("");
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{ id: number; username: string } | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<{ id: number; username: string } | null>(null);
|
||||||
|
|
||||||
const [newUsername, setNewUsername] = useState("");
|
const [newUsername, setNewUsername] = useState("");
|
||||||
@@ -58,6 +60,7 @@ export default function Admin() {
|
|||||||
const createUser = useCreateUser();
|
const createUser = useCreateUser();
|
||||||
const updateUser = useUpdateUser();
|
const updateUser = useUpdateUser();
|
||||||
const deleteUser = useDeleteUser();
|
const deleteUser = useDeleteUser();
|
||||||
|
const setUserPassword = useSetUserPassword();
|
||||||
|
|
||||||
if (!authLoading && !isAdmin) {
|
if (!authLoading && !isAdmin) {
|
||||||
return (
|
return (
|
||||||
@@ -110,6 +113,26 @@ export default function Admin() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSetUserPassword = () => {
|
||||||
|
if (!editUser || !editPassword) return;
|
||||||
|
if (editPassword.length < 8) {
|
||||||
|
toast({ title: "Password too short", description: "Minimum 8 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 = () => {
|
const handleDeleteUser = () => {
|
||||||
if (!deleteConfirm) return;
|
if (!deleteConfirm) return;
|
||||||
deleteUser.mutate(
|
deleteUser.mutate(
|
||||||
@@ -206,7 +229,7 @@ export default function Admin() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
onClick={() => setEditUser({ id: u.id, username: u.username, role: u.role, tier: u.tier ?? "free" })}
|
onClick={() => setEditUser({ id: u.id, username: u.username, role: u.role, tier: u.tier ?? "free", authProvider: u.authProvider })}
|
||||||
>
|
>
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -385,7 +408,7 @@ export default function Admin() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={!!editUser} onOpenChange={(open) => !open && setEditUser(null)}>
|
<Dialog open={!!editUser} onOpenChange={(open) => { if (!open) { setEditUser(null); setEditPassword(""); } }}>
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit User — {editUser?.username}</DialogTitle>
|
<DialogTitle>Edit User — {editUser?.username}</DialogTitle>
|
||||||
@@ -422,15 +445,41 @@ export default function Admin() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
{editUser?.authProvider !== "oidc" ? (
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
<Label>Set Password</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={editPassword}
|
||||||
|
onChange={(e) => setEditPassword(e.target.value)}
|
||||||
|
placeholder="min. 8 characters"
|
||||||
|
data-testid="input-set-password"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Resets the user's password immediately.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter className="flex-col sm:flex-row sm:justify-end gap-2">
|
||||||
<Button variant="outline" onClick={() => setEditUser(null)}>Cancel</Button>
|
<Button variant="outline" onClick={() => { setEditUser(null); setEditPassword(""); }}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleUpdateUser}
|
onClick={handleUpdateUser}
|
||||||
disabled={updateUser.isPending}
|
disabled={updateUser.isPending}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
{editUser?.authProvider !== "oidc" && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleSetUserPassword}
|
||||||
|
disabled={setUserPassword.isPending || !editPassword}
|
||||||
|
>
|
||||||
|
{setUserPassword.isPending ? "Setting…" : "Set Password"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
useListCompareTools,
|
||||||
|
getListCompareToolsQueryKey,
|
||||||
|
type ToolWithStats,
|
||||||
|
} from "@workspace/api-client-react";
|
||||||
|
import { Link, useSearch } from "wouter";
|
||||||
|
import { Layout } from "@/components/layout";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { RatingStars } from "@/components/rating-stars";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { ShieldAlert, Trophy, Scale } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
function fmt(v: number | null | undefined): string {
|
||||||
|
return v != null ? v.toFixed(1) : "–";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Compare() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { hasFeature, isLoading: authLoading } = useAuth();
|
||||||
|
const search = useSearch();
|
||||||
|
|
||||||
|
const params = useMemo(() => {
|
||||||
|
const ids = new URLSearchParams(search)
|
||||||
|
.get("ids")
|
||||||
|
?.split(",")
|
||||||
|
.map((s) => Number(s.trim()))
|
||||||
|
.filter((n) => Number.isInteger(n) && n > 0);
|
||||||
|
return ids && ids.length > 0 ? { ids: ids.join(",") } : undefined;
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const canCompare = hasFeature("compare");
|
||||||
|
|
||||||
|
const { data: tools, isLoading: loading } = useListCompareTools(
|
||||||
|
params ?? { ids: "" },
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
queryKey: getListCompareToolsQueryKey(params ?? { ids: "" }),
|
||||||
|
enabled: !!params && canCompare,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!authLoading && !canCompare) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||||
|
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
|
||||||
|
<h2 className="text-2xl font-bold">{t("compare.requiresPremium")}</h2>
|
||||||
|
<p className="text-muted-foreground">{t("compare.requiresPremiumSub")}</p>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href="/tools">{t("common.backToTools")}</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = tools ?? [];
|
||||||
|
if (list.length === 0) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||||
|
<Scale className="w-12 h-12 text-muted-foreground" />
|
||||||
|
<h2 className="text-2xl font-bold">{t("compare.nothingToCompare")}</h2>
|
||||||
|
<p className="text-muted-foreground">{t("compare.nothingToCompareSub")}</p>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href="/tools">{t("common.browseTools")}</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bestRating = Math.max(...list.map((t) => t.avgCombined ?? 0));
|
||||||
|
const bestUsefulness = Math.max(...list.map((t) => t.avgUsefulness ?? 0));
|
||||||
|
const bestUsability = Math.max(...list.map((t) => t.avgUsability ?? 0));
|
||||||
|
const bestReviews = Math.max(...list.map((t) => t.ratingCount));
|
||||||
|
|
||||||
|
const isBest = (value: number | null, best: number) =>
|
||||||
|
value != null && value > 0 && value >= best && value === best;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="space-y-6 pb-10">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("compare.title")}</h1>
|
||||||
|
<p className="text-muted-foreground">{t("compare.subtitle", { count: list.length })}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("compare.sideBySide")}</CardTitle>
|
||||||
|
<CardDescription>{t("compare.bestValue")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-44">{t("compare.attribute")}</TableHead>
|
||||||
|
{list.map((t) => (
|
||||||
|
<TableHead key={t.id} className="min-w-[12rem]">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Link to={`/tools/${t.id}`} className="font-semibold hover:text-primary hover:underline">
|
||||||
|
{t.name}
|
||||||
|
</Link>
|
||||||
|
<Badge variant="outline" className="w-fit text-[10px]">{t.category}</Badge>
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-medium">{t("compare.rating")}</TableCell>
|
||||||
|
{list.map((t) => (
|
||||||
|
<TableCell key={t.id} className={cn(isBest(t.avgCombined, bestRating) && "bg-primary/5")}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RatingStars value={t.avgCombined ?? 0} size="sm" />
|
||||||
|
<span className="font-semibold tabular-nums">{fmt(t.avgCombined)}/5</span>
|
||||||
|
{isBest(t.avgCombined, bestRating) && <Trophy className="w-3.5 h-3.5 text-primary" />}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-medium">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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,11 +8,13 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/com
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Layout } from "@/components/layout";
|
import { Layout } from "@/components/layout";
|
||||||
import { ToolCard } from "@/components/tool-card";
|
import { ToolCard } from "@/components/tool-card";
|
||||||
import { Star, Wrench, MessageSquare, ArrowRight } from "lucide-react";
|
import { Star, Wrench, MessageSquare, ArrowRight, Plus } from "lucide-react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: summary, isLoading: loadingSummary } = useGetAnalyticsSummary();
|
const { data: summary, isLoading: loadingSummary } = useGetAnalyticsSummary();
|
||||||
const { data: topTools, isLoading: loadingTopTools } = useGetTopTools({ limit: 4, metric: GetTopToolsMetric.combined });
|
const { data: topTools, isLoading: loadingTopTools } = useGetTopTools({ limit: 4, metric: GetTopToolsMetric.combined });
|
||||||
const { data: recentTools, isLoading: loadingRecent } = useListTools({ sort: "newest" });
|
const { data: recentTools, isLoading: loadingRecent } = useListTools({ sort: "newest" });
|
||||||
@@ -20,9 +22,17 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-8 pb-8">
|
<div className="space-y-8 pb-8">
|
||||||
<div>
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4">
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Welcome to toolr</h1>
|
<div>
|
||||||
<p className="text-muted-foreground">The community hub where engineers honestly rate the tools they use daily.</p>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("home.welcome")}</h1>
|
||||||
|
<p className="text-muted-foreground">{t("home.tagline")}</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/tools/new">
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
{t("browse.addTool")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Banner */}
|
{/* Stats Banner */}
|
||||||
@@ -33,7 +43,7 @@ export default function Home() {
|
|||||||
<Wrench className="w-6 h-6" />
|
<Wrench className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground mb-1">Total Tools</p>
|
<p className="text-sm font-medium text-muted-foreground mb-1">{t("home.totalTools")}</p>
|
||||||
{loadingSummary ? (
|
{loadingSummary ? (
|
||||||
<Skeleton className="h-8 w-16" />
|
<Skeleton className="h-8 w-16" />
|
||||||
) : (
|
) : (
|
||||||
@@ -49,7 +59,7 @@ export default function Home() {
|
|||||||
<MessageSquare className="w-6 h-6" />
|
<MessageSquare className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground mb-1">Total Ratings</p>
|
<p className="text-sm font-medium text-muted-foreground mb-1">{t("home.totalRatings")}</p>
|
||||||
{loadingSummary ? (
|
{loadingSummary ? (
|
||||||
<Skeleton className="h-8 w-16" />
|
<Skeleton className="h-8 w-16" />
|
||||||
) : (
|
) : (
|
||||||
@@ -65,7 +75,7 @@ export default function Home() {
|
|||||||
<Star className="w-6 h-6" />
|
<Star className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-muted-foreground mb-1">Avg Rating</p>
|
<p className="text-sm font-medium text-muted-foreground mb-1">{t("home.avgRating")}</p>
|
||||||
{loadingSummary ? (
|
{loadingSummary ? (
|
||||||
<Skeleton className="h-8 w-16" />
|
<Skeleton className="h-8 w-16" />
|
||||||
) : (
|
) : (
|
||||||
@@ -80,12 +90,12 @@ export default function Home() {
|
|||||||
<section>
|
<section>
|
||||||
<div className="flex justify-between items-end mb-4">
|
<div className="flex justify-between items-end mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight mb-1">Top Rated Tools</h2>
|
<h2 className="text-2xl font-bold tracking-tight mb-1">{t("home.topRated")}</h2>
|
||||||
<p className="text-muted-foreground text-sm">Highest combined usefulness and usability.</p>
|
<p className="text-muted-foreground text-sm">{t("home.topRatedSub")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" className="text-primary hidden sm:flex" asChild>
|
<Button variant="ghost" className="text-primary hidden sm:flex" asChild>
|
||||||
<Link href="/tools?sort=top_rated">
|
<Link href="/tools?sort=top_rated">
|
||||||
View all <ArrowRight className="w-4 h-4 ml-2" />
|
{t("common.viewAll")} <ArrowRight className="w-4 h-4 ml-2" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,10 +116,10 @@ export default function Home() {
|
|||||||
<Card className="bg-muted/30 border-dashed">
|
<Card className="bg-muted/30 border-dashed">
|
||||||
<CardContent className="flex flex-col items-center justify-center p-12 text-center">
|
<CardContent className="flex flex-col items-center justify-center p-12 text-center">
|
||||||
<Star className="w-12 h-12 text-muted-foreground/30 mb-4" />
|
<Star className="w-12 h-12 text-muted-foreground/30 mb-4" />
|
||||||
<h3 className="text-lg font-medium">No ratings yet</h3>
|
<h3 className="text-lg font-medium">{t("home.noRatingsYet")}</h3>
|
||||||
<p className="text-muted-foreground max-w-sm mt-1 mb-4">Be the first to rate a tool and help the community.</p>
|
<p className="text-muted-foreground max-w-sm mt-1 mb-4">{t("home.noRatingsSub")}</p>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href="/tools">Browse Tools</Link>
|
<Link href="/tools">{t("common.browseTools")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -120,12 +130,12 @@ export default function Home() {
|
|||||||
<section>
|
<section>
|
||||||
<div className="flex justify-between items-end mb-4">
|
<div className="flex justify-between items-end mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight mb-1">Recently Added</h2>
|
<h2 className="text-2xl font-bold tracking-tight mb-1">{t("home.recentlyAdded")}</h2>
|
||||||
<p className="text-muted-foreground text-sm">The latest additions to the catalog.</p>
|
<p className="text-muted-foreground text-sm">{t("home.recentlyAddedSub")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" className="text-primary hidden sm:flex" asChild>
|
<Button variant="ghost" className="text-primary hidden sm:flex" asChild>
|
||||||
<Link href="/tools?sort=newest">
|
<Link href="/tools?sort=newest">
|
||||||
View all <ArrowRight className="w-4 h-4 ml-2" />
|
{t("common.viewAll")} <ArrowRight className="w-4 h-4 ml-2" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,10 +156,10 @@ export default function Home() {
|
|||||||
<Card className="bg-muted/30 border-dashed">
|
<Card className="bg-muted/30 border-dashed">
|
||||||
<CardContent className="flex flex-col items-center justify-center p-12 text-center">
|
<CardContent className="flex flex-col items-center justify-center p-12 text-center">
|
||||||
<Wrench className="w-12 h-12 text-muted-foreground/30 mb-4" />
|
<Wrench className="w-12 h-12 text-muted-foreground/30 mb-4" />
|
||||||
<h3 className="text-lg font-medium">No tools found</h3>
|
<h3 className="text-lg font-medium">{t("home.noTools")}</h3>
|
||||||
<p className="text-muted-foreground max-w-sm mt-1 mb-4">There are no tools in the database yet.</p>
|
<p className="text-muted-foreground max-w-sm mt-1 mb-4">{t("home.noToolsSub")}</p>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href="/tools/new">Add a Tool</Link>
|
<Link href="/tools/new">{t("browse.addTool")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
import { Wrench, AlertCircle } from "lucide-react";
|
import { Wrench, AlertCircle } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const search = useSearch();
|
const search = useSearch();
|
||||||
const params = new URLSearchParams(search);
|
const params = new URLSearchParams(search);
|
||||||
@@ -33,7 +35,7 @@ export default function Login() {
|
|||||||
setLocation(returnTo);
|
setLocation(returnTo);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
setError(err.data?.error || err.message || "Invalid username or password");
|
setError(err.data?.error || err.message || t("auth.invalidCredentials"));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -50,18 +52,18 @@ export default function Login() {
|
|||||||
<Wrench className="w-7 h-7" />
|
<Wrench className="w-7 h-7" />
|
||||||
<span>toolr</span>
|
<span>toolr</span>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-muted-foreground text-sm">Sign in to your account</p>
|
<p className="text-muted-foreground text-sm">{t("auth.loginSubtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<CardTitle className="text-lg">Sign in</CardTitle>
|
<CardTitle className="text-lg">{t("auth.signIn")}</CardTitle>
|
||||||
<CardDescription>Enter your credentials to continue</CardDescription>
|
<CardDescription>{t("auth.loginDescription")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="username">Username</Label>
|
<Label htmlFor="username">{t("auth.username")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -73,7 +75,7 @@ export default function Login() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">{t("auth.password")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -95,7 +97,7 @@ export default function Login() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={localLogin.isPending}
|
disabled={localLogin.isPending}
|
||||||
>
|
>
|
||||||
{localLogin.isPending ? "Signing in..." : "Sign in"}
|
{localLogin.isPending ? t("common.loading") : t("auth.signIn")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -103,7 +105,7 @@ export default function Login() {
|
|||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Link href="/" className="text-sm text-muted-foreground hover:text-primary transition-colors">
|
<Link href="/" className="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||||
Back to Home
|
{t("notFound.backHome")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Wrench, ArrowLeft } from "lucide-react";
|
import { Wrench, ArrowLeft } from "lucide-react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen w-full flex items-center justify-center bg-background">
|
<div className="min-h-screen w-full flex items-center justify-center bg-background">
|
||||||
<Card className="w-full max-w-md mx-4">
|
<Card className="w-full max-w-md mx-4">
|
||||||
@@ -13,10 +15,10 @@ export default function NotFound() {
|
|||||||
<span>toolr</span>
|
<span>toolr</span>
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-6xl font-bold text-muted-foreground/30 mb-2">404</h1>
|
<h1 className="text-6xl font-bold text-muted-foreground/30 mb-2">404</h1>
|
||||||
<p className="text-muted-foreground mb-6">This page doesn't exist.</p>
|
<p className="text-muted-foreground mb-6">{t("notFound.text")}</p>
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<Button variant="outline" className="gap-2">
|
<Button variant="outline" className="gap-2">
|
||||||
<ArrowLeft className="w-4 h-4" /> Back to Home
|
<ArrowLeft className="w-4 h-4" /> {t("notFound.backHome")}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
getListToolRatingsQueryKey,
|
getListToolRatingsQueryKey,
|
||||||
useGetRatingDistribution,
|
useGetRatingDistribution,
|
||||||
getGetRatingDistributionQueryKey,
|
getGetRatingDistributionQueryKey,
|
||||||
|
useGetToolRatingHistory,
|
||||||
|
getGetToolRatingHistoryQueryKey,
|
||||||
useCreateRating,
|
useCreateRating,
|
||||||
getGetTopToolsQueryKey,
|
getGetTopToolsQueryKey,
|
||||||
getGetAnalyticsSummaryQueryKey,
|
getGetAnalyticsSummaryQueryKey,
|
||||||
@@ -38,10 +40,13 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, Legend } from "recharts";
|
||||||
import { ExternalLink, Star, ArrowLeft, Plus, Pencil, Trash2, Link as LinkIcon, DollarSign, Euro } from "lucide-react";
|
import { ExternalLink, Star, ArrowLeft, Plus, Pencil, Trash2, Link as LinkIcon, DollarSign, Euro, Bookmark } from "lucide-react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { useWatchlist } from "@/hooks/use-watchlist";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useDeleteTool, getListToolsQueryKey } from "@workspace/api-client-react";
|
import { useDeleteTool, getListToolsQueryKey } from "@workspace/api-client-react";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -54,6 +59,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { customFetch } from "@workspace/api-client-react";
|
import { customFetch } from "@workspace/api-client-react";
|
||||||
|
import { recordRecentTool } from "@/lib/recent-tools";
|
||||||
|
|
||||||
const ratingSchema = z.object({
|
const ratingSchema = z.object({
|
||||||
usefulness: z.number().min(1).max(5),
|
usefulness: z.number().min(1).max(5),
|
||||||
@@ -75,6 +81,8 @@ export default function ToolDetail() {
|
|||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
||||||
const { user, isAdmin, hasFeature } = useAuth();
|
const { user, isAdmin, hasFeature } = useAuth();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isWatched, toggle: toggleWatchlist, canWatchlist } = useWatchlist();
|
||||||
const canManageCosts = hasFeature("costs");
|
const canManageCosts = hasFeature("costs");
|
||||||
const hasTrash = hasFeature("trash");
|
const hasTrash = hasFeature("trash");
|
||||||
const deleteTool = useDeleteTool();
|
const deleteTool = useDeleteTool();
|
||||||
@@ -195,6 +203,10 @@ export default function ToolDetail() {
|
|||||||
query: { enabled: !!id, queryKey: getGetToolQueryKey(id) }
|
query: { enabled: !!id, queryKey: getGetToolQueryKey(id) }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tool?.id && tool.name) recordRecentTool(tool.id, tool.name);
|
||||||
|
}, [tool]);
|
||||||
|
|
||||||
const { data: ratings, isLoading: loadingRatings } = useListToolRatings(id, {
|
const { data: ratings, isLoading: loadingRatings } = useListToolRatings(id, {
|
||||||
query: { enabled: !!id, queryKey: getListToolRatingsQueryKey(id) }
|
query: { enabled: !!id, queryKey: getListToolRatingsQueryKey(id) }
|
||||||
});
|
});
|
||||||
@@ -203,6 +215,10 @@ export default function ToolDetail() {
|
|||||||
query: { enabled: !!id, queryKey: getGetRatingDistributionQueryKey({ toolId: id }) }
|
query: { enabled: !!id, queryKey: getGetRatingDistributionQueryKey({ toolId: id }) }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: ratingHistory, isLoading: loadingRatingHistory } = useGetToolRatingHistory(id, {
|
||||||
|
query: { enabled: !!id, queryKey: getGetToolRatingHistoryQueryKey(id) }
|
||||||
|
});
|
||||||
|
|
||||||
const createRating = useCreateRating();
|
const createRating = useCreateRating();
|
||||||
|
|
||||||
const form = useForm<RatingFormValues>({
|
const form = useForm<RatingFormValues>({
|
||||||
@@ -346,13 +362,24 @@ export default function ToolDetail() {
|
|||||||
<span className="text-muted-foreground self-end mb-1">/ 5</span>
|
<span className="text-muted-foreground self-end mb-1">/ 5</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Based on {tool.ratingCount} reviews
|
{t("detail.basedOnReview", { count: tool.ratingCount })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{canWatchlist && (
|
||||||
|
<Button
|
||||||
|
variant={isWatched(id) ? "default" : "outline"}
|
||||||
|
className="w-full gap-2"
|
||||||
|
onClick={() => toggleWatchlist(Number(id))}
|
||||||
|
>
|
||||||
|
<Bookmark className={cn("w-4 h-4", isWatched(id) && "fill-current")} />
|
||||||
|
{isWatched(id) ? t("detail.savedToWatchlist") : t("detail.saveToWatchlist")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{tool.websiteUrl && (
|
{tool.websiteUrl && (
|
||||||
<Button asChild className="w-full mt-2" variant="outline">
|
<Button asChild className="w-full mt-2" variant="outline">
|
||||||
<a href={tool.websiteUrl} target="_blank" rel="noopener noreferrer">
|
<a href={tool.websiteUrl} target="_blank" rel="noopener noreferrer">
|
||||||
Visit Website <ExternalLink className="w-4 h-4 ml-2" />
|
{t("detail.visitWebsite")} <ExternalLink className="w-4 h-4 ml-2" />
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -361,7 +388,7 @@ export default function ToolDetail() {
|
|||||||
<div className="flex gap-2 w-full mt-1">
|
<div className="flex gap-2 w-full mt-1">
|
||||||
<Button asChild variant="outline" size="sm" className="flex-1 gap-2">
|
<Button asChild variant="outline" size="sm" className="flex-1 gap-2">
|
||||||
<Link href={`/tools/${id}/edit`}>
|
<Link href={`/tools/${id}/edit`}>
|
||||||
<Pencil className="w-3.5 h-3.5" /> Edit
|
<Pencil className="w-3.5 h-3.5" /> {t("detail.edit")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -370,7 +397,7 @@ export default function ToolDetail() {
|
|||||||
className="flex-1 gap-2 text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
className="flex-1 gap-2 text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||||
onClick={() => setDeleteOpen(true)}
|
onClick={() => setDeleteOpen(true)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" /> Delete
|
<Trash2 className="w-3.5 h-3.5" /> {t("detail.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -536,10 +563,10 @@ export default function ToolDetail() {
|
|||||||
{tool && (
|
{tool && (
|
||||||
<div className="space-y-4 mt-8">
|
<div className="space-y-4 mt-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-xl font-bold">Costs</h3>
|
<h3 className="text-xl font-bold">{t("detail.costs")}</h3>
|
||||||
{canManageCosts && (
|
{canManageCosts && (
|
||||||
<Button variant="outline" size="sm" onClick={() => { resetCostForm(); setCostDialogOpen(true); }} className="gap-2">
|
<Button variant="outline" size="sm" onClick={() => { resetCostForm(); setCostDialogOpen(true); }} className="gap-2">
|
||||||
<Plus className="w-4 h-4" /> Add Cost
|
<Plus className="w-4 h-4" /> {t("detail.addCost")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -587,7 +614,7 @@ export default function ToolDetail() {
|
|||||||
<Dialog open={costDialogOpen} onOpenChange={(o) => { if (!o) setCostDialogOpen(false); }}>
|
<Dialog open={costDialogOpen} onOpenChange={(o) => { if (!o) setCostDialogOpen(false); }}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editCost ? "Edit Cost" : "Add Cost"}</DialogTitle>
|
<DialogTitle>{editCost ? t("detail.edit") + " " + t("detail.costs") : t("detail.addCost")}</DialogTitle>
|
||||||
<DialogDescription>Manage license cost information for this tool.</DialogDescription>
|
<DialogDescription>Manage license cost information for this tool.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
@@ -654,19 +681,19 @@ export default function ToolDetail() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Rating Breakdown</CardTitle>
|
<CardTitle>{t("detail.ratingBreakdown")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<span className="font-medium">Usefulness</span>
|
<span className="font-medium">{t("detail.usefulness")}</span>
|
||||||
<span className="font-bold">{tool.avgUsefulness ? tool.avgUsefulness.toFixed(1) : "0.0"}</span>
|
<span className="font-bold">{tool.avgUsefulness ? tool.avgUsefulness.toFixed(1) : "0.0"}</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={((tool.avgUsefulness || 0) / 5) * 100} className="h-2" />
|
<Progress value={((tool.avgUsefulness || 0) / 5) * 100} className="h-2" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<span className="font-medium">Usability</span>
|
<span className="font-medium">{t("detail.usability")}</span>
|
||||||
<span className="font-bold">{tool.avgUsability ? tool.avgUsability.toFixed(1) : "0.0"}</span>
|
<span className="font-bold">{tool.avgUsability ? tool.avgUsability.toFixed(1) : "0.0"}</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={((tool.avgUsability || 0) / 5) * 100} className="h-2" />
|
<Progress value={((tool.avgUsability || 0) / 5) * 100} className="h-2" />
|
||||||
@@ -676,7 +703,7 @@ export default function ToolDetail() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Score Distribution</CardTitle>
|
<CardTitle>{t("detail.scoreDistribution")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loadingDistribution ? (
|
{loadingDistribution ? (
|
||||||
@@ -695,21 +722,49 @@ export default function ToolDetail() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("detail.scoreTrend")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loadingRatingHistory ? (
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
) : ratingHistory && ratingHistory.length > 1 ? (
|
||||||
|
<div className="h-48">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={ratingHistory} margin={{ top: 5, right: 10, bottom: 0, left: -20 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" opacity={0.2} />
|
||||||
|
<XAxis dataKey="date" hide />
|
||||||
|
<YAxis domain={[0, 5]} tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||||
|
<Tooltip labelFormatter={(_, payload) => (payload?.[0] ? format(new Date(payload[0].payload.date), "dd.MM.yyyy HH:mm") : "")} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
|
<Line type="monotone" dataKey="combined" name="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>
|
</div>
|
||||||
|
|
||||||
{/* Right Column: Reviews */}
|
{/* Right Column: Reviews */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h3 className="text-2xl font-bold">Reviews</h3>
|
<h3 className="text-2xl font-bold">{t("detail.reviews")}</h3>
|
||||||
{!isReviewFormOpen && user && (
|
{!isReviewFormOpen && user && (
|
||||||
<Button onClick={() => setIsReviewFormOpen(true)}>Write a Review</Button>
|
<Button onClick={() => setIsReviewFormOpen(true)}>{t("detail.addReview")}</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isReviewFormOpen && (
|
{isReviewFormOpen && (
|
||||||
<Card className="border-primary shadow-sm">
|
<Card className="border-primary shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Write a Review</CardTitle>
|
<CardTitle>{t("detail.addReview")}</CardTitle>
|
||||||
<CardDescription>Share your experience with {tool.name}</CardDescription>
|
<CardDescription>Share your experience with {tool.name}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -721,7 +776,7 @@ export default function ToolDetail() {
|
|||||||
name="usefulness"
|
name="usefulness"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Usefulness</FormLabel>
|
<FormLabel>{t("detail.usefulness")}</FormLabel>
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
<RatingStars
|
<RatingStars
|
||||||
value={field.value}
|
value={field.value}
|
||||||
@@ -739,7 +794,7 @@ export default function ToolDetail() {
|
|||||||
name="usability"
|
name="usability"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Usability</FormLabel>
|
<FormLabel>{t("detail.usability")}</FormLabel>
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
<RatingStars
|
<RatingStars
|
||||||
value={field.value}
|
value={field.value}
|
||||||
@@ -796,7 +851,7 @@ export default function ToolDetail() {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={createRating.isPending}>
|
<Button type="submit" disabled={createRating.isPending}>
|
||||||
{createRating.isPending ? "Submitting..." : "Submit Review"}
|
{createRating.isPending ? t("common.loading") : t("detail.submit")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -825,11 +880,11 @@ export default function ToolDetail() {
|
|||||||
|
|
||||||
<div className="flex gap-6 mb-4">
|
<div className="flex gap-6 mb-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">Usefulness</span>
|
<span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">{t("detail.usefulness")}</span>
|
||||||
<RatingStars value={rating.usefulness} size="sm" />
|
<RatingStars value={rating.usefulness} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">Usability</span>
|
<span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">{t("detail.usability")}</span>
|
||||||
<RatingStars value={rating.usability} size="sm" />
|
<RatingStars value={rating.usability} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -845,7 +900,7 @@ export default function ToolDetail() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12 bg-muted/30 border border-dashed rounded-xl">
|
<div className="text-center py-12 bg-muted/30 border border-dashed rounded-xl">
|
||||||
<Star className="w-12 h-12 text-muted-foreground/30 mx-auto mb-3" />
|
<Star className="w-12 h-12 text-muted-foreground/30 mx-auto mb-3" />
|
||||||
<h4 className="text-lg font-medium">No reviews yet</h4>
|
<h4 className="text-lg font-medium">{t("detail.noReviews")}</h4>
|
||||||
<p className="text-muted-foreground mt-1">Be the first to share your thoughts on this tool.</p>
|
<p className="text-muted-foreground mt-1">Be the first to share your thoughts on this tool.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,25 +1,86 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
useListTools,
|
useListTools,
|
||||||
useListCategories,
|
useListCategories,
|
||||||
ListToolsSort
|
ListToolsSort,
|
||||||
} from "@workspace/api-client-react";
|
} from "@workspace/api-client-react";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { Layout } from "@/components/layout";
|
import { Layout } from "@/components/layout";
|
||||||
import { ToolCard } from "@/components/tool-card";
|
import { ToolCard } from "@/components/tool-card";
|
||||||
|
import { ToolCardWide } from "@/components/tool-card-wide";
|
||||||
|
import { ToolRow, TABLE_GRID } from "@/components/tool-row";
|
||||||
|
import { ToolPreviewCard } from "@/components/tool-preview-card";
|
||||||
|
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
|
||||||
|
import { ViewToggle, type ViewMode } from "@/components/view-toggle";
|
||||||
|
import { DensityToggle, type Density } from "@/components/density-toggle";
|
||||||
|
import { FilterPopover } from "@/components/filter-popover";
|
||||||
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||||
|
import { useBrowsePreferences, isViewMode, isDensity } from "@/hooks/use-browse-preferences";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { useWatchlist } from "@/hooks/use-watchlist";
|
||||||
|
import { CompareBar } from "@/components/compare-bar";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Search, Wrench, SlidersHorizontal, X } from "lucide-react";
|
import { Search, Wrench, X, ArrowUp, ArrowDown, ArrowUpDown, SlidersHorizontal, Plus } from "lucide-react";
|
||||||
import { Link, useLocation, useSearch } from "wouter";
|
import { Link, useLocation, useSearch } from "wouter";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const SORT_VALUES = new Set<string>([ListToolsSort.newest, ListToolsSort.top_rated, ListToolsSort.most_reviewed]);
|
const SORT_VALUES = new Set<string>(Object.values(ListToolsSort));
|
||||||
|
|
||||||
|
const SORT_KEYS: Record<string, string> = {
|
||||||
|
[ListToolsSort.newest]: "browse.sortNewest",
|
||||||
|
[ListToolsSort.top_rated]: "browse.sortTopRated",
|
||||||
|
[ListToolsSort.most_reviewed]: "browse.sortMostReviewed",
|
||||||
|
[ListToolsSort.name_asc]: "browse.sortNameAsc",
|
||||||
|
[ListToolsSort.name_desc]: "browse.sortNameDesc",
|
||||||
|
[ListToolsSort.recently_updated]: "browse.sortRecentlyUpdated",
|
||||||
|
};
|
||||||
|
|
||||||
|
function SortHeader({
|
||||||
|
active,
|
||||||
|
direction,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
align = "left",
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
direction?: "asc" | "desc";
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
align?: "left" | "right";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 text-xs font-semibold uppercase tracking-wide hover:text-foreground transition-colors",
|
||||||
|
active ? "text-foreground" : "text-muted-foreground",
|
||||||
|
align === "right" && "w-full justify-end",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{active && direction === "asc" ? (
|
||||||
|
<ArrowUp className="w-3.5 h-3.5" />
|
||||||
|
) : active && direction === "desc" ? (
|
||||||
|
<ArrowDown className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="w-3.5 h-3.5 opacity-50" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ToolsBrowse() {
|
export default function ToolsBrowse() {
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
const urlSearch = useSearch();
|
const urlSearch = useSearch();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const initialParams = new URLSearchParams(urlSearch);
|
const initialParams = useMemo(() => new URLSearchParams(urlSearch), [urlSearch]);
|
||||||
const initialSearch = initialParams.get("search") ?? "";
|
const initialSearch = initialParams.get("search") ?? "";
|
||||||
const initialCategory = initialParams.get("category") ?? "all";
|
const initialCategory = initialParams.get("category") ?? "all";
|
||||||
const initialSortParam = initialParams.get("sort") ?? "";
|
const initialSortParam = initialParams.get("sort") ?? "";
|
||||||
@@ -27,144 +88,482 @@ export default function ToolsBrowse() {
|
|||||||
? (initialSortParam as ListToolsSort)
|
? (initialSortParam as ListToolsSort)
|
||||||
: ListToolsSort.newest;
|
: ListToolsSort.newest;
|
||||||
|
|
||||||
|
const { serverView, serverDensity, localView, localDensity, persist } = useBrowsePreferences();
|
||||||
|
const { hasFeature } = useAuth();
|
||||||
|
const { isWatched, toggle: toggleWatchlist, canWatchlist } = useWatchlist();
|
||||||
|
|
||||||
|
const [compareIds, setCompareIds] = useState<number[]>([]);
|
||||||
|
const [compareUpsellOpen, setCompareUpsellOpen] = useState(false);
|
||||||
|
|
||||||
|
function toggleCompare(id: number) {
|
||||||
|
setCompareIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||||
|
}
|
||||||
|
|
||||||
const [search, setSearch] = useState(initialSearch);
|
const [search, setSearch] = useState(initialSearch);
|
||||||
const [searchInput, setSearchInput] = useState(initialSearch);
|
const [searchInput, setSearchInput] = useState(initialSearch);
|
||||||
const [category, setCategory] = useState<string>(initialCategory);
|
const [category, setCategory] = useState<string>(initialCategory);
|
||||||
const [sort, setSort] = useState<ListToolsSort>(initialSort);
|
const [sort, setSort] = useState<ListToolsSort>(initialSort);
|
||||||
|
const [overrideView, setOverrideView] = useState<ViewMode | null>(null);
|
||||||
|
const [overrideDensity, setOverrideDensity] = useState<Density | null>(null);
|
||||||
|
const [tags, setTags] = useState<string[]>(() =>
|
||||||
|
(initialParams.get("tags") ?? "").split(",").map((t) => t.trim()).filter(Boolean),
|
||||||
|
);
|
||||||
|
const [features, setFeatures] = useState<string[]>(() =>
|
||||||
|
(initialParams.get("features") ?? "").split(",").map((f) => f.trim()).filter(Boolean),
|
||||||
|
);
|
||||||
|
const [minRating, setMinRating] = useState<number | null>(() => {
|
||||||
|
const v = Number(initialParams.get("minRating"));
|
||||||
|
return Number.isFinite(v) ? Math.min(5, Math.max(0, v)) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const urlView = initialParams.get("view");
|
||||||
|
const urlDensity = initialParams.get("density");
|
||||||
|
const view: ViewMode =
|
||||||
|
overrideView ?? (isViewMode(urlView) ? urlView : null) ?? serverView ?? localView ?? "grid";
|
||||||
|
const density: Density =
|
||||||
|
overrideDensity ?? (isDensity(urlDensity) ? urlDensity : null) ?? serverDensity ?? localDensity ?? "cozy";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
if (search) p.set("search", search);
|
if (search) p.set("search", search);
|
||||||
if (category && category !== "all") p.set("category", category);
|
if (category && category !== "all") p.set("category", category);
|
||||||
if (sort && sort !== ListToolsSort.newest) p.set("sort", sort);
|
if (sort && sort !== ListToolsSort.newest) p.set("sort", sort);
|
||||||
|
if (view !== "grid") p.set("view", view);
|
||||||
|
if (density !== "cozy") p.set("density", density);
|
||||||
|
if (tags.length > 0) p.set("tags", tags.join(","));
|
||||||
|
if (features.length > 0) p.set("features", features.join(","));
|
||||||
|
if (minRating != null) p.set("minRating", String(minRating));
|
||||||
const qs = p.toString();
|
const qs = p.toString();
|
||||||
navigate(qs ? `/tools?${qs}` : "/tools", { replace: true });
|
navigate(qs ? `/tools?${qs}` : "/tools", { replace: true });
|
||||||
}, [search, category, sort]);
|
}, [search, category, sort, view, density, tags, features, minRating, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setSearch(searchInput), 300);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [searchInput]);
|
||||||
|
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const isTyping =
|
||||||
|
target.tagName === "INPUT" ||
|
||||||
|
target.tagName === "TEXTAREA" ||
|
||||||
|
target.tagName === "SELECT" ||
|
||||||
|
target.isContentEditable;
|
||||||
|
if (e.key === "/" && !isTyping) {
|
||||||
|
e.preventDefault();
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { data: categories, isLoading: loadingCategories } = useListCategories();
|
const { data: categories, isLoading: loadingCategories } = useListCategories();
|
||||||
|
|
||||||
const queryParams = {
|
const queryParams = {
|
||||||
...(search ? { search } : {}),
|
...(search ? { search } : {}),
|
||||||
...(category && category !== "all" ? { category } : {}),
|
...(category && category !== "all" ? { category } : {}),
|
||||||
...(sort ? { sort } : {})
|
...(sort ? { sort } : {}),
|
||||||
|
...(tags.length > 0 ? { tags: tags.join(",") } : {}),
|
||||||
|
...(features.length > 0 ? { features: features.join(",") } : {}),
|
||||||
|
...(minRating != null ? { minRating } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data: tools, isLoading: loadingTools } = useListTools(queryParams);
|
|
||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
const { data: tools, isLoading: loadingTools } = useListTools(queryParams);
|
||||||
e.preventDefault();
|
const allTools = tools ?? [];
|
||||||
setSearch(searchInput);
|
|
||||||
};
|
function setView(v: ViewMode) {
|
||||||
|
setOverrideView(v);
|
||||||
|
persist(v, density);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDensity(d: Density) {
|
||||||
|
setOverrideDensity(d);
|
||||||
|
persist(view, d);
|
||||||
|
}
|
||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
setSearch("");
|
setSearch("");
|
||||||
setSearchInput("");
|
setSearchInput("");
|
||||||
setCategory("all");
|
setCategory("all");
|
||||||
setSort(ListToolsSort.newest);
|
setSort(ListToolsSort.newest);
|
||||||
|
setTags([]);
|
||||||
|
setFeatures([]);
|
||||||
|
setMinRating(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFilters = search !== "" || category !== "all" || sort !== ListToolsSort.newest;
|
const filterCount = tags.length + features.length + (minRating != null ? 1 : 0);
|
||||||
|
const hasFilters = search !== "" || category !== "all" || sort !== ListToolsSort.newest || filterCount > 0;
|
||||||
|
|
||||||
|
function toggleNameSort() {
|
||||||
|
setSort(sort === ListToolsSort.name_asc ? ListToolsSort.name_desc : ListToolsSort.name_asc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- virtualization ----
|
||||||
|
const tableParentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const rowsParentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const tableVirtualizer = useVirtualizer({
|
||||||
|
count: allTools.length,
|
||||||
|
getScrollElement: () => tableParentRef.current,
|
||||||
|
estimateSize: () => (density === "compact" ? 44 : 64),
|
||||||
|
overscan: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rowsVirtualizer = useVirtualizer({
|
||||||
|
count: allTools.length,
|
||||||
|
getScrollElement: () => rowsParentRef.current,
|
||||||
|
estimateSize: () => (density === "compact" ? 72 : 116),
|
||||||
|
overscan: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
const gridPad = density === "compact" ? "gap-3" : "gap-4";
|
||||||
|
|
||||||
|
const sortActive = (v: ListToolsSort) => sort === v;
|
||||||
|
const nameDir: "asc" | "desc" | undefined =
|
||||||
|
sort === ListToolsSort.name_asc ? "asc" : sort === ListToolsSort.name_desc ? "desc" : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6 pb-8">
|
<div className="space-y-6 pb-8">
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Browse Tools</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("nav.browseTools")}</h1>
|
||||||
<p className="text-muted-foreground">Discover and evaluate the best tools for your stack.</p>
|
<p className="text-muted-foreground">{t("browse.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href="/tools/new">Add a Tool</Link>
|
<Link href="/tools/new">
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
{t("browse.addTool")}
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="bg-card border rounded-xl p-4 flex flex-col md:flex-row gap-4">
|
<div className="bg-card border rounded-xl p-4 flex flex-col md:flex-row gap-4">
|
||||||
<form onSubmit={handleSearch} className="flex-1 relative">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search tools..."
|
ref={searchInputRef}
|
||||||
|
placeholder={`${t("common.search")}… (press "/" to focus)`}
|
||||||
className="pl-9 w-full"
|
className="pl-9 w-full"
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
data-testid="input-search"
|
data-testid="input-search"
|
||||||
/>
|
/>
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap sm:flex-nowrap gap-4 shrink-0">
|
<div className="flex flex-wrap sm:flex-nowrap gap-4 shrink-0">
|
||||||
<Select value={category} onValueChange={setCategory}>
|
<Select value={category} onValueChange={setCategory}>
|
||||||
<SelectTrigger className="w-full sm:w-[180px]" data-testid="select-category">
|
<SelectTrigger className="w-full sm:w-[180px]" data-testid="select-category">
|
||||||
<SelectValue placeholder="All Categories" />
|
<SelectValue placeholder="All Categories" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Categories</SelectItem>
|
<SelectItem value="all">{t("browse.allCategories")}</SelectItem>
|
||||||
{!loadingCategories && categories?.map((c) => (
|
{!loadingCategories &&
|
||||||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
categories?.map((c) => (
|
||||||
))}
|
<SelectItem key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={sort} onValueChange={(val) => setSort(val as ListToolsSort)}>
|
<Select value={sort} onValueChange={(val) => setSort(val as ListToolsSort)}>
|
||||||
<SelectTrigger className="w-full sm:w-[180px]" data-testid="select-sort">
|
<SelectTrigger className="w-full sm:w-[180px]" data-testid="select-sort">
|
||||||
<SelectValue placeholder="Sort By" />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value={ListToolsSort.newest}>Newest</SelectItem>
|
{Object.values(ListToolsSort).map((v) => (
|
||||||
<SelectItem value={ListToolsSort.top_rated}>Top Rated</SelectItem>
|
<SelectItem key={v} value={v}>
|
||||||
<SelectItem value={ListToolsSort.most_reviewed}>Most Reviewed</SelectItem>
|
{t(SORT_KEYS[v])}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{hasFilters && (
|
<FilterPopover
|
||||||
<Button variant="ghost" size="icon" onClick={clearFilters} className="shrink-0" title="Clear filters">
|
tags={tags}
|
||||||
<X className="w-4 h-4" />
|
features={features}
|
||||||
</Button>
|
minRating={minRating}
|
||||||
|
onChange={({ tags: t, features: f, minRating: m }) => {
|
||||||
|
if (t) setTags(t);
|
||||||
|
if (f) setFeatures(f);
|
||||||
|
if (m !== undefined) setMinRating(m);
|
||||||
|
}}
|
||||||
|
onClear={() => {
|
||||||
|
setTags([]);
|
||||||
|
setFeatures([]);
|
||||||
|
setMinRating(null);
|
||||||
|
}}
|
||||||
|
activeCount={filterCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasFilters && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 -mt-2">
|
||||||
|
{search && (
|
||||||
|
<Badge variant="secondary" className="gap-1 pl-2.5 pr-1.5 py-1">
|
||||||
|
“{search}”
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSearch("");
|
||||||
|
setSearchInput("");
|
||||||
|
}}
|
||||||
|
className="rounded-sm hover:bg-muted p-0.5"
|
||||||
|
aria-label="Remove search"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{category !== "all" && (
|
||||||
|
<Badge variant="secondary" className="gap-1 pl-2.5 pr-1.5 py-1">
|
||||||
|
{category}
|
||||||
|
<button
|
||||||
|
onClick={() => setCategory("all")}
|
||||||
|
className="rounded-sm hover:bg-muted p-0.5"
|
||||||
|
aria-label="Remove category"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{sort !== ListToolsSort.newest && (
|
||||||
|
<Badge variant="secondary" className="gap-1 pl-2.5 pr-1.5 py-1">
|
||||||
|
{t(SORT_KEYS[sort])}
|
||||||
|
<button
|
||||||
|
onClick={() => setSort(ListToolsSort.newest)}
|
||||||
|
className="rounded-sm hover:bg-muted p-0.5"
|
||||||
|
aria-label="Reset sort"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{tags.map((t) => (
|
||||||
|
<Badge key={`tag-${t}`} variant="secondary" className="gap-1 pl-2.5 pr-1.5 py-1">
|
||||||
|
{t}
|
||||||
|
<button
|
||||||
|
onClick={() => setTags(tags.filter((x) => x !== t))}
|
||||||
|
className="rounded-sm hover:bg-muted p-0.5"
|
||||||
|
aria-label={`Remove tag ${t}`}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{features.map((f) => (
|
||||||
|
<Badge key={`feat-${f}`} variant="secondary" className="gap-1 pl-2.5 pr-1.5 py-1">
|
||||||
|
{f}
|
||||||
|
<button
|
||||||
|
onClick={() => setFeatures(features.filter((x) => x !== f))}
|
||||||
|
className="rounded-sm hover:bg-muted p-0.5"
|
||||||
|
aria-label={`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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
<div>
|
{loadingTools ? (
|
||||||
<div className="flex items-center text-sm text-muted-foreground mb-4">
|
<Skeletons view={view} density={density} />
|
||||||
<SlidersHorizontal className="w-4 h-4 mr-2" />
|
) : allTools.length > 0 ? (
|
||||||
{loadingTools ? "Loading tools..." : `Showing ${tools?.length || 0} tools`}
|
view === "grid" ? (
|
||||||
</div>
|
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4", gridPad)}>
|
||||||
|
{allTools.map((tool) => (
|
||||||
{loadingTools ? (
|
<ToolCard
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
key={tool.id}
|
||||||
{[1, 2, 3, 4, 5, 6, 7, 8].map(i => (
|
tool={tool}
|
||||||
<Skeleton key={`sk-tools-${i}`} className="h-[200px] w-full rounded-xl" />
|
compare={{ selected: compareIds.includes(tool.id), onToggle: () => toggleCompare(tool.id) }}
|
||||||
|
watchlist={canWatchlist ? { watched: isWatched(tool.id), onToggle: () => toggleWatchlist(tool.id) } : undefined}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : tools && tools.length > 0 ? (
|
) : view === "rows" ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div ref={rowsParentRef} className="overflow-auto max-h-[72vh]">
|
||||||
{tools.map((tool) => (
|
<div className="relative" style={{ height: rowsVirtualizer.getTotalSize() }}>
|
||||||
<ToolCard key={tool.id} tool={tool} />
|
{rowsVirtualizer.getVirtualItems().map((vi) => (
|
||||||
))}
|
<div
|
||||||
|
key={allTools[vi.index].id}
|
||||||
|
ref={rowsVirtualizer.measureElement}
|
||||||
|
data-index={vi.index}
|
||||||
|
className="absolute top-0 left-0 w-full"
|
||||||
|
style={{ transform: `translateY(${vi.start}px)` }}
|
||||||
|
>
|
||||||
|
<ToolCardWide
|
||||||
|
tool={allTools[vi.index]}
|
||||||
|
density={density}
|
||||||
|
compare={{ selected: compareIds.includes(allTools[vi.index].id), onToggle: () => toggleCompare(allTools[vi.index].id) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-muted/30 border border-dashed rounded-xl py-24 flex flex-col items-center justify-center text-center">
|
<div ref={tableParentRef} className="overflow-auto max-h-[72vh] rounded-xl border bg-background">
|
||||||
<div className="bg-muted p-4 rounded-full mb-4">
|
<div className={cn("grid items-center gap-3 border-b bg-muted sticky top-0 z-10 px-4", TABLE_GRID)}>
|
||||||
<Wrench className="w-8 h-8 text-muted-foreground" />
|
<div className="py-2.5">
|
||||||
|
<SortHeader active={sortActive(ListToolsSort.name_asc) || sortActive(ListToolsSort.name_desc)} direction={nameDir} onClick={toggleNameSort}>
|
||||||
|
{t("browse.tool")}
|
||||||
|
</SortHeader>
|
||||||
|
</div>
|
||||||
|
<div className="py-2.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t("browse.category")}
|
||||||
|
</div>
|
||||||
|
<div className="py-2.5">
|
||||||
|
<SortHeader active={sortActive(ListToolsSort.top_rated)} onClick={() => setSort(ListToolsSort.top_rated)} align="right">
|
||||||
|
{t("browse.rating")}
|
||||||
|
</SortHeader>
|
||||||
|
</div>
|
||||||
|
<div className="py-2.5">
|
||||||
|
<SortHeader active={sortActive(ListToolsSort.most_reviewed)} onClick={() => setSort(ListToolsSort.most_reviewed)} align="right">
|
||||||
|
{t("browse.reviews")}
|
||||||
|
</SortHeader>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-medium mb-2">No tools found</h3>
|
<div className="relative px-4" style={{ height: tableVirtualizer.getTotalSize() }}>
|
||||||
<p className="text-muted-foreground max-w-md mx-auto mb-6">
|
{tableVirtualizer.getVirtualItems().map((vi) => {
|
||||||
We couldn't find any tools matching your current filters. Try adjusting your search criteria or add a new tool.
|
const tool = allTools[vi.index];
|
||||||
</p>
|
return (
|
||||||
<div className="flex gap-4">
|
<div
|
||||||
{hasFilters && (
|
key={tool.id}
|
||||||
<Button variant="outline" onClick={clearFilters}>
|
ref={tableVirtualizer.measureElement}
|
||||||
Clear Filters
|
data-index={vi.index}
|
||||||
</Button>
|
className="absolute top-0 left-0 w-full"
|
||||||
)}
|
style={{ transform: `translateY(${vi.start}px)` }}
|
||||||
<Button asChild>
|
>
|
||||||
<Link href="/tools/new">Add Tool</Link>
|
<HoverCard openDelay={250} closeDelay={100}>
|
||||||
</Button>
|
<HoverCardTrigger asChild>
|
||||||
|
<div>
|
||||||
|
<ToolRow
|
||||||
|
tool={tool}
|
||||||
|
density={density}
|
||||||
|
compare={{ selected: compareIds.includes(tool.id), onToggle: () => toggleCompare(tool.id) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent side="right" align="start" className="w-72" collisionPadding={16}>
|
||||||
|
<ToolPreviewCard tool={tool} />
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
</div>
|
) : (
|
||||||
|
<div className="bg-muted/30 border border-dashed rounded-xl py-24 flex flex-col items-center justify-center text-center">
|
||||||
|
<div className="bg-muted p-4 rounded-full mb-4">
|
||||||
|
<Wrench className="w-8 h-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-medium mb-2">{t("browse.noToolsFound")}</h3>
|
||||||
|
<p className="text-muted-foreground max-w-md mx-auto mb-6">
|
||||||
|
{t("browse.noToolsMatch")}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{hasFilters && (
|
||||||
|
<Button variant="outline" onClick={clearFilters}>
|
||||||
|
{t("filter.clearFilters")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/tools/new">{t("browse.addTool")}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CompareBar
|
||||||
|
tools={allTools.filter((t) => compareIds.includes(t.id))}
|
||||||
|
onRemove={(id) => toggleCompare(id)}
|
||||||
|
onClear={() => setCompareIds([])}
|
||||||
|
canCompare={hasFeature("compare")}
|
||||||
|
onCompare={() => navigate(`/compare?ids=${compareIds.join(",")}`)}
|
||||||
|
onUpgrade={() => setCompareUpsellOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog open={compareUpsellOpen} onOpenChange={setCompareUpsellOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||||
import { Trash2, RotateCcw, Search, ShieldAlert, Trash as TrashIcon, RefreshCcw } from "lucide-react";
|
import { Trash2, RotateCcw, Search, ShieldAlert, Trash as TrashIcon, RefreshCcw } from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function Trash() {
|
export default function Trash() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { user, isAdmin, hasFeature, isLoading: authLoading } = useAuth();
|
const { user, isAdmin, hasFeature, isLoading: authLoading } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -139,10 +141,10 @@ export default function Trash() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||||
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
|
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
|
||||||
<h2 className="text-2xl font-bold">Trash requires a higher tier</h2>
|
<h2 className="text-2xl font-bold">{t("trash.requiresPremium")}</h2>
|
||||||
<p className="text-muted-foreground">The trash is available to Premium and Enterprise users.</p>
|
<p className="text-muted-foreground">{t("trash.requiresPremiumSub")}</p>
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<a href="/tools">Back to tools</a>
|
<a href="/tools">{t("common.backToTools")}</a>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
@@ -155,16 +157,16 @@ export default function Trash() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6 pb-10">
|
<div className="space-y-6 pb-10">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Trash</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("trash.title")}</h1>
|
||||||
<p className="text-muted-foreground">Deleted tools are kept here until they are restored or permanently removed.</p>
|
<p className="text-muted-foreground">{t("trash.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-4 flex-wrap">
|
<CardHeader className="flex flex-row items-center justify-between gap-4 flex-wrap">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<CardTitle>Trashed Tools</CardTitle>
|
<CardTitle>{t("trash.trashedTools")}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{selectedIds.length > 0 ? `${selectedIds.length} selected` : `${trashed.length} tool(s) in trash`}
|
{selectedIds.length > 0 ? t("trash.selected", { count: selectedIds.length }) : t("trash.toolsInTrash", { count: trashed.length })}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@@ -172,7 +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" />
|
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
className="pl-8 w-56"
|
className="pl-8 w-56"
|
||||||
placeholder="Search trash…"
|
placeholder={`${t("common.search")}…`}
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -183,7 +185,7 @@ export default function Trash() {
|
|||||||
disabled={selectedIds.length === 0 || restore.isPending}
|
disabled={selectedIds.length === 0 || restore.isPending}
|
||||||
onClick={() => handleRestore(selectedIds)}
|
onClick={() => handleRestore(selectedIds)}
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-4 h-4 mr-2" /> Restore ({selectedIds.length})
|
<RotateCcw className="w-4 h-4 mr-2" /> {t("trash.restore")} ({selectedIds.length})
|
||||||
</Button>
|
</Button>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
@@ -193,7 +195,7 @@ export default function Trash() {
|
|||||||
disabled={selectedIds.length === 0 || deletePermanent.isPending}
|
disabled={selectedIds.length === 0 || deletePermanent.isPending}
|
||||||
onClick={() => setConfirmDelete(true)}
|
onClick={() => setConfirmDelete(true)}
|
||||||
>
|
>
|
||||||
<TrashIcon className="w-4 h-4 mr-2" /> Delete permanently ({selectedIds.length})
|
<TrashIcon className="w-4 h-4 mr-2" /> {t("trash.deletePermanently")} ({selectedIds.length})
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -201,7 +203,7 @@ export default function Trash() {
|
|||||||
disabled={trashed.length === 0 || empty.isPending}
|
disabled={trashed.length === 0 || empty.isPending}
|
||||||
onClick={() => setConfirmEmpty(true)}
|
onClick={() => setConfirmEmpty(true)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-2" /> Empty trash
|
<Trash2 className="w-4 h-4 mr-2" /> {t("trash.emptyTrash")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -215,7 +217,7 @@ export default function Trash() {
|
|||||||
) : trashed.length === 0 ? (
|
) : trashed.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground py-8 text-center flex flex-col items-center gap-2">
|
<div className="text-sm text-muted-foreground py-8 text-center flex flex-col items-center gap-2">
|
||||||
<RefreshCcw className="w-6 h-6 text-muted-foreground/50" />
|
<RefreshCcw className="w-6 h-6 text-muted-foreground/50" />
|
||||||
{search ? "No tools match your search." : "The trash is empty."}
|
{search ? t("trash.noMatch") : t("trash.empty")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
@@ -230,8 +232,8 @@ export default function Trash() {
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Category</TableHead>
|
<TableHead>Category</TableHead>
|
||||||
<TableHead>Deleted at</TableHead>
|
<TableHead>{t("trash.deletedAt")}</TableHead>
|
||||||
<TableHead>Deleted by</TableHead>
|
<TableHead>{t("trash.deletedBy")}</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -294,7 +296,7 @@ export default function Trash() {
|
|||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
onClick={() => handleDeletePermanent(selectedIds)}
|
onClick={() => handleDeletePermanent(selectedIds)}
|
||||||
>
|
>
|
||||||
Delete permanently
|
{t("trash.deletePermanently")}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
@@ -314,7 +316,7 @@ export default function Trash() {
|
|||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
onClick={handleEmpty}
|
onClick={handleEmpty}
|
||||||
>
|
>
|
||||||
Empty trash
|
{t("trash.emptyTrash")}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
useGetMeWatchlist,
|
||||||
|
getGetMeWatchlistQueryKey,
|
||||||
|
} from "@workspace/api-client-react";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { useWatchlist } from "@/hooks/use-watchlist";
|
||||||
|
import { Layout } from "@/components/layout";
|
||||||
|
import { ToolCard } from "@/components/tool-card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { ShieldAlert, Bookmark } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function Watchlist() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { hasFeature, isLoading: authLoading } = useAuth();
|
||||||
|
const { toggle } = useWatchlist();
|
||||||
|
const canWatchlist = hasFeature("watchlist");
|
||||||
|
|
||||||
|
const { data: tools, isLoading: loading } = useGetMeWatchlist({
|
||||||
|
query: { queryKey: getGetMeWatchlistQueryKey(), enabled: canWatchlist },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!authLoading && !canWatchlist) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||||
|
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
|
||||||
|
<h2 className="text-2xl font-bold">{t("watchlist.requiresPremium")}</h2>
|
||||||
|
<p className="text-muted-foreground">{t("watchlist.requiresPremiumSub")}</p>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href="/tools">{t("common.backToTools")}</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = tools ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="space-y-6 pb-10">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("watchlist.title")}</h1>
|
||||||
|
<p className="text-muted-foreground">{t("watchlist.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{[1, 2, 3, 4].map((i) => <Skeleton key={i} className="h-[200px] w-full rounded-xl" />)}
|
||||||
|
</div>
|
||||||
|
) : list.length === 0 ? (
|
||||||
|
<div className="bg-muted/30 border border-dashed rounded-xl py-24 flex flex-col items-center justify-center text-center gap-3">
|
||||||
|
<Bookmark className="w-8 h-8 text-muted-foreground" />
|
||||||
|
<h3 className="text-xl font-medium">{t("watchlist.empty")}</h3>
|
||||||
|
<p className="text-muted-foreground max-w-md mx-auto">
|
||||||
|
{t("watchlist.emptySub")}
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href="/tools">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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -52,6 +52,14 @@ export const UserTier = {
|
|||||||
enterprise: 'enterprise',
|
enterprise: 'enterprise',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export type UserAuthProvider = typeof UserAuthProvider[keyof typeof UserAuthProvider];
|
||||||
|
|
||||||
|
|
||||||
|
export const UserAuthProvider = {
|
||||||
|
local: 'local',
|
||||||
|
oidc: 'oidc',
|
||||||
|
} as const;
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -59,6 +67,7 @@ export interface User {
|
|||||||
email?: string | null;
|
email?: string | null;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
tier?: UserTier;
|
tier?: UserTier;
|
||||||
|
authProvider?: UserAuthProvider;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +120,23 @@ export interface UserRoleUpdate {
|
|||||||
tier?: UserRoleUpdateTier;
|
tier?: UserRoleUpdateTier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChangePasswordInput {
|
||||||
|
/** @minLength 1 */
|
||||||
|
currentPassword: string;
|
||||||
|
/** @minLength 8 */
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetPasswordInput {
|
||||||
|
/** @minLength 8 */
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordRedirect {
|
||||||
|
/** @nullable */
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuditLog {
|
export interface AuditLog {
|
||||||
id: number;
|
id: number;
|
||||||
entityType: string;
|
entityType: string;
|
||||||
@@ -145,6 +171,13 @@ export interface Tool {
|
|||||||
deletedBy?: string | null;
|
deletedBy?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RatingHistoryItem {
|
||||||
|
date: string;
|
||||||
|
usefulness: number;
|
||||||
|
usability: number;
|
||||||
|
combined: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ToolWithStats {
|
export interface ToolWithStats {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -308,6 +341,29 @@ export interface AuthUser {
|
|||||||
isLocal?: boolean;
|
isLocal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UserPreferencesView = typeof UserPreferencesView[keyof typeof UserPreferencesView];
|
||||||
|
|
||||||
|
|
||||||
|
export const UserPreferencesView = {
|
||||||
|
grid: 'grid',
|
||||||
|
table: 'table',
|
||||||
|
rows: 'rows',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type UserPreferencesDensity = typeof UserPreferencesDensity[keyof typeof UserPreferencesDensity];
|
||||||
|
|
||||||
|
|
||||||
|
export const UserPreferencesDensity = {
|
||||||
|
cozy: 'cozy',
|
||||||
|
compact: 'compact',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface UserPreferences {
|
||||||
|
view?: UserPreferencesView;
|
||||||
|
density?: UserPreferencesDensity;
|
||||||
|
watchlist?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ErrorResponse {
|
export interface ErrorResponse {
|
||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
@@ -316,6 +372,20 @@ export type ListToolsParams = {
|
|||||||
category?: string;
|
category?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
sort?: ListToolsSort;
|
sort?: ListToolsSort;
|
||||||
|
/**
|
||||||
|
* Comma-separated tags; tool must include all of them
|
||||||
|
*/
|
||||||
|
tags?: string;
|
||||||
|
/**
|
||||||
|
* Comma-separated features; tool must include all of them
|
||||||
|
*/
|
||||||
|
features?: string;
|
||||||
|
/**
|
||||||
|
* Minimum average combined rating (0-5)
|
||||||
|
* @minimum 0
|
||||||
|
* @maximum 5
|
||||||
|
*/
|
||||||
|
minRating?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ListToolsSort = typeof ListToolsSort[keyof typeof ListToolsSort];
|
export type ListToolsSort = typeof ListToolsSort[keyof typeof ListToolsSort];
|
||||||
@@ -325,8 +395,18 @@ export const ListToolsSort = {
|
|||||||
newest: 'newest',
|
newest: 'newest',
|
||||||
top_rated: 'top_rated',
|
top_rated: 'top_rated',
|
||||||
most_reviewed: 'most_reviewed',
|
most_reviewed: 'most_reviewed',
|
||||||
|
name_asc: 'name_asc',
|
||||||
|
name_desc: 'name_desc',
|
||||||
|
recently_updated: 'recently_updated',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export type ListCompareToolsParams = {
|
||||||
|
/**
|
||||||
|
* Comma-separated tool ids
|
||||||
|
*/
|
||||||
|
ids: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ListTrashedToolsParams = {
|
export type ListTrashedToolsParams = {
|
||||||
search?: string;
|
search?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,19 +25,24 @@ import type {
|
|||||||
AuthMode,
|
AuthMode,
|
||||||
AuthUser,
|
AuthUser,
|
||||||
CategoryStats,
|
CategoryStats,
|
||||||
|
ChangePasswordInput,
|
||||||
EmptyTrash200,
|
EmptyTrash200,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
GetRatingDistributionParams,
|
GetRatingDistributionParams,
|
||||||
GetTopToolsParams,
|
GetTopToolsParams,
|
||||||
HealthStatus,
|
HealthStatus,
|
||||||
ListAuditLogsParams,
|
ListAuditLogsParams,
|
||||||
|
ListCompareToolsParams,
|
||||||
ListToolsParams,
|
ListToolsParams,
|
||||||
ListTrashedToolsParams,
|
ListTrashedToolsParams,
|
||||||
LocalLoginInput,
|
LocalLoginInput,
|
||||||
|
PasswordRedirect,
|
||||||
Rating,
|
Rating,
|
||||||
RatingDistribution,
|
RatingDistribution,
|
||||||
|
RatingHistoryItem,
|
||||||
RatingInput,
|
RatingInput,
|
||||||
RestoreTools200,
|
RestoreTools200,
|
||||||
|
SetPasswordInput,
|
||||||
Tool,
|
Tool,
|
||||||
ToolInput,
|
ToolInput,
|
||||||
ToolUpdate,
|
ToolUpdate,
|
||||||
@@ -47,6 +52,7 @@ import type {
|
|||||||
TrashToolsInput,
|
TrashToolsInput,
|
||||||
User,
|
User,
|
||||||
UserCreateInput,
|
UserCreateInput,
|
||||||
|
UserPreferences,
|
||||||
UserRoleUpdate,
|
UserRoleUpdate,
|
||||||
VersionInfo
|
VersionInfo
|
||||||
} from './api.schemas';
|
} from './api.schemas';
|
||||||
@@ -374,6 +380,167 @@ export const useCreateTool = <TError = ErrorType<ErrorResponse>,
|
|||||||
return useMutation(getCreateToolMutationOptions(options));
|
return useMutation(getCreateToolMutationOptions(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getListCompareToolsUrl = (params: ListCompareToolsParams,) => {
|
||||||
|
const normalizedParams = new URLSearchParams();
|
||||||
|
|
||||||
|
Object.entries(params || {}).forEach(([key, value]) => {
|
||||||
|
|
||||||
|
if (value !== undefined) {
|
||||||
|
normalizedParams.append(key, value === null ? 'null' : value.toString())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const stringifiedParams = normalizedParams.toString();
|
||||||
|
|
||||||
|
return stringifiedParams.length > 0 ? `/api/compare?${stringifiedParams}` : `/api/compare`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Compare tools side by side (premium)
|
||||||
|
*/
|
||||||
|
export const listCompareTools = async (params: ListCompareToolsParams, options?: RequestInit): Promise<ToolWithStats[]> => {
|
||||||
|
|
||||||
|
return customFetch<ToolWithStats[]>(getListCompareToolsUrl(params),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'GET'
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getListCompareToolsQueryKey = (params?: ListCompareToolsParams,) => {
|
||||||
|
return [
|
||||||
|
`/api/compare`, ...(params ? [params] : [])
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getListCompareToolsQueryOptions = <TData = Awaited<ReturnType<typeof listCompareTools>>, TError = ErrorType<ErrorResponse>>(params: ListCompareToolsParams, options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof listCompareTools>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getListCompareToolsQueryKey(params);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof listCompareTools>>> = ({ signal }) => listCompareTools(params, { signal, ...requestOptions });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listCompareTools>>, TError, TData> & { queryKey: QueryKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListCompareToolsQueryResult = NonNullable<Awaited<ReturnType<typeof listCompareTools>>>
|
||||||
|
export type ListCompareToolsQueryError = ErrorType<ErrorResponse>
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Compare tools side by side (premium)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useListCompareTools<TData = Awaited<ReturnType<typeof listCompareTools>>, TError = ErrorType<ErrorResponse>>(
|
||||||
|
params: ListCompareToolsParams, options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof listCompareTools>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||||
|
|
||||||
|
const queryOptions = getListCompareToolsQueryOptions(params,options)
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||||
|
|
||||||
|
return { ...query, queryKey: queryOptions.queryKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetToolRatingHistoryUrl = (id: number,) => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `/api/tools/${id}/rating-history`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get a tool's rating history over time
|
||||||
|
*/
|
||||||
|
export const getToolRatingHistory = async (id: number, options?: RequestInit): Promise<RatingHistoryItem[]> => {
|
||||||
|
|
||||||
|
return customFetch<RatingHistoryItem[]>(getGetToolRatingHistoryUrl(id),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'GET'
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetToolRatingHistoryQueryKey = (id: number,) => {
|
||||||
|
return [
|
||||||
|
`/api/tools/${id}/rating-history`
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetToolRatingHistoryQueryOptions = <TData = Awaited<ReturnType<typeof getToolRatingHistory>>, TError = ErrorType<ErrorResponse>>(id: number, options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getToolRatingHistory>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getGetToolRatingHistoryQueryKey(id);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof getToolRatingHistory>>> = ({ signal }) => getToolRatingHistory(id, { signal, ...requestOptions });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { queryKey, queryFn, enabled: !!(id), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getToolRatingHistory>>, TError, TData> & { queryKey: QueryKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetToolRatingHistoryQueryResult = NonNullable<Awaited<ReturnType<typeof getToolRatingHistory>>>
|
||||||
|
export type GetToolRatingHistoryQueryError = ErrorType<ErrorResponse>
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get a tool's rating history over time
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useGetToolRatingHistory<TData = Awaited<ReturnType<typeof getToolRatingHistory>>, TError = ErrorType<ErrorResponse>>(
|
||||||
|
id: number, options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getToolRatingHistory>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||||
|
|
||||||
|
const queryOptions = getGetToolRatingHistoryQueryOptions(id,options)
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||||
|
|
||||||
|
return { ...query, queryKey: queryOptions.queryKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const getGetToolUrl = (id: number,) => {
|
export const getGetToolUrl = (id: number,) => {
|
||||||
|
|
||||||
|
|
||||||
@@ -1887,6 +2054,379 @@ export function useGetMe<TData = Awaited<ReturnType<typeof getMe>>, TError = Err
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getChangeMyPasswordUrl = () => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `/api/auth/me/password`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Change own password (local users only)
|
||||||
|
*/
|
||||||
|
export const changeMyPassword = async (changePasswordInput: ChangePasswordInput, options?: RequestInit): Promise<void> => {
|
||||||
|
|
||||||
|
return customFetch<void>(getChangeMyPasswordUrl(),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||||
|
body: JSON.stringify(
|
||||||
|
changePasswordInput,)
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getChangeMyPasswordMutationOptions = <TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof changeMyPassword>>, TError,{data: BodyType<ChangePasswordInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof changeMyPassword>>, TError,{data: BodyType<ChangePasswordInput>}, TContext> => {
|
||||||
|
|
||||||
|
const mutationKey = ['changeMyPassword'];
|
||||||
|
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||||
|
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||||
|
options
|
||||||
|
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||||
|
: {mutation: { mutationKey, }, request: undefined};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<Awaited<ReturnType<typeof changeMyPassword>>, {data: BodyType<ChangePasswordInput>}> = (props) => {
|
||||||
|
const {data} = props ?? {};
|
||||||
|
|
||||||
|
return changeMyPassword(data,requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type ChangeMyPasswordMutationResult = NonNullable<Awaited<ReturnType<typeof changeMyPassword>>>
|
||||||
|
export type ChangeMyPasswordMutationBody = BodyType<ChangePasswordInput>
|
||||||
|
export type ChangeMyPasswordMutationError = ErrorType<ErrorResponse>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Change own password (local users only)
|
||||||
|
*/
|
||||||
|
export const useChangeMyPassword = <TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof changeMyPassword>>, TError,{data: BodyType<ChangePasswordInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof changeMyPassword>>,
|
||||||
|
TError,
|
||||||
|
{data: BodyType<ChangePasswordInput>},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
return useMutation(getChangeMyPasswordMutationOptions(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getGetPasswordRedirectUrl = () => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `/api/auth/password-redirect`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get redirect URL for managing credentials in the identity provider
|
||||||
|
*/
|
||||||
|
export const getPasswordRedirect = async ( options?: RequestInit): Promise<PasswordRedirect> => {
|
||||||
|
|
||||||
|
return customFetch<PasswordRedirect>(getGetPasswordRedirectUrl(),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'GET'
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetPasswordRedirectQueryKey = () => {
|
||||||
|
return [
|
||||||
|
`/api/auth/password-redirect`
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetPasswordRedirectQueryOptions = <TData = Awaited<ReturnType<typeof getPasswordRedirect>>, TError = ErrorType<unknown>>( options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getPasswordRedirect>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getGetPasswordRedirectQueryKey();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof getPasswordRedirect>>> = ({ signal }) => getPasswordRedirect({ signal, ...requestOptions });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getPasswordRedirect>>, TError, TData> & { queryKey: QueryKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetPasswordRedirectQueryResult = NonNullable<Awaited<ReturnType<typeof getPasswordRedirect>>>
|
||||||
|
export type GetPasswordRedirectQueryError = ErrorType<unknown>
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get redirect URL for managing credentials in the identity provider
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useGetPasswordRedirect<TData = Awaited<ReturnType<typeof getPasswordRedirect>>, TError = ErrorType<unknown>>(
|
||||||
|
options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getPasswordRedirect>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||||
|
|
||||||
|
const queryOptions = getGetPasswordRedirectQueryOptions(options)
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||||
|
|
||||||
|
return { ...query, queryKey: queryOptions.queryKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetMePreferencesUrl = () => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `/api/auth/me/preferences`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get current user's browse preferences
|
||||||
|
*/
|
||||||
|
export const getMePreferences = async ( options?: RequestInit): Promise<UserPreferences> => {
|
||||||
|
|
||||||
|
return customFetch<UserPreferences>(getGetMePreferencesUrl(),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'GET'
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetMePreferencesQueryKey = () => {
|
||||||
|
return [
|
||||||
|
`/api/auth/me/preferences`
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetMePreferencesQueryOptions = <TData = Awaited<ReturnType<typeof getMePreferences>>, TError = ErrorType<ErrorResponse>>( options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getMePreferences>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getGetMePreferencesQueryKey();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof getMePreferences>>> = ({ signal }) => getMePreferences({ signal, ...requestOptions });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getMePreferences>>, TError, TData> & { queryKey: QueryKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetMePreferencesQueryResult = NonNullable<Awaited<ReturnType<typeof getMePreferences>>>
|
||||||
|
export type GetMePreferencesQueryError = ErrorType<ErrorResponse>
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get current user's browse preferences
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useGetMePreferences<TData = Awaited<ReturnType<typeof getMePreferences>>, TError = ErrorType<ErrorResponse>>(
|
||||||
|
options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getMePreferences>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||||
|
|
||||||
|
const queryOptions = getGetMePreferencesQueryOptions(options)
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||||
|
|
||||||
|
return { ...query, queryKey: queryOptions.queryKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getUpdateMePreferencesUrl = () => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `/api/auth/me/preferences`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Update current user's browse preferences
|
||||||
|
*/
|
||||||
|
export const updateMePreferences = async (userPreferences: UserPreferences, options?: RequestInit): Promise<UserPreferences> => {
|
||||||
|
|
||||||
|
return customFetch<UserPreferences>(getUpdateMePreferencesUrl(),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||||
|
body: JSON.stringify(
|
||||||
|
userPreferences,)
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getUpdateMePreferencesMutationOptions = <TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateMePreferences>>, TError,{data: BodyType<UserPreferences>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof updateMePreferences>>, TError,{data: BodyType<UserPreferences>}, TContext> => {
|
||||||
|
|
||||||
|
const mutationKey = ['updateMePreferences'];
|
||||||
|
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||||
|
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||||
|
options
|
||||||
|
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||||
|
: {mutation: { mutationKey, }, request: undefined};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<Awaited<ReturnType<typeof updateMePreferences>>, {data: BodyType<UserPreferences>}> = (props) => {
|
||||||
|
const {data} = props ?? {};
|
||||||
|
|
||||||
|
return updateMePreferences(data,requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type UpdateMePreferencesMutationResult = NonNullable<Awaited<ReturnType<typeof updateMePreferences>>>
|
||||||
|
export type UpdateMePreferencesMutationBody = BodyType<UserPreferences>
|
||||||
|
export type UpdateMePreferencesMutationError = ErrorType<ErrorResponse>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Update current user's browse preferences
|
||||||
|
*/
|
||||||
|
export const useUpdateMePreferences = <TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateMePreferences>>, TError,{data: BodyType<UserPreferences>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof updateMePreferences>>,
|
||||||
|
TError,
|
||||||
|
{data: BodyType<UserPreferences>},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
return useMutation(getUpdateMePreferencesMutationOptions(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getGetMeWatchlistUrl = () => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `/api/auth/me/watchlist`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get current user's watchlist tools (premium)
|
||||||
|
*/
|
||||||
|
export const getMeWatchlist = async ( options?: RequestInit): Promise<ToolWithStats[]> => {
|
||||||
|
|
||||||
|
return customFetch<ToolWithStats[]>(getGetMeWatchlistUrl(),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'GET'
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetMeWatchlistQueryKey = () => {
|
||||||
|
return [
|
||||||
|
`/api/auth/me/watchlist`
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getGetMeWatchlistQueryOptions = <TData = Awaited<ReturnType<typeof getMeWatchlist>>, TError = ErrorType<ErrorResponse>>( options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getMeWatchlist>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = queryOptions?.queryKey ?? getGetMeWatchlistQueryKey();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof getMeWatchlist>>> = ({ signal }) => getMeWatchlist({ signal, ...requestOptions });
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getMeWatchlist>>, TError, TData> & { queryKey: QueryKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetMeWatchlistQueryResult = NonNullable<Awaited<ReturnType<typeof getMeWatchlist>>>
|
||||||
|
export type GetMeWatchlistQueryError = ErrorType<ErrorResponse>
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get current user's watchlist tools (premium)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function useGetMeWatchlist<TData = Awaited<ReturnType<typeof getMeWatchlist>>, TError = ErrorType<ErrorResponse>>(
|
||||||
|
options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getMeWatchlist>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
|
||||||
|
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||||
|
|
||||||
|
const queryOptions = getGetMeWatchlistQueryOptions(options)
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||||
|
|
||||||
|
return { ...query, queryKey: queryOptions.queryKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const getListUsersUrl = () => {
|
export const getListUsersUrl = () => {
|
||||||
|
|
||||||
|
|
||||||
@@ -2177,6 +2717,78 @@ export const useDeleteUser = <TError = ErrorType<unknown>,
|
|||||||
return useMutation(getDeleteUserMutationOptions(options));
|
return useMutation(getDeleteUserMutationOptions(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getSetUserPasswordUrl = (id: number,) => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return `/api/users/${id}/password`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Set/reset a user's password (admin only, local users only)
|
||||||
|
*/
|
||||||
|
export const setUserPassword = async (id: number,
|
||||||
|
setPasswordInput: SetPasswordInput, options?: RequestInit): Promise<void> => {
|
||||||
|
|
||||||
|
return customFetch<void>(getSetUserPasswordUrl(id),
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||||
|
body: JSON.stringify(
|
||||||
|
setPasswordInput,)
|
||||||
|
}
|
||||||
|
);}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getSetUserPasswordMutationOptions = <TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof setUserPassword>>, TError,{id: number;data: BodyType<SetPasswordInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof setUserPassword>>, TError,{id: number;data: BodyType<SetPasswordInput>}, TContext> => {
|
||||||
|
|
||||||
|
const mutationKey = ['setUserPassword'];
|
||||||
|
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||||
|
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||||
|
options
|
||||||
|
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||||
|
: {mutation: { mutationKey, }, request: undefined};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<Awaited<ReturnType<typeof setUserPassword>>, {id: number;data: BodyType<SetPasswordInput>}> = (props) => {
|
||||||
|
const {id,data} = props ?? {};
|
||||||
|
|
||||||
|
return setUserPassword(id,data,requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type SetUserPasswordMutationResult = NonNullable<Awaited<ReturnType<typeof setUserPassword>>>
|
||||||
|
export type SetUserPasswordMutationBody = BodyType<SetPasswordInput>
|
||||||
|
export type SetUserPasswordMutationError = ErrorType<ErrorResponse>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Set/reset a user's password (admin only, local users only)
|
||||||
|
*/
|
||||||
|
export const useSetUserPassword = <TError = ErrorType<ErrorResponse>,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof setUserPassword>>, TError,{id: number;data: BodyType<SetPasswordInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof setUserPassword>>,
|
||||||
|
TError,
|
||||||
|
{id: number;data: BodyType<SetPasswordInput>},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
return useMutation(getSetUserPasswordMutationOptions(options));
|
||||||
|
}
|
||||||
|
|
||||||
export const getListAuditLogsUrl = (params?: ListAuditLogsParams,) => {
|
export const getListAuditLogsUrl = (params?: ListAuditLogsParams,) => {
|
||||||
const normalizedParams = new URLSearchParams();
|
const normalizedParams = new URLSearchParams();
|
||||||
|
|
||||||
|
|||||||
+306
-1
@@ -72,7 +72,27 @@ paths:
|
|||||||
required: false
|
required: false
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
enum: [newest, top_rated, most_reviewed]
|
enum: [newest, top_rated, most_reviewed, name_asc, name_desc, recently_updated]
|
||||||
|
- name: tags
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: Comma-separated tags; tool must include all of them
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: features
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: Comma-separated features; tool must include all of them
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: minRating
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: Minimum average combined rating (0-5)
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
minimum: 0
|
||||||
|
maximum: 5
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: List of tools
|
description: List of tools
|
||||||
@@ -106,6 +126,67 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ErrorResponse"
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
|
/compare:
|
||||||
|
get:
|
||||||
|
operationId: listCompareTools
|
||||||
|
tags: [tools]
|
||||||
|
summary: Compare tools side by side (premium)
|
||||||
|
parameters:
|
||||||
|
- name: ids
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Comma-separated tool ids
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Tools in requested order
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/ToolWithStats"
|
||||||
|
"401":
|
||||||
|
description: Authentication required
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"403":
|
||||||
|
description: Premium feature required
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
|
/tools/{id}/rating-history:
|
||||||
|
get:
|
||||||
|
operationId: getToolRatingHistory
|
||||||
|
tags: [tools]
|
||||||
|
summary: Get a tool's rating history over time
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Rating history
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/RatingHistoryItem"
|
||||||
|
"400":
|
||||||
|
description: Invalid id
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
/tools/{id}:
|
/tools/{id}:
|
||||||
get:
|
get:
|
||||||
operationId: getTool
|
operationId: getTool
|
||||||
@@ -533,6 +614,127 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ErrorResponse"
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
|
/auth/me/password:
|
||||||
|
post:
|
||||||
|
operationId: changeMyPassword
|
||||||
|
tags: [auth]
|
||||||
|
summary: Change own password (local users only)
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ChangePasswordInput"
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Password changed
|
||||||
|
"400":
|
||||||
|
description: Invalid input or wrong current password
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"401":
|
||||||
|
description: Not authenticated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"422":
|
||||||
|
description: OIDC user - password is managed by the identity provider
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"429":
|
||||||
|
description: Too many attempts
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
|
/auth/password-redirect:
|
||||||
|
get:
|
||||||
|
operationId: getPasswordRedirect
|
||||||
|
tags: [auth]
|
||||||
|
summary: Get redirect URL for managing credentials in the identity provider
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Redirect URL (null in local mode)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PasswordRedirect"
|
||||||
|
|
||||||
|
/auth/me/preferences:
|
||||||
|
get:
|
||||||
|
operationId: getMePreferences
|
||||||
|
tags: [auth]
|
||||||
|
summary: Get current user's browse preferences
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: User preferences
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UserPreferences"
|
||||||
|
"401":
|
||||||
|
description: Not authenticated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
put:
|
||||||
|
operationId: updateMePreferences
|
||||||
|
tags: [auth]
|
||||||
|
summary: Update current user's browse preferences
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UserPreferences"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Updated preferences
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UserPreferences"
|
||||||
|
"401":
|
||||||
|
description: Not authenticated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
|
/auth/me/watchlist:
|
||||||
|
get:
|
||||||
|
operationId: getMeWatchlist
|
||||||
|
tags: [auth]
|
||||||
|
summary: Get current user's watchlist tools (premium)
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Watchlist tools in saved order
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/ToolWithStats"
|
||||||
|
"401":
|
||||||
|
description: Not authenticated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"403":
|
||||||
|
description: Premium feature required
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
/users:
|
/users:
|
||||||
get:
|
get:
|
||||||
operationId: listUsers
|
operationId: listUsers
|
||||||
@@ -633,6 +835,51 @@ paths:
|
|||||||
"204":
|
"204":
|
||||||
description: Deleted
|
description: Deleted
|
||||||
|
|
||||||
|
/users/{id}/password:
|
||||||
|
patch:
|
||||||
|
operationId: setUserPassword
|
||||||
|
tags: [users]
|
||||||
|
summary: Set/reset a user's password (admin only, local users only)
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/SetPasswordInput"
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Password updated
|
||||||
|
"400":
|
||||||
|
description: Validation error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"404":
|
||||||
|
description: User not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"422":
|
||||||
|
description: OIDC user - password is managed by the identity provider
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"429":
|
||||||
|
description: Too many attempts
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
/audit-logs:
|
/audit-logs:
|
||||||
get:
|
get:
|
||||||
operationId: listAuditLogs
|
operationId: listAuditLogs
|
||||||
@@ -720,6 +967,10 @@ components:
|
|||||||
tier:
|
tier:
|
||||||
type: string
|
type: string
|
||||||
enum: [free, premium, enterprise]
|
enum: [free, premium, enterprise]
|
||||||
|
authProvider:
|
||||||
|
type: string
|
||||||
|
enum: [local, oidc]
|
||||||
|
default: local
|
||||||
createdAt:
|
createdAt:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@@ -753,6 +1004,32 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
enum: [free, premium, enterprise]
|
enum: [free, premium, enterprise]
|
||||||
|
|
||||||
|
ChangePasswordInput:
|
||||||
|
type: object
|
||||||
|
required: [currentPassword, newPassword]
|
||||||
|
properties:
|
||||||
|
currentPassword:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
newPassword:
|
||||||
|
type: string
|
||||||
|
minLength: 8
|
||||||
|
|
||||||
|
SetPasswordInput:
|
||||||
|
type: object
|
||||||
|
required: [password]
|
||||||
|
properties:
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
minLength: 8
|
||||||
|
|
||||||
|
PasswordRedirect:
|
||||||
|
type: object
|
||||||
|
required: [url]
|
||||||
|
properties:
|
||||||
|
url:
|
||||||
|
type: ["string", "null"]
|
||||||
|
|
||||||
AuditLog:
|
AuditLog:
|
||||||
type: object
|
type: object
|
||||||
required: [id, entityType, action, userId, username, createdAt]
|
required: [id, entityType, action, userId, username, createdAt]
|
||||||
@@ -813,6 +1090,20 @@ components:
|
|||||||
deletedBy:
|
deletedBy:
|
||||||
type: ["string", "null"]
|
type: ["string", "null"]
|
||||||
|
|
||||||
|
RatingHistoryItem:
|
||||||
|
type: object
|
||||||
|
required: [date, usefulness, usability, combined]
|
||||||
|
properties:
|
||||||
|
date:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
usefulness:
|
||||||
|
type: number
|
||||||
|
usability:
|
||||||
|
type: number
|
||||||
|
combined:
|
||||||
|
type: number
|
||||||
|
|
||||||
ToolWithStats:
|
ToolWithStats:
|
||||||
type: object
|
type: object
|
||||||
required: [id, name, description, category, createdAt, updatedAt, ratingCount, avgUsefulness, avgUsability, avgCombined]
|
required: [id, name, description, category, createdAt, updatedAt, ratingCount, avgUsefulness, avgUsability, avgCombined]
|
||||||
@@ -1047,6 +1338,20 @@ components:
|
|||||||
isLocal:
|
isLocal:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
|
||||||
|
UserPreferences:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
view:
|
||||||
|
type: string
|
||||||
|
enum: [grid, table, rows]
|
||||||
|
density:
|
||||||
|
type: string
|
||||||
|
enum: [cozy, compact]
|
||||||
|
watchlist:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
|
||||||
ErrorResponse:
|
ErrorResponse:
|
||||||
type: object
|
type: object
|
||||||
required: [error]
|
required: [error]
|
||||||
|
|||||||
@@ -32,10 +32,18 @@ export const GetVersionResponse = zod.object({
|
|||||||
/**
|
/**
|
||||||
* @summary List all tools
|
* @summary List all tools
|
||||||
*/
|
*/
|
||||||
|
export const listToolsQueryMinRatingMin = 0;
|
||||||
|
export const listToolsQueryMinRatingMax = 5;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const ListToolsQueryParams = zod.object({
|
export const ListToolsQueryParams = zod.object({
|
||||||
"category": zod.coerce.string().optional(),
|
"category": zod.coerce.string().optional(),
|
||||||
"search": zod.coerce.string().optional(),
|
"search": zod.coerce.string().optional(),
|
||||||
"sort": zod.enum(['newest', 'top_rated', 'most_reviewed']).optional()
|
"sort": zod.enum(['newest', 'top_rated', 'most_reviewed', 'name_asc', 'name_desc', 'recently_updated']).optional(),
|
||||||
|
"tags": zod.coerce.string().optional().describe('Comma-separated tags; tool must include all of them'),
|
||||||
|
"features": zod.coerce.string().optional().describe('Comma-separated features; tool must include all of them'),
|
||||||
|
"minRating": zod.coerce.number().min(listToolsQueryMinRatingMin).max(listToolsQueryMinRatingMax).optional().describe('Minimum average combined rating (0-5)')
|
||||||
})
|
})
|
||||||
|
|
||||||
export const ListToolsResponseItem = zod.object({
|
export const ListToolsResponseItem = zod.object({
|
||||||
@@ -77,6 +85,49 @@ export const CreateToolBody = zod.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Compare tools side by side (premium)
|
||||||
|
*/
|
||||||
|
export const ListCompareToolsQueryParams = zod.object({
|
||||||
|
"ids": zod.coerce.string().describe('Comma-separated tool ids')
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ListCompareToolsResponseItem = zod.object({
|
||||||
|
"id": zod.number(),
|
||||||
|
"name": zod.string(),
|
||||||
|
"description": zod.string(),
|
||||||
|
"category": zod.string(),
|
||||||
|
"websiteUrl": zod.string().nullish(),
|
||||||
|
"iconUrl": zod.string().nullish(),
|
||||||
|
"createdBy": zod.string().nullish(),
|
||||||
|
"features": zod.array(zod.string()).optional(),
|
||||||
|
"tags": zod.array(zod.string()).optional(),
|
||||||
|
"createdAt": zod.coerce.date(),
|
||||||
|
"updatedAt": zod.coerce.date(),
|
||||||
|
"ratingCount": zod.number(),
|
||||||
|
"avgUsefulness": zod.number().nullable(),
|
||||||
|
"avgUsability": zod.number().nullable(),
|
||||||
|
"avgCombined": zod.number().nullable()
|
||||||
|
})
|
||||||
|
export const ListCompareToolsResponse = zod.array(ListCompareToolsResponseItem)
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get a tool's rating history over time
|
||||||
|
*/
|
||||||
|
export const GetToolRatingHistoryParams = zod.object({
|
||||||
|
"id": zod.coerce.number()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const GetToolRatingHistoryResponseItem = zod.object({
|
||||||
|
"date": zod.coerce.date(),
|
||||||
|
"usefulness": zod.number(),
|
||||||
|
"usability": zod.number(),
|
||||||
|
"combined": zod.number()
|
||||||
|
})
|
||||||
|
export const GetToolRatingHistoryResponse = zod.array(GetToolRatingHistoryResponseItem)
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Get a tool by ID
|
* @summary Get a tool by ID
|
||||||
*/
|
*/
|
||||||
@@ -429,15 +480,89 @@ export const GetMeResponse = zod.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Change own password (local users only)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const changeMyPasswordBodyNewPasswordMin = 8;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const ChangeMyPasswordBody = zod.object({
|
||||||
|
"currentPassword": zod.string().min(1),
|
||||||
|
"newPassword": zod.string().min(changeMyPasswordBodyNewPasswordMin)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get redirect URL for managing credentials in the identity provider
|
||||||
|
*/
|
||||||
|
export const GetPasswordRedirectResponse = zod.object({
|
||||||
|
"url": zod.string().nullable()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get current user's browse preferences
|
||||||
|
*/
|
||||||
|
export const GetMePreferencesResponse = zod.object({
|
||||||
|
"view": zod.enum(['grid', 'table', 'rows']).optional(),
|
||||||
|
"density": zod.enum(['cozy', 'compact']).optional(),
|
||||||
|
"watchlist": zod.array(zod.number()).optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Update current user's browse preferences
|
||||||
|
*/
|
||||||
|
export const UpdateMePreferencesBody = zod.object({
|
||||||
|
"view": zod.enum(['grid', 'table', 'rows']).optional(),
|
||||||
|
"density": zod.enum(['cozy', 'compact']).optional(),
|
||||||
|
"watchlist": zod.array(zod.number()).optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const UpdateMePreferencesResponse = zod.object({
|
||||||
|
"view": zod.enum(['grid', 'table', 'rows']).optional(),
|
||||||
|
"density": zod.enum(['cozy', 'compact']).optional(),
|
||||||
|
"watchlist": zod.array(zod.number()).optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get current user's watchlist tools (premium)
|
||||||
|
*/
|
||||||
|
export const GetMeWatchlistResponseItem = zod.object({
|
||||||
|
"id": zod.number(),
|
||||||
|
"name": zod.string(),
|
||||||
|
"description": zod.string(),
|
||||||
|
"category": zod.string(),
|
||||||
|
"websiteUrl": zod.string().nullish(),
|
||||||
|
"iconUrl": zod.string().nullish(),
|
||||||
|
"createdBy": zod.string().nullish(),
|
||||||
|
"features": zod.array(zod.string()).optional(),
|
||||||
|
"tags": zod.array(zod.string()).optional(),
|
||||||
|
"createdAt": zod.coerce.date(),
|
||||||
|
"updatedAt": zod.coerce.date(),
|
||||||
|
"ratingCount": zod.number(),
|
||||||
|
"avgUsefulness": zod.number().nullable(),
|
||||||
|
"avgUsability": zod.number().nullable(),
|
||||||
|
"avgCombined": zod.number().nullable()
|
||||||
|
})
|
||||||
|
export const GetMeWatchlistResponse = zod.array(GetMeWatchlistResponseItem)
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary List all local users (admin only)
|
* @summary List all local users (admin only)
|
||||||
*/
|
*/
|
||||||
|
export const listUsersResponseAuthProviderDefault = `local`;
|
||||||
|
|
||||||
export const ListUsersResponseItem = zod.object({
|
export const ListUsersResponseItem = zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.number(),
|
||||||
"username": zod.string(),
|
"username": zod.string(),
|
||||||
"email": zod.string().nullish(),
|
"email": zod.string().nullish(),
|
||||||
"role": zod.enum(['admin', 'user']),
|
"role": zod.enum(['admin', 'user']),
|
||||||
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
|
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
|
||||||
|
"authProvider": zod.enum(['local', 'oidc']).default(listUsersResponseAuthProviderDefault),
|
||||||
"createdAt": zod.coerce.date()
|
"createdAt": zod.coerce.date()
|
||||||
})
|
})
|
||||||
export const ListUsersResponse = zod.array(ListUsersResponseItem)
|
export const ListUsersResponse = zod.array(ListUsersResponseItem)
|
||||||
@@ -473,12 +598,15 @@ export const UpdateUserBody = zod.object({
|
|||||||
"tier": zod.enum(['free', 'premium', 'enterprise']).optional()
|
"tier": zod.enum(['free', 'premium', 'enterprise']).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const updateUserResponseAuthProviderDefault = `local`;
|
||||||
|
|
||||||
export const UpdateUserResponse = zod.object({
|
export const UpdateUserResponse = zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.number(),
|
||||||
"username": zod.string(),
|
"username": zod.string(),
|
||||||
"email": zod.string().nullish(),
|
"email": zod.string().nullish(),
|
||||||
"role": zod.enum(['admin', 'user']),
|
"role": zod.enum(['admin', 'user']),
|
||||||
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
|
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
|
||||||
|
"authProvider": zod.enum(['local', 'oidc']).default(updateUserResponseAuthProviderDefault),
|
||||||
"createdAt": zod.coerce.date()
|
"createdAt": zod.coerce.date()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -491,6 +619,22 @@ export const DeleteUserParams = zod.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Set/reset a user's password (admin only, local users only)
|
||||||
|
*/
|
||||||
|
export const SetUserPasswordParams = zod.object({
|
||||||
|
"id": zod.coerce.number()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const setUserPasswordBodyPasswordMin = 8;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const SetUserPasswordBody = zod.object({
|
||||||
|
"password": zod.string().min(setUserPasswordBodyPasswordMin)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary List audit log entries (admin only)
|
* @summary List audit log entries (admin only)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.9.1 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ChangePasswordInput {
|
||||||
|
/** @minLength 1 */
|
||||||
|
currentPassword: string;
|
||||||
|
/** @minLength 8 */
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ export * from './authUser';
|
|||||||
export * from './authUserRole';
|
export * from './authUserRole';
|
||||||
export * from './authUserTier';
|
export * from './authUserTier';
|
||||||
export * from './categoryStats';
|
export * from './categoryStats';
|
||||||
|
export * from './changePasswordInput';
|
||||||
export * from './emptyTrash200';
|
export * from './emptyTrash200';
|
||||||
export * from './errorResponse';
|
export * from './errorResponse';
|
||||||
export * from './getRatingDistributionParams';
|
export * from './getRatingDistributionParams';
|
||||||
@@ -21,15 +22,19 @@ export * from './getTopToolsMetric';
|
|||||||
export * from './getTopToolsParams';
|
export * from './getTopToolsParams';
|
||||||
export * from './healthStatus';
|
export * from './healthStatus';
|
||||||
export * from './listAuditLogsParams';
|
export * from './listAuditLogsParams';
|
||||||
|
export * from './listCompareToolsParams';
|
||||||
export * from './listToolsParams';
|
export * from './listToolsParams';
|
||||||
export * from './listToolsSort';
|
export * from './listToolsSort';
|
||||||
export * from './listTrashedToolsParams';
|
export * from './listTrashedToolsParams';
|
||||||
export * from './localLoginInput';
|
export * from './localLoginInput';
|
||||||
|
export * from './passwordRedirect';
|
||||||
export * from './rating';
|
export * from './rating';
|
||||||
export * from './ratingDistribution';
|
export * from './ratingDistribution';
|
||||||
|
export * from './ratingHistoryItem';
|
||||||
export * from './ratingInput';
|
export * from './ratingInput';
|
||||||
export * from './restoreTools200';
|
export * from './restoreTools200';
|
||||||
export * from './scoreBucket';
|
export * from './scoreBucket';
|
||||||
|
export * from './setPasswordInput';
|
||||||
export * from './tool';
|
export * from './tool';
|
||||||
export * from './toolInput';
|
export * from './toolInput';
|
||||||
export * from './toolUpdate';
|
export * from './toolUpdate';
|
||||||
@@ -38,9 +43,13 @@ export * from './topToolEntry';
|
|||||||
export * from './trashTools200';
|
export * from './trashTools200';
|
||||||
export * from './trashToolsInput';
|
export * from './trashToolsInput';
|
||||||
export * from './user';
|
export * from './user';
|
||||||
|
export * from './userAuthProvider';
|
||||||
export * from './userCreateInput';
|
export * from './userCreateInput';
|
||||||
export * from './userCreateInputRole';
|
export * from './userCreateInputRole';
|
||||||
export * from './userCreateInputTier';
|
export * from './userCreateInputTier';
|
||||||
|
export * from './userPreferences';
|
||||||
|
export * from './userPreferencesDensity';
|
||||||
|
export * from './userPreferencesView';
|
||||||
export * from './userRole';
|
export * from './userRole';
|
||||||
export * from './userRoleUpdate';
|
export * from './userRoleUpdate';
|
||||||
export * from './userRoleUpdateRole';
|
export * from './userRoleUpdateRole';
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.9.1 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ListCompareToolsParams = {
|
||||||
|
/**
|
||||||
|
* Comma-separated tool ids
|
||||||
|
*/
|
||||||
|
ids: string;
|
||||||
|
};
|
||||||
@@ -11,4 +11,18 @@ export type ListToolsParams = {
|
|||||||
category?: string;
|
category?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
sort?: ListToolsSort;
|
sort?: ListToolsSort;
|
||||||
|
/**
|
||||||
|
* Comma-separated tags; tool must include all of them
|
||||||
|
*/
|
||||||
|
tags?: string;
|
||||||
|
/**
|
||||||
|
* Comma-separated features; tool must include all of them
|
||||||
|
*/
|
||||||
|
features?: string;
|
||||||
|
/**
|
||||||
|
* Minimum average combined rating (0-5)
|
||||||
|
* @minimum 0
|
||||||
|
* @maximum 5
|
||||||
|
*/
|
||||||
|
minRating?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,4 +13,7 @@ export const ListToolsSort = {
|
|||||||
newest: 'newest',
|
newest: 'newest',
|
||||||
top_rated: 'top_rated',
|
top_rated: 'top_rated',
|
||||||
most_reviewed: 'most_reviewed',
|
most_reviewed: 'most_reviewed',
|
||||||
|
name_asc: 'name_asc',
|
||||||
|
name_desc: 'name_desc',
|
||||||
|
recently_updated: 'recently_updated',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.9.1 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PasswordRedirect {
|
||||||
|
/** @nullable */
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.9.1 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RatingHistoryItem {
|
||||||
|
date: Date;
|
||||||
|
usefulness: number;
|
||||||
|
usability: number;
|
||||||
|
combined: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.9.1 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SetPasswordInput {
|
||||||
|
/** @minLength 8 */
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
import type { UserAuthProvider } from './userAuthProvider';
|
||||||
import type { UserRole } from './userRole';
|
import type { UserRole } from './userRole';
|
||||||
import type { UserTier } from './userTier';
|
import type { UserTier } from './userTier';
|
||||||
|
|
||||||
@@ -15,5 +16,6 @@ export interface User {
|
|||||||
email?: string | null;
|
email?: string | null;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
tier?: UserTier;
|
tier?: UserTier;
|
||||||
|
authProvider?: UserAuthProvider;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.9.1 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type UserAuthProvider = typeof UserAuthProvider[keyof typeof UserAuthProvider];
|
||||||
|
|
||||||
|
|
||||||
|
export const UserAuthProvider = {
|
||||||
|
local: 'local',
|
||||||
|
oidc: 'oidc',
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.9.1 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
import type { UserPreferencesDensity } from './userPreferencesDensity';
|
||||||
|
import type { UserPreferencesView } from './userPreferencesView';
|
||||||
|
|
||||||
|
export interface UserPreferences {
|
||||||
|
view?: UserPreferencesView;
|
||||||
|
density?: UserPreferencesDensity;
|
||||||
|
watchlist?: number[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.9.1 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type UserPreferencesDensity = typeof UserPreferencesDensity[keyof typeof UserPreferencesDensity];
|
||||||
|
|
||||||
|
|
||||||
|
export const UserPreferencesDensity = {
|
||||||
|
cozy: 'cozy',
|
||||||
|
compact: 'compact',
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.9.1 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type UserPreferencesView = typeof UserPreferencesView[keyof typeof UserPreferencesView];
|
||||||
|
|
||||||
|
|
||||||
|
export const UserPreferencesView = {
|
||||||
|
grid: 'grid',
|
||||||
|
table: 'table',
|
||||||
|
rows: 'rows',
|
||||||
|
} as const;
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import { pgTable, text, serial, timestamp } from "drizzle-orm/pg-core";
|
import { pgTable, text, serial, timestamp, jsonb } from "drizzle-orm/pg-core";
|
||||||
import { createInsertSchema } from "drizzle-zod";
|
import { createInsertSchema } from "drizzle-zod";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
export type UserPreferences = {
|
||||||
|
view?: "grid" | "table" | "rows";
|
||||||
|
density?: "cozy" | "compact";
|
||||||
|
watchlist?: number[];
|
||||||
|
};
|
||||||
|
|
||||||
export const usersTable = pgTable("users", {
|
export const usersTable = pgTable("users", {
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
username: text("username").notNull().unique(),
|
username: text("username").notNull().unique(),
|
||||||
@@ -12,6 +18,7 @@ export const usersTable = pgTable("users", {
|
|||||||
authProvider: text("auth_provider").notNull().default("local"),
|
authProvider: text("auth_provider").notNull().default("local"),
|
||||||
authProviderId: text("auth_provider_id"),
|
authProviderId: text("auth_provider_id"),
|
||||||
displayName: text("display_name"),
|
displayName: text("display_name"),
|
||||||
|
preferences: jsonb("preferences").$type<UserPreferences>().notNull().default({}),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -15,8 +15,19 @@
|
|||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"supportedArchitectures": {
|
"supportedArchitectures": {
|
||||||
"os": ["current", "linux"],
|
"os": [
|
||||||
"cpu": ["current", "x64"]
|
"current",
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"cpu": [
|
||||||
|
"current",
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@rollup/rollup-darwin-arm64": "^4.62.4",
|
||||||
|
"@tailwindcss/oxide-darwin-arm64": "^4.3.3",
|
||||||
|
"lightningcss-darwin-arm64": "^1.33.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+133
@@ -21,6 +21,9 @@ catalogs:
|
|||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.90.21
|
specifier: ^5.90.21
|
||||||
version: 5.100.9
|
version: 5.100.9
|
||||||
|
'@tanstack/react-virtual':
|
||||||
|
specifier: ^3.13.6
|
||||||
|
version: 3.14.9
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^25.3.3
|
specifier: ^25.3.3
|
||||||
version: 25.6.2
|
version: 25.6.2
|
||||||
@@ -164,6 +167,16 @@ importers:
|
|||||||
typescript:
|
typescript:
|
||||||
specifier: ~5.9.3
|
specifier: ~5.9.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
optionalDependencies:
|
||||||
|
'@rollup/rollup-darwin-arm64':
|
||||||
|
specifier: ^4.62.4
|
||||||
|
version: 4.62.4
|
||||||
|
'@tailwindcss/oxide-darwin-arm64':
|
||||||
|
specifier: ^4.3.3
|
||||||
|
version: 4.3.3
|
||||||
|
lightningcss-darwin-arm64:
|
||||||
|
specifier: ^1.33.0
|
||||||
|
version: 1.33.0
|
||||||
|
|
||||||
artifacts/api-server:
|
artifacts/api-server:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -191,6 +204,9 @@ importers:
|
|||||||
express:
|
express:
|
||||||
specifier: ^5.2.1
|
specifier: ^5.2.1
|
||||||
version: 5.2.1
|
version: 5.2.1
|
||||||
|
express-rate-limit:
|
||||||
|
specifier: ^8.6.1
|
||||||
|
version: 8.6.1(express@5.2.1)
|
||||||
express-session:
|
express-session:
|
||||||
specifier: ^1.19.0
|
specifier: ^1.19.0
|
||||||
version: 1.19.0
|
version: 1.19.0
|
||||||
@@ -528,6 +544,9 @@ importers:
|
|||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 5.100.9(react@19.1.0)
|
version: 5.100.9(react@19.1.0)
|
||||||
|
'@tanstack/react-virtual':
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 3.14.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 25.6.2
|
version: 25.6.2
|
||||||
@@ -561,6 +580,9 @@ importers:
|
|||||||
framer-motion:
|
framer-motion:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 12.38.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 12.38.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
|
i18next:
|
||||||
|
specifier: ^26.3.6
|
||||||
|
version: 26.3.6(typescript@5.9.3)
|
||||||
input-otp:
|
input-otp:
|
||||||
specifier: ^1.4.2
|
specifier: ^1.4.2
|
||||||
version: 1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
version: 1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||||
@@ -582,6 +604,9 @@ importers:
|
|||||||
react-hook-form:
|
react-hook-form:
|
||||||
specifier: ^7.55.0
|
specifier: ^7.55.0
|
||||||
version: 7.75.0(react@19.1.0)
|
version: 7.75.0(react@19.1.0)
|
||||||
|
react-i18next:
|
||||||
|
specifier: ^17.0.11
|
||||||
|
version: 17.0.11(i18next@26.3.6(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.3)
|
||||||
react-icons:
|
react-icons:
|
||||||
specifier: ^5.4.0
|
specifier: ^5.4.0
|
||||||
version: 5.6.0(react@19.1.0)
|
version: 5.6.0(react@19.1.0)
|
||||||
@@ -1534,6 +1559,11 @@ packages:
|
|||||||
'@rolldown/pluginutils@1.0.0-rc.3':
|
'@rolldown/pluginutils@1.0.0-rc.3':
|
||||||
resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==}
|
resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==}
|
||||||
|
|
||||||
|
'@rollup/rollup-darwin-arm64@4.62.4':
|
||||||
|
resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.60.3':
|
'@rollup/rollup-linux-x64-gnu@4.60.3':
|
||||||
resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==}
|
resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
@@ -1597,6 +1627,12 @@ packages:
|
|||||||
'@tailwindcss/node@4.3.0':
|
'@tailwindcss/node@4.3.0':
|
||||||
resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==}
|
resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==}
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-darwin-arm64@4.3.3':
|
||||||
|
resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
|
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
|
||||||
resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
|
resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
@@ -1638,6 +1674,15 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18 || ^19
|
react: ^18 || ^19
|
||||||
|
|
||||||
|
'@tanstack/react-virtual@3.14.9':
|
||||||
|
resolution: {integrity: sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.17.7':
|
||||||
|
resolution: {integrity: sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==}
|
||||||
|
|
||||||
'@types/babel__core@7.20.5':
|
'@types/babel__core@7.20.5':
|
||||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||||
|
|
||||||
@@ -2204,6 +2249,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
|
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
|
||||||
engines: {node: ^18.19.0 || >=20.5.0}
|
engines: {node: ^18.19.0 || >=20.5.0}
|
||||||
|
|
||||||
|
express-rate-limit@8.6.1:
|
||||||
|
resolution: {integrity: sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==}
|
||||||
|
engines: {node: '>= 16'}
|
||||||
|
peerDependencies:
|
||||||
|
express: '>= 4.11'
|
||||||
|
|
||||||
express-session@1.19.0:
|
express-session@1.19.0:
|
||||||
resolution: {integrity: sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==}
|
resolution: {integrity: sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -2347,6 +2398,9 @@ packages:
|
|||||||
help-me@5.0.0:
|
help-me@5.0.0:
|
||||||
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
|
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
|
||||||
|
|
||||||
|
html-parse-stringify@4.0.1:
|
||||||
|
resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==}
|
||||||
|
|
||||||
http-errors@2.0.1:
|
http-errors@2.0.1:
|
||||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -2355,6 +2409,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
|
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
|
|
||||||
|
i18next@26.3.6:
|
||||||
|
resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: ^5 || ^6 || ^7
|
||||||
|
peerDependenciesMeta:
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
|
||||||
iconv-lite@0.7.2:
|
iconv-lite@0.7.2:
|
||||||
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -2376,6 +2438,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
ip-address@10.4.0:
|
||||||
|
resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==}
|
||||||
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
ipaddr.js@1.9.1:
|
ipaddr.js@1.9.1:
|
||||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
@@ -2456,6 +2522,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==}
|
resolution: {integrity: sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
|
|
||||||
|
lightningcss-darwin-arm64@1.33.0:
|
||||||
|
resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
lightningcss-linux-x64-gnu@1.32.0:
|
lightningcss-linux-x64-gnu@1.32.0:
|
||||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -2809,6 +2881,22 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||||
|
|
||||||
|
react-i18next@17.0.11:
|
||||||
|
resolution: {integrity: sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==}
|
||||||
|
peerDependencies:
|
||||||
|
i18next: '>= 26.2.0'
|
||||||
|
react: '>= 16.8.0'
|
||||||
|
react-dom: '*'
|
||||||
|
react-native: '*'
|
||||||
|
typescript: ^5 || ^6 || ^7
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
react-native:
|
||||||
|
optional: true
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
|
||||||
react-icons@5.6.0:
|
react-icons@5.6.0:
|
||||||
resolution: {integrity: sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==}
|
resolution: {integrity: sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3065,6 +3153,7 @@ packages:
|
|||||||
tsconfck@3.1.6:
|
tsconfck@3.1.6:
|
||||||
resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==}
|
resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==}
|
||||||
engines: {node: ^18 || >=20}
|
engines: {node: ^18 || >=20}
|
||||||
|
deprecated: unmaintained
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: ^5.0.0
|
typescript: ^5.0.0
|
||||||
@@ -4274,6 +4363,9 @@ snapshots:
|
|||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-rc.3': {}
|
'@rolldown/pluginutils@1.0.0-rc.3': {}
|
||||||
|
|
||||||
|
'@rollup/rollup-darwin-arm64@4.62.4':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.60.3':
|
'@rollup/rollup-linux-x64-gnu@4.60.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4348,6 +4440,9 @@ snapshots:
|
|||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
tailwindcss: 4.3.0
|
tailwindcss: 4.3.0
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-darwin-arm64@4.3.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
|
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4378,6 +4473,14 @@ snapshots:
|
|||||||
'@tanstack/query-core': 5.100.9
|
'@tanstack/query-core': 5.100.9
|
||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
|
|
||||||
|
'@tanstack/react-virtual@3.14.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/virtual-core': 3.17.7
|
||||||
|
react: 19.1.0
|
||||||
|
react-dom: 19.1.0(react@19.1.0)
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.17.7': {}
|
||||||
|
|
||||||
'@types/babel__core@7.20.5':
|
'@types/babel__core@7.20.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/parser': 7.29.3
|
'@babel/parser': 7.29.3
|
||||||
@@ -4845,6 +4948,14 @@ snapshots:
|
|||||||
strip-final-newline: 4.0.0
|
strip-final-newline: 4.0.0
|
||||||
yoctocolors: 2.1.2
|
yoctocolors: 2.1.2
|
||||||
|
|
||||||
|
express-rate-limit@8.6.1(express@5.2.1):
|
||||||
|
dependencies:
|
||||||
|
debug: 4.4.3
|
||||||
|
express: 5.2.1
|
||||||
|
ip-address: 10.4.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
express-session@1.19.0:
|
express-session@1.19.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
cookie: 0.7.2
|
cookie: 0.7.2
|
||||||
@@ -5023,6 +5134,8 @@ snapshots:
|
|||||||
|
|
||||||
help-me@5.0.0: {}
|
help-me@5.0.0: {}
|
||||||
|
|
||||||
|
html-parse-stringify@4.0.1: {}
|
||||||
|
|
||||||
http-errors@2.0.1:
|
http-errors@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
depd: 2.0.0
|
depd: 2.0.0
|
||||||
@@ -5033,6 +5146,10 @@ snapshots:
|
|||||||
|
|
||||||
human-signals@8.0.1: {}
|
human-signals@8.0.1: {}
|
||||||
|
|
||||||
|
i18next@26.3.6(typescript@5.9.3):
|
||||||
|
optionalDependencies:
|
||||||
|
typescript: 5.9.3
|
||||||
|
|
||||||
iconv-lite@0.7.2:
|
iconv-lite@0.7.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
@@ -5048,6 +5165,8 @@ snapshots:
|
|||||||
|
|
||||||
internmap@2.0.3: {}
|
internmap@2.0.3: {}
|
||||||
|
|
||||||
|
ip-address@10.4.0: {}
|
||||||
|
|
||||||
ipaddr.js@1.9.1: {}
|
ipaddr.js@1.9.1: {}
|
||||||
|
|
||||||
is-extglob@2.1.1: {}
|
is-extglob@2.1.1: {}
|
||||||
@@ -5098,6 +5217,9 @@ snapshots:
|
|||||||
|
|
||||||
leven@4.1.0: {}
|
leven@4.1.0: {}
|
||||||
|
|
||||||
|
lightningcss-darwin-arm64@1.33.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
lightningcss-linux-x64-gnu@1.32.0:
|
lightningcss-linux-x64-gnu@1.32.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -5461,6 +5583,17 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
|
|
||||||
|
react-i18next@17.0.11(i18next@26.3.6(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.3):
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.29.2
|
||||||
|
html-parse-stringify: 4.0.1
|
||||||
|
i18next: 26.3.6(typescript@5.9.3)
|
||||||
|
react: 19.1.0
|
||||||
|
use-sync-external-store: 1.6.0(react@19.1.0)
|
||||||
|
optionalDependencies:
|
||||||
|
react-dom: 19.1.0(react@19.1.0)
|
||||||
|
typescript: 5.9.3
|
||||||
|
|
||||||
react-icons@5.6.0(react@19.1.0):
|
react-icons@5.6.0(react@19.1.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ catalog:
|
|||||||
'@replit/vite-plugin-runtime-error-modal': ^0.0.6
|
'@replit/vite-plugin-runtime-error-modal': ^0.0.6
|
||||||
'@tailwindcss/vite': ^4.1.14
|
'@tailwindcss/vite': ^4.1.14
|
||||||
'@tanstack/react-query': ^5.90.21
|
'@tanstack/react-query': ^5.90.21
|
||||||
|
'@tanstack/react-virtual': ^3.13.6
|
||||||
'@types/node': ^25.3.3
|
'@types/node': ^25.3.3
|
||||||
'@types/react': ^19.2.0
|
'@types/react': ^19.2.0
|
||||||
'@types/react-dom': ^19.2.0
|
'@types/react-dom': ^19.2.0
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
pnpm --filter db push
|
|
||||||
Reference in New Issue
Block a user