Compare commits

..

4 Commits

Author SHA1 Message Date
opencode ca980fce09 docs: add v0.8.6 release notes
Build & Push Docker Image / build (push) Successful in 2m16s
2026-08-04 23:13:09 +02:00
opencode 8f2fd89847 feat(import): bulk tool import (CSV/JSON/YAML) for admins; NetBox-style help button
Build & Push Docker Image / build (push) Successful in 2m49s
- Add POST /admin/tools/import with format auto-detect, CSV delimiters
  (comma/semicolon/tab), per-row validation via CreateToolBody, bulk insert,
  audit log entries per imported tool; gated by new 'tool-import' feature
  flag (premium/enterprise; admins always pass)
- Add tool-import-dialog UI (format tabs, delimiter select, textarea, file
  upload, result/error list) behind hasFeature('tool-import')
- Replace FieldHelp question marks and bare GuideHelp links with a NetBox-style
  'Hilfe/Help' outline button (HelpCircle + text) in form headers only
- Sync locales to 482 keys per language (de/en), update handbook docs
  (administration import section, index/plaene feature tables), regenerate
  API client + zod schemas, add yaml dependency
2026-08-04 23:09:11 +02:00
opencode d47db6d386 docs: add v0.8.5 release notes
Build & Push Docker Image / build (push) Successful in 3m43s
2026-08-04 21:01:58 +02:00
opencode 70e72dac71 fix(i18n): translate submit button, density/view toggles; rename watchlist guide to Merkliste; add guide help buttons
Build & Push Docker Image / build (push) Successful in 2m31s
2026-08-04 20:43:36 +02:00
53 changed files with 1135 additions and 175 deletions
+1
View File
@@ -23,6 +23,7 @@
"openid-client": "6.8.4",
"pino": "10.3.1",
"pino-http": "11.0.0",
"yaml": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
@@ -2,8 +2,8 @@ import { type Request, type Response, type NextFunction } from "express";
const TIER_FEATURES: Record<string, string[]> = {
free: ["browse", "rate", "search"],
premium: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "compare", "watchlist"],
enterprise: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "compare", "watchlist", "sso", "audit-export", "api-access"],
premium: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "compare", "watchlist", "tool-import"],
enterprise: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "compare", "watchlist", "tool-import", "sso", "audit-export", "api-access"],
};
export function getEntitlements(tier: string | undefined, role: string | undefined): string[] {
+183
View File
@@ -1,9 +1,12 @@
import { Router, type IRouter } from "express";
import { eq, and, sql } from "drizzle-orm";
import { z } from "zod";
import { parse as parseYaml } from "yaml";
import { db, toolsTable, ratingsTable, toolCostsTable, toolRelationsTable } from "@workspace/db";
import { requireAdmin } from "../middleware/auth";
import { requireFeature } from "../middleware/feature";
import { writeAuditLog } from "../lib/audit";
import { CreateToolBody } from "@workspace/api-zod";
const EvaluateBody = z.object({
toolId: z.coerce.number().int().positive(),
@@ -219,4 +222,184 @@ router.post("/admin/redundancy/evaluate", requireAdmin, async (req, res): Promis
res.json({ ok: true });
});
const ToolImportBody = z.object({
format: z.enum(["auto", "csv", "json", "yaml"]).default("auto"),
delimiter: z.enum(["auto", "comma", "semicolon", "tab"]).default("auto"),
data: z.string().min(1),
});
const TOOL_FIELDS = ["name", "description", "category", "websiteUrl", "iconUrl", "features", "tags"] as const;
function splitList(value: unknown): string[] {
if (Array.isArray(value)) {
return value.map((v) => String(v).trim()).filter(Boolean);
}
if (typeof value === "string") {
return value
.split(/[|;,]/)
.map((v) => v.trim())
.filter(Boolean);
}
return [];
}
function normalizeTool(raw: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const key of TOOL_FIELDS) {
if (key in raw) out[key] = raw[key];
}
if ("features" in out) out.features = splitList(out.features);
if ("tags" in out) out.tags = splitList(out.tags);
return out;
}
function parseCsv(data: string, delimiter: string): Record<string, unknown>[] {
let sep = ",";
if (delimiter === "semicolon") sep = ";";
else if (delimiter === "tab") sep = "\t";
else if (delimiter === "auto") {
const firstLine = data.split(/\r?\n/)[0] ?? "";
const candidates = [",", ";", "\t"];
const counts = candidates.map((c) => (firstLine.match(new RegExp(`\\${c === "\t" ? "\\t" : c}`, "g")) ?? []).length);
const max = Math.max(...counts);
if (max > 0) sep = candidates[counts.indexOf(max)];
}
const lines = data.split(/\r?\n/).filter((l) => l.trim().length > 0);
if (lines.length === 0) return [];
const header = parseCsvLine(lines[0], sep);
const rows: Record<string, unknown>[] = [];
for (let i = 1; i < lines.length; i++) {
const cells = parseCsvLine(lines[i], sep);
const row: Record<string, unknown> = {};
header.forEach((h, idx) => {
const key = h.trim();
if (key) row[key] = cells[idx] ?? "";
});
rows.push(row);
}
return rows;
}
function parseCsvLine(line: string, sep: string): string[] {
const cells: string[] = [];
let cur = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
if (ch === '"') {
if (line[i + 1] === '"') {
cur += '"';
i++;
} else {
inQuotes = false;
}
} else {
cur += ch;
}
} else if (ch === '"') {
inQuotes = true;
} else if (ch === sep) {
cells.push(cur);
cur = "";
} else {
cur += ch;
}
}
cells.push(cur);
return cells;
}
router.post(
"/admin/tools/import",
requireAdmin,
requireFeature("tool-import"),
async (req, res): Promise<void> => {
const parsed = ToolImportBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const { format, delimiter, data } = parsed.data;
let rawRows: Record<string, unknown>[] = [];
let effectiveFormat = format;
if (format === "auto") {
const trimmed = data.trimStart();
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
effectiveFormat = "json";
} else if (trimmed.includes(":") && !trimmed.startsWith("\"")) {
effectiveFormat = "yaml";
} else {
effectiveFormat = "csv";
}
}
try {
if (effectiveFormat === "json") {
const json = JSON.parse(data);
if (Array.isArray(json)) {
rawRows = json.filter((r) => r && typeof r === "object") as Record<string, unknown>[];
} else if (json && typeof json === "object") {
rawRows = [json as Record<string, unknown>];
} else {
res.status(400).json({ error: "JSON data must be an object or an array of objects" });
return;
}
} else if (effectiveFormat === "yaml") {
const yaml = parseYaml(data);
if (Array.isArray(yaml)) {
rawRows = yaml.filter((r) => r && typeof r === "object") as Record<string, unknown>[];
} else if (yaml && typeof yaml === "object") {
rawRows = [yaml as Record<string, unknown>];
} else {
res.status(400).json({ error: "YAML data must be an object or a list of objects" });
return;
}
} else {
rawRows = parseCsv(data, delimiter);
}
} catch (err) {
res.status(400).json({ error: `Failed to parse ${effectiveFormat} data: ${(err as Error).message}` });
return;
}
const errors: { row: number; error: string }[] = [];
const validRows: { name: string; description: string; category: string; websiteUrl?: string; iconUrl?: string; features: string[]; tags: string[]; createdBy: string }[] = [];
const user = req.session.user!;
const createdBy = user.preferred_username || user.name || user.sub;
rawRows.forEach((raw, idx) => {
const normalized = normalizeTool(raw);
const check = CreateToolBody.safeParse(normalized);
if (!check.success) {
errors.push({ row: idx + 1, error: check.error.message });
return;
}
validRows.push({
name: check.data.name,
description: check.data.description,
category: check.data.category,
websiteUrl: check.data.websiteUrl,
iconUrl: check.data.iconUrl,
features: check.data.features ?? [],
tags: check.data.tags ?? [],
createdBy,
});
});
let imported = 0;
if (validRows.length > 0) {
const inserted = await db.insert(toolsTable).values(validRows).returning({ id: toolsTable.id, name: toolsTable.name, category: toolsTable.category });
imported = inserted.length;
for (const tool of inserted) {
await writeAuditLog(req, "tool", tool.id, "create", { name: tool.name, category: tool.category, source: "import" });
}
}
res.json({ imported, total: rawRows.length, errors });
},
);
export default router;
@@ -23,12 +23,13 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Skeleton } from "@/components/ui/skeleton";
import { useToast } from "@/hooks/use-toast";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Search, Trash2, ExternalLink, Pencil, Star } from "lucide-react";
import { ToolImportDialog } from "@/components/tool-import-dialog";
import { Search, Trash2, ExternalLink, Pencil, Star, Upload } from "lucide-react";
import { format } from "date-fns";
export function AdminToolsTab() {
const { t } = useTranslation();
const { isAdmin, isLoading: authLoading } = useAuth();
const { isAdmin, hasFeature, isLoading: authLoading } = useAuth();
const { toast } = useToast();
const queryClient = useQueryClient();
@@ -36,6 +37,7 @@ export function AdminToolsTab() {
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<Set<number>>(new Set());
const [confirmTrash, setConfirmTrash] = useState(false);
const [importOpen, setImportOpen] = useState(false);
useEffect(() => {
const t = setTimeout(() => setSearch(searchInput), 300);
@@ -111,6 +113,16 @@ export function AdminToolsTab() {
</CardDescription>
</div>
<div className="flex items-center gap-2 flex-wrap">
{hasFeature("tool-import") && (
<Button
size="sm"
variant="outline"
onClick={() => setImportOpen(true)}
data-testid="button-tool-import"
>
<Upload className="w-4 h-4 mr-2" /> {t("adminTools.importButton")}
</Button>
)}
<div className="relative">
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -229,6 +241,15 @@ export function AdminToolsTab() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ToolImportDialog
open={importOpen}
onOpenChange={setImportOpen}
onImported={() => {
toast({ title: t("adminTools.toastImportDone") });
invalidate();
}}
/>
</Card>
);
}
@@ -1,5 +1,6 @@
import { Slider } from "@/components/ui/slider";
import { Maximize2, Minimize2 } from "lucide-react";
import { useTranslation } from "react-i18next";
export type Density = "cozy" | "compact";
@@ -10,8 +11,13 @@ export function DensityToggle({
value: Density;
onValueChange: (d: Density) => void;
}) {
const { t } = useTranslation();
const densityLabel = value === "compact" ? t("common.densityCompact") : t("common.densityComfortable");
return (
<div className="flex items-center gap-2" title={`Density: ${value === "compact" ? "Compact" : "Comfortable"}`}>
<div
className="flex items-center gap-2"
title={t("common.densityTitle", { density: densityLabel })}
>
<Maximize2 className="w-4 h-4 text-muted-foreground" aria-hidden />
<Slider
className="w-16"
@@ -20,7 +26,7 @@ export function DensityToggle({
step={1}
value={[value === "compact" ? 1 : 0]}
onValueChange={([v]) => onValueChange(v === 1 ? "compact" : "cozy")}
aria-label="List density"
aria-label={t("common.listDensity")}
/>
<Minimize2 className="w-4 h-4 text-muted-foreground" aria-hidden />
</div>
@@ -1,32 +0,0 @@
import { HelpCircle } from "lucide-react";
import { Link } from "wouter";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export function FieldHelp({
schema,
field,
children,
}: {
schema: string;
field: string;
children?: React.ReactNode;
}) {
const label = children ?? field;
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/docs/reference/schemas/${schema}#${field}`}
target="_blank"
rel="noreferrer"
aria-label={`Help: ${label}`}
data-testid={`help-${schema}-${field}`}
className="inline-flex shrink-0 text-muted-foreground hover:text-foreground transition-colors"
>
<HelpCircle className="h-3.5 w-3.5" />
</Link>
</TooltipTrigger>
<TooltipContent>{label} Details in der Dokumentation</TooltipContent>
</Tooltip>
);
}
@@ -105,7 +105,7 @@ export function FilterPopover({
step={0.5}
value={[minRating ?? 0]}
onValueChange={([v]) => onChange({ minRating: v })}
aria-label="Minimum rating"
aria-label={t("filter.minRating")}
/>
<span className="text-sm tabular-nums w-8 text-right text-muted-foreground">
{minRating != null ? `${minRating.toFixed(1)}+` : t("filter.any")}
@@ -1,5 +1,7 @@
import { BookOpen } from "lucide-react";
import { HelpCircle } from "lucide-react";
import { Link } from "wouter";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export function GuideHelp({
@@ -9,21 +11,24 @@ export function GuideHelp({
guide: string;
label: string;
}) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/docs/handbook/${guide}`}
target="_blank"
rel="noreferrer"
aria-label={`Help: ${label}`}
data-testid={`guide-${guide}`}
className="inline-flex shrink-0 text-muted-foreground hover:text-foreground transition-colors"
>
<BookOpen className="h-3.5 w-3.5" />
</Link>
<Button asChild variant="outline" size="sm" className="gap-1.5 text-muted-foreground">
<Link
href={`/docs/handbook/${guide}`}
target="_blank"
rel="noreferrer"
aria-label={`${t("common.help")}: ${label}`}
data-testid={`guide-${guide}`}
>
<HelpCircle className="h-4 w-4" />
{t("common.help")}
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>{label} Anleitung in der Dokumentation</TooltipContent>
<TooltipContent>{label} {t("common.guideTooltip")}</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,185 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useImportTools, type ToolImportResponse } from "@workspace/api-client-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Upload, FileUp, CheckCircle2, AlertCircle } from "lucide-react";
type Format = "auto" | "csv" | "json" | "yaml";
type Delimiter = "auto" | "comma" | "semicolon" | "tab";
export function ToolImportDialog({
open,
onOpenChange,
onImported,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onImported: () => void;
}) {
const { t } = useTranslation();
const importTools = useImportTools();
const [format, setFormat] = useState<Format>("auto");
const [delimiter, setDelimiter] = useState<Delimiter>("auto");
const [data, setData] = useState("");
const [result, setResult] = useState<ToolImportResponse | null>(null);
function handleFile(file: File) {
const reader = new FileReader();
reader.onload = () => {
setData(String(reader.result ?? ""));
};
reader.readAsText(file);
}
function handleImport() {
importTools.mutate(
{ data: { format, delimiter, data } },
{
onSuccess: (res) => {
setResult(res);
if (res.imported > 0) onImported();
},
},
);
}
function handleClose(open: boolean) {
if (!importTools.isPending) {
onOpenChange(open);
if (!open) {
setData("");
setResult(null);
setFormat("auto");
setDelimiter("auto");
}
}
}
const canSubmit = data.trim().length > 0 && !importTools.isPending;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="w-4 h-4" />
{t("adminTools.importTitle")}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<p className="text-sm text-muted-foreground">{t("adminTools.importSub")}</p>
<Tabs value={format} onValueChange={(v) => setFormat(v as Format)}>
<TabsList className="w-full grid grid-cols-4">
<TabsTrigger value="auto">{t("adminTools.importFormatAuto")}</TabsTrigger>
<TabsTrigger value="csv">CSV</TabsTrigger>
<TabsTrigger value="json">JSON</TabsTrigger>
<TabsTrigger value="yaml">YAML</TabsTrigger>
</TabsList>
<TabsContent value="auto" className="text-xs text-muted-foreground pt-2">
{t("adminTools.importFormatAutoSub")}
</TabsContent>
<TabsContent value="csv" className="pt-2 space-y-3">
<div className="space-y-2">
<Label>{t("adminTools.importDelimiter")}</Label>
<Select value={delimiter} onValueChange={(v) => setDelimiter(v as Delimiter)}>
<SelectTrigger className="w-52">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("adminTools.importDelimiterAuto")}</SelectItem>
<SelectItem value="comma">{t("adminTools.importDelimiterComma")}</SelectItem>
<SelectItem value="semicolon">{t("adminTools.importDelimiterSemicolon")}</SelectItem>
<SelectItem value="tab">{t("adminTools.importDelimiterTab")}</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">{t("adminTools.importCsvSub")}</p>
</TabsContent>
<TabsContent value="json" className="pt-2 space-y-3">
<p className="text-xs text-muted-foreground">{t("adminTools.importJsonSub")}</p>
</TabsContent>
<TabsContent value="yaml" className="pt-2 space-y-3">
<p className="text-xs text-muted-foreground">{t("adminTools.importYamlSub")}</p>
</TabsContent>
</Tabs>
<div className="flex items-center gap-3">
<Button type="button" variant="outline" size="sm" className="gap-2" onClick={() => document.getElementById("tool-import-file")?.click()}>
<FileUp className="w-4 h-4" />
{t("adminTools.importFile")}
</Button>
<input
id="tool-import-file"
type="file"
accept=".csv,.json,.yaml,.yml,.txt,text/plain,application/json"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
e.target.value = "";
}}
/>
</div>
<Textarea
value={data}
onChange={(e) => setData(e.target.value)}
placeholder={t("adminTools.importTextareaPlaceholder")}
className="min-h-[220px] font-mono text-sm"
data-testid="input-tool-import-data"
/>
{importTools.isError && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
<span>{importTools.error?.data?.error ?? importTools.error?.message}</span>
</div>
)}
{result && (
<div className="space-y-2">
<div className="flex items-center gap-2 rounded-md border border-primary/20 bg-primary/5 px-3 py-2 text-sm">
<CheckCircle2 className="w-4 h-4 text-primary shrink-0" />
<span>{t("adminTools.importDone", { count: result.imported, total: result.total })}</span>
</div>
{result.errors.length > 0 && (
<div className="space-y-1">
<p className="text-sm font-medium flex items-center gap-1.5">
<AlertCircle className="w-4 h-4 text-amber-500" />
{t("adminTools.importErrors", { count: result.errors.length })}
</p>
<div className="max-h-40 overflow-y-auto rounded-md border bg-muted/40 divide-y divide-border">
{result.errors.map((err, i) => (
<div key={i} className="flex items-start gap-2 px-3 py-1.5 text-xs">
<span className="font-mono text-muted-foreground shrink-0">
{t("adminTools.importErrorRow", { row: err.row })}:
</span>
<span className="text-foreground break-words">{err.error}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleClose(false)} disabled={importTools.isPending}>
{t("common.cancel")}
</Button>
<Button onClick={handleImport} disabled={!canSubmit}>
{importTools.isPending ? t("adminTools.importing") : t("adminTools.importButton")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -17,6 +17,7 @@ import {
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/password-input";
import { GuideHelp } from "@/components/guide-help";
import { useToast } from "@/hooks/use-toast";
import { useChangeMyPassword, useGetPasswordRedirect, getGetPasswordRedirectQueryKey } from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
@@ -163,8 +164,9 @@ export function UserMenu() {
<Dialog open={pwOpen} onOpenChange={setPwOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
<DialogTitle>{t("auth.changePassword")}</DialogTitle>
<GuideHelp guide="konto" label={t("auth.changePassword")} />
</DialogHeader>
{!user?.isLocal ? (
<div className="space-y-4 py-2">
@@ -193,7 +195,7 @@ export function UserMenu() {
<PasswordInput
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="min. 6 characters"
placeholder={t("auth.passwordMinLength")}
data-testid="input-new-password"
/>
</div>
@@ -1,12 +1,13 @@
import { LayoutGrid, List, Rows3 } from "lucide-react";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useTranslation } from "react-i18next";
export type ViewMode = "grid" | "table" | "rows";
const OPTIONS: { value: ViewMode; label: string; Icon: typeof LayoutGrid }[] = [
{ value: "grid", label: "Grid", Icon: LayoutGrid },
{ value: "table", label: "Table", Icon: List },
{ value: "rows", label: "Rows", Icon: Rows3 },
const OPTIONS: { value: ViewMode; labelKey: string; Icon: typeof LayoutGrid }[] = [
{ value: "grid", labelKey: "common.viewGrid", Icon: LayoutGrid },
{ value: "table", labelKey: "common.viewTable", Icon: List },
{ value: "rows", labelKey: "common.viewRows", Icon: Rows3 },
];
export function ViewToggle({
@@ -16,6 +17,7 @@ export function ViewToggle({
value: ViewMode;
onValueChange: (v: ViewMode) => void;
}) {
const { t } = useTranslation();
return (
<ToggleGroup
type="single"
@@ -25,10 +27,16 @@ export function ViewToggle({
}}
variant="outline"
size="sm"
aria-label="View mode"
aria-label={t("common.viewMode")}
>
{OPTIONS.map(({ value: v, label, Icon }) => (
<ToggleGroupItem key={v} value={v} aria-label={label} title={label} data-testid={`view-${v}`}>
{OPTIONS.map(({ value: v, labelKey, Icon }) => (
<ToggleGroupItem
key={v}
value={v}
aria-label={t(labelKey)}
title={t(labelKey)}
data-testid={`view-${v}`}
>
<Icon className="w-4 h-4" />
</ToggleGroupItem>
))}
+33 -4
View File
@@ -30,6 +30,7 @@
"currentPassword": "Aktuelles Passwort",
"newPassword": "Neues Passwort",
"confirmPassword": "Passwort bestätigen",
"passwordMinLength": "mind. 6 Zeichen",
"pwMismatch": "Passwörter stimmen nicht überein",
"pwTooShort": "Passwort muss mindestens 8 Zeichen haben",
"pwChanged": "Passwort geändert",
@@ -54,8 +55,17 @@
"all": "Alle",
"none": "Keine",
"language": "Sprache",
"viewDetails": "Details ansehen",
"guideTooltip": "Anleitung in der Dokumentation"
"viewDetails": "Details ansehen",
"guideTooltip": "Anleitung in der Dokumentation",
"help": "Hilfe",
"viewMode": "Ansichtsmodus",
"viewGrid": "Raster",
"viewTable": "Tabelle",
"viewRows": "Zeilen",
"listDensity": "Listendichte",
"densityCompact": "Kompakt",
"densityComfortable": "Komfortabel",
"densityTitle": "Dichte: {{density}}"
},
"home": {
"welcome": "Willkommen bei toolr",
@@ -295,7 +305,6 @@
"parameter": "Parameter",
"noResults": "Keine Treffer",
"fields": "Felder",
"fieldHelpHint": "Hinweis: Formular-Felder verlinken per ?-Icon direkt zu den jeweiligen Zeilen dieser Tabelle.",
"name": "Name",
"in": "In",
"requestBody": "Request-Body"
@@ -393,7 +402,27 @@
"moveToTrashAction": "In den Papierkorb verschieben",
"toastMoved": "Tools in den Papierkorb verschoben",
"toastMovedSub": "{{count}} Tool(s) in den Papierkorb verschoben.",
"toastMoveFailed": "Verschieben in den Papierkorb fehlgeschlagen"
"toastMoveFailed": "Verschieben in den Papierkorb fehlgeschlagen",
"importTitle": "Tools importieren",
"importSub": "Mehrere Tools auf einmal importieren — als CSV, JSON oder YAML (Premium-Feature).",
"importButton": "Importieren",
"importFormatAuto": "Auto",
"importFormatAutoSub": "Das Format wird automatisch erkannt (CSV, JSON oder YAML).",
"importDelimiter": "CSV-Trenner",
"importDelimiterAuto": "Automatisch",
"importDelimiterComma": "Komma",
"importDelimiterSemicolon": "Semikolon",
"importDelimiterTab": "Tabulator",
"importCsvSub": "Spalten: name, description, category, websiteUrl, iconUrl, features, tags. Features/Tags durch | getrennt. Erste Zeile enthält die Spaltennamen.",
"importJsonSub": "JSON-Objekt oder Array von Objekten mit den Feldern name, description, category, websiteUrl, iconUrl, features, tags.",
"importYamlSub": "YAML-Liste von Objekten (oder ein einzelnes Objekt) mit den Feldern name, description, category, websiteUrl, iconUrl, features, tags.",
"importFile": "Datei laden",
"importTextareaPlaceholder": "name;description;category;features;tags\nMein Tool;Eine tolle Beschreibung;Monitoring;Deployment|Open Source;devops",
"importing": "Wird importiert…",
"importDone": "{{count}} von {{total}} Tools erfolgreich importiert.",
"importErrors": "{{count}} Zeilen mit Fehlern:",
"importErrorRow": "Zeile {{row}}",
"toastImportDone": "Tools erfolgreich importiert"
},
"analytics": {
"title": "Plattform-Analysen",
+33 -4
View File
@@ -30,6 +30,7 @@
"currentPassword": "Current password",
"newPassword": "New password",
"confirmPassword": "Confirm password",
"passwordMinLength": "min. 6 characters",
"pwMismatch": "Passwords do not match",
"pwTooShort": "Password must be at least 8 characters",
"pwChanged": "Password changed",
@@ -54,8 +55,17 @@
"all": "All",
"none": "None",
"language": "Language",
"viewDetails": "View details",
"guideTooltip": "Guide in the documentation"
"viewDetails": "View details",
"guideTooltip": "Guide in the documentation",
"help": "Help",
"viewMode": "View mode",
"viewGrid": "Grid",
"viewTable": "Table",
"viewRows": "Rows",
"listDensity": "List density",
"densityCompact": "Compact",
"densityComfortable": "Comfortable",
"densityTitle": "Density: {{density}}"
},
"home": {
"welcome": "Welcome to toolr",
@@ -295,7 +305,6 @@
"parameter": "Parameter",
"noResults": "No results",
"fields": "Fields",
"fieldHelpHint": "Note: form fields link via the ? icon directly to the respective rows of this table.",
"name": "Name",
"in": "In",
"requestBody": "Request Body"
@@ -393,7 +402,27 @@
"moveToTrashAction": "Move to trash",
"toastMoved": "Tools moved to trash",
"toastMovedSub": "{{count}} tool(s) moved to trash.",
"toastMoveFailed": "Failed to move to trash"
"toastMoveFailed": "Failed to move to trash",
"importTitle": "Import tools",
"importSub": "Import multiple tools at once — as CSV, JSON or YAML (premium feature).",
"importButton": "Import",
"importFormatAuto": "Auto",
"importFormatAutoSub": "The format is detected automatically (CSV, JSON or YAML).",
"importDelimiter": "CSV delimiter",
"importDelimiterAuto": "Auto-detect",
"importDelimiterComma": "Comma",
"importDelimiterSemicolon": "Semicolon",
"importDelimiterTab": "Tab",
"importCsvSub": "Columns: name, description, category, websiteUrl, iconUrl, features, tags. Separate features/tags with |. The first line holds the column names.",
"importJsonSub": "A JSON object or array of objects with the fields name, description, category, websiteUrl, iconUrl, features, tags.",
"importYamlSub": "A YAML list of objects (or a single object) with the fields name, description, category, websiteUrl, iconUrl, features, tags.",
"importFile": "Upload file",
"importTextareaPlaceholder": "name;description;category;features;tags\nMy Tool;A great description;Monitoring;Deployment|Open Source;devops",
"importing": "Importing…",
"importDone": "{{count}} of {{total}} tools imported successfully.",
"importErrors": "{{count}} rows with errors:",
"importErrorRow": "Row {{row}}",
"toastImportDone": "Tools imported successfully"
},
"analytics": {
"title": "Platform Analytics",
+1 -3
View File
@@ -7,7 +7,6 @@ import {
} from "@workspace/api-client-react";
import { useTranslation } from "react-i18next";
import { Layout } from "@/components/layout";
import { GuideHelp } from "@/components/guide-help";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
@@ -51,9 +50,8 @@ export default function Analytics() {
<Layout>
<div className="space-y-6 pb-10">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
<h1 className="text-3xl font-bold tracking-tight mb-2">
{t("analytics.title")}
<GuideHelp guide="analytics" label={t("analytics.title")} />
</h1>
<p className="text-muted-foreground">{t("analytics.subtitle")}</p>
</div>
+1 -3
View File
@@ -8,7 +8,6 @@ import { Link, useSearch } from "wouter";
import { Layout } from "@/components/layout";
import { useAuth } from "@/hooks/use-auth";
import { RatingStars } from "@/components/rating-stars";
import { GuideHelp } from "@/components/guide-help";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -102,9 +101,8 @@ export default function Compare() {
<Layout>
<div className="space-y-6 pb-10">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
<h1 className="text-3xl font-bold tracking-tight mb-2">
{t("compare.title")}
<GuideHelp guide="vergleichen" label={t("compare.title")} />
</h1>
<p className="text-muted-foreground">{t("compare.subtitle", { count: list.length })}</p>
</div>
-5
View File
@@ -30,7 +30,6 @@ import {
ExternalLink,
FileText,
GitBranch,
HelpCircle,
Library,
Menu,
Search,
@@ -591,10 +590,6 @@ function SchemaView({ schema }: { schema: SchemaModel }) {
{schema.description && <p className="text-muted-foreground">{schema.description}</p>}
</div>
<FieldTable fields={schema.fields} />
<p className="text-xs text-muted-foreground">
<HelpCircle className="h-3.5 w-3.5 inline mr-1" />
{t("docs.fieldHelpHint")}
</p>
</div>
<Toc headings={headings} title={t("docs.fields")} />
</div>
+3 -1
View File
@@ -24,7 +24,9 @@ export default function Home() {
<div className="space-y-8 pb-8">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("home.welcome")}</h1>
<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>
+7 -13
View File
@@ -60,7 +60,6 @@ import {
} from "@/components/ui/alert-dialog";
import { customFetch } from "@workspace/api-client-react";
import { recordRecentTool } from "@/lib/recent-tools";
import { FieldHelp } from "@/components/field-help";
import { GuideHelp } from "@/components/guide-help";
const ratingSchema = z.object({
@@ -616,9 +615,8 @@ export default function ToolDetail() {
<Dialog open={costDialogOpen} onOpenChange={(o) => { if (!o) setCostDialogOpen(false); }}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<DialogTitle>
{editCost ? t("detail.edit") + " " + t("detail.costs") : t("detail.addCost")}
<GuideHelp guide="kosten" label={t("detail.costs")} />
</DialogTitle>
<DialogDescription>{t("detail.costDialogSub")}</DialogDescription>
</DialogHeader>
@@ -768,12 +766,12 @@ export default function ToolDetail() {
{isReviewFormOpen && (
<Card className="border-primary shadow-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
{t("detail.addReview")}
<GuideHelp guide="bewerten" label={t("detail.addReview")} />
</CardTitle>
<CardDescription>{t("detail.shareExperience", { name: tool.name })}</CardDescription>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="space-y-1.5">
<CardTitle>{t("detail.addReview")}</CardTitle>
<CardDescription>{t("detail.shareExperience", { name: tool.name })}</CardDescription>
</div>
<GuideHelp guide="bewerten" label={t("detail.addReview")} />
</CardHeader>
<CardContent>
<Form {...form}>
@@ -786,7 +784,6 @@ export default function ToolDetail() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("detail.usefulness")}
<FieldHelp schema="RatingInput" field="usefulness">{t("detail.usefulness")}</FieldHelp>
</FormLabel>
<div className="py-2">
<RatingStars
@@ -807,7 +804,6 @@ export default function ToolDetail() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("detail.usability")}
<FieldHelp schema="RatingInput" field="usability">{t("detail.usability")}</FieldHelp>
</FormLabel>
<div className="py-2">
<RatingStars
@@ -830,7 +826,6 @@ export default function ToolDetail() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("detail.commentOptional")}
<FieldHelp schema="RatingInput" field="comment">{t("detail.commentLabel")}</FieldHelp>
</FormLabel>
<FormControl>
<Textarea
@@ -851,7 +846,6 @@ export default function ToolDetail() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("detail.nameOptional")}
<FieldHelp schema="RatingInput" field="reviewerName">{t("detail.nameLabel")}</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder={t("detail.anonymousPlaceholder")} {...field} />
+9 -15
View File
@@ -30,7 +30,6 @@ import { CategoryCombobox } from "@/components/category-combobox";
import { FeatureInput } from "@/components/feature-input";
import { TagInput } from "@/components/tag-input";
import { useAuth } from "@/hooks/use-auth";
import { FieldHelp } from "@/components/field-help";
import { GuideHelp } from "@/components/guide-help";
import { useTranslation } from "react-i18next";
@@ -163,13 +162,15 @@ export default function ToolEdit() {
</Card>
) : (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Pencil className="w-5 h-5 text-primary" />
{t("toolForm.toolDetails")}
<GuideHelp guide="tool-bearbeiten" label={t("toolForm.toolDetails")} />
</CardTitle>
<CardDescription>{t("toolForm.toolDetailsEditSub")}</CardDescription>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="space-y-1.5">
<CardTitle className="flex items-center gap-2">
<Pencil className="w-5 h-5 text-primary" />
{t("toolForm.toolDetails")}
</CardTitle>
<CardDescription>{t("toolForm.toolDetailsEditSub")}</CardDescription>
</div>
<GuideHelp guide="tool-bearbeiten" label={t("toolForm.toolDetails")} />
</CardHeader>
<CardContent>
<Form {...form}>
@@ -182,7 +183,6 @@ export default function ToolEdit() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.name")}
<FieldHelp schema="ToolInput" field="name">{t("toolForm.name")}</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder={t("toolForm.name")} {...field} />
@@ -198,7 +198,6 @@ export default function ToolEdit() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.category")}
<FieldHelp schema="ToolInput" field="category">{t("toolForm.category")}</FieldHelp>
</FormLabel>
<FormControl>
<CategoryCombobox value={field.value} onChange={field.onChange} />
@@ -216,7 +215,6 @@ export default function ToolEdit() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.websiteUrlOptional")}
<FieldHelp schema="ToolInput" field="websiteUrl">{t("toolForm.websiteUrl")}</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder={t("toolForm.urlPlaceholder")} type="url" {...field} />
@@ -233,7 +231,6 @@ export default function ToolEdit() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.iconUrlOptional")}
<FieldHelp schema="ToolInput" field="iconUrl">{t("toolForm.iconUrl")}</FieldHelp>
</FormLabel>
<FormControl>
<div className="flex items-center gap-3">
@@ -269,7 +266,6 @@ export default function ToolEdit() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.description")}
<FieldHelp schema="ToolInput" field="description">{t("toolForm.description")}</FieldHelp>
</FormLabel>
<FormControl>
<Textarea
@@ -288,7 +284,6 @@ export default function ToolEdit() {
<div>
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
{t("toolForm.features")}
<FieldHelp schema="ToolInput" field="features">{t("toolForm.features")}</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">{t("toolForm.featuresEditSub")}</p>
</div>
@@ -335,7 +330,6 @@ export default function ToolEdit() {
<div>
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
{t("toolForm.tags")}
<FieldHelp schema="ToolInput" field="tags">{t("toolForm.tags")}</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">{t("toolForm.tagsEditSub")}</p>
</div>
+10 -16
View File
@@ -18,7 +18,6 @@ import { CategoryCombobox } from "@/components/category-combobox";
import { FeatureInput } from "@/components/feature-input";
import { TagInput } from "@/components/tag-input";
import { useAuth } from "@/hooks/use-auth";
import { FieldHelp } from "@/components/field-help";
import { GuideHelp } from "@/components/guide-help";
import { useTranslation } from "react-i18next";
@@ -126,13 +125,15 @@ export default function ToolNew() {
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Wrench className="w-5 h-5 text-primary" />
{t("toolForm.toolDetails")}
<GuideHelp guide="tool-anlegen" label={t("toolForm.toolDetails")} />
</CardTitle>
<CardDescription>{t("toolForm.toolDetailsNewSub")}</CardDescription>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="space-y-1.5">
<CardTitle className="flex items-center gap-2">
<Wrench className="w-5 h-5 text-primary" />
{t("toolForm.toolDetails")}
</CardTitle>
<CardDescription>{t("toolForm.toolDetailsNewSub")}</CardDescription>
</div>
<GuideHelp guide="tool-anlegen" label={t("toolForm.toolDetails")} />
</CardHeader>
<CardContent>
<Form {...form}>
@@ -145,7 +146,6 @@ export default function ToolNew() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.name")}
<FieldHelp schema="ToolInput" field="name">{t("toolForm.name")}</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder={t("toolForm.namePlaceholder")} {...field} data-testid="input-tool-name" />
@@ -162,7 +162,6 @@ export default function ToolNew() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.category")}
<FieldHelp schema="ToolInput" field="category">{t("toolForm.category")}</FieldHelp>
</FormLabel>
<FormControl>
<CategoryCombobox
@@ -183,7 +182,6 @@ export default function ToolNew() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.websiteUrlOptional")}
<FieldHelp schema="ToolInput" field="websiteUrl">{t("toolForm.websiteUrl")}</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder={t("toolForm.urlPlaceholder")} type="url" {...field} data-testid="input-tool-url" />
@@ -200,7 +198,6 @@ export default function ToolNew() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.iconUrlOptional")}
<FieldHelp schema="ToolInput" field="iconUrl">{t("toolForm.iconUrl")}</FieldHelp>
</FormLabel>
<FormControl>
<div className="flex items-center gap-3">
@@ -237,7 +234,6 @@ export default function ToolNew() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.description")}
<FieldHelp schema="ToolInput" field="description">{t("toolForm.description")}</FieldHelp>
</FormLabel>
<FormControl>
<Textarea
@@ -257,7 +253,6 @@ export default function ToolNew() {
<div>
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
{t("toolForm.features")}
<FieldHelp schema="ToolInput" field="features">{t("toolForm.features")}</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">{t("toolForm.featuresNewSub")}</p>
</div>
@@ -313,7 +308,6 @@ export default function ToolNew() {
<div>
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
{t("toolForm.tags")}
<FieldHelp schema="ToolInput" field="tags">{t("toolForm.tags")}</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">{t("toolForm.tagsHelp")}</p>
</div>
@@ -369,7 +363,7 @@ export default function ToolNew() {
className="w-full sm:w-auto"
data-testid="button-submit-tool"
>
{createTool.isPending ? "Adding Tool..." : "Submit Tool"}
{createTool.isPending ? t("toolForm.addingTool") : t("toolForm.submitTool")}
</Button>
</div>
</form>
@@ -230,7 +230,9 @@ export default function ToolsBrowse() {
<div className="space-y-6 pb-8">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("nav.browseTools")}</h1>
<h1 className="text-3xl font-bold tracking-tight mb-2">
{t("nav.browseTools")}
</h1>
<p className="text-muted-foreground">{t("browse.subtitle")}</p>
</div>
<Button asChild>
+3 -1
View File
@@ -156,7 +156,9 @@ export default function Trash() {
<Layout>
<div className="space-y-6 pb-10">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("trash.title")}</h1>
<h1 className="text-3xl font-bold tracking-tight mb-2">
{t("trash.title")}
</h1>
<p className="text-muted-foreground">{t("trash.subtitle")}</p>
</div>
+1 -3
View File
@@ -6,7 +6,6 @@ import { useAuth } from "@/hooks/use-auth";
import { useWatchlist } from "@/hooks/use-watchlist";
import { Layout } from "@/components/layout";
import { ToolCard } from "@/components/tool-card";
import { GuideHelp } from "@/components/guide-help";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ShieldAlert, Bookmark } from "lucide-react";
@@ -43,9 +42,8 @@ export default function Watchlist() {
<Layout>
<div className="space-y-6 pb-10">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
<h1 className="text-3xl font-bold tracking-tight mb-2">
{t("watchlist.title")}
<GuideHelp guide="watchlist" label={t("watchlist.title")} />
</h1>
<p className="text-muted-foreground">{t("watchlist.subtitle")}</p>
</div>
+40
View File
@@ -36,6 +36,46 @@ Central access to the tool catalog.
- **Bulk action:** select multiple tools and move them to the trash
(confirmation dialog; soft-deleted tools are removed from all public views
and can be restored or permanently deleted).
- **Import** (premium): via the **Import** button at the top right, multiple
tools can be created at once. The format is detected automatically (CSV,
JSON or YAML); for CSV the delimiter can be chosen (auto-detect, comma,
semicolon or tab). Data can be pasted directly into the form or uploaded
as a file.
Supported fields: `name` (required), `description`, `category`, `websiteUrl`,
`iconUrl`, `features`, `tags` (`features`/`tags` are `|`-separated lists).
**CSV example:**
```csv
name;description;category;features;tags
Grafana;Observability and dashboard platform;Monitoring;Dashboards|Alerts;devops
Prometheus;Time-series database;Monitoring;Metrics;devops
```
**JSON example:**
```json
[
{ "name": "Grafana", "category": "Monitoring", "features": ["Dashboards"] },
{ "name": "Prometheus", "category": "Monitoring" }
]
```
**YAML example:**
```yaml
- name: Grafana
category: Monitoring
features:
- Dashboards
- name: Prometheus
category: Monitoring
```
During import, individual faulty rows are skipped and listed with their row
number; correct rows are still created. Every import produces audit-log
entries.
## "Audit log" tab
+40
View File
@@ -36,6 +36,46 @@ Zentraler Zugriff auf den Tool-Katalog.
- **Massenaktion:** mehrere Tools auswählen und in den Papierkorb verschieben
(Bestätigungsdialog; soft gelöschte Tools sind aus allen öffentlichen Ansichten
entfernt und können wiederhergestellt oder endgültig gelöscht werden).
- **Importieren** (Premium): über die Schaltfläche **Importieren** oben rechts
können mehrere Tools gleichzeitig angelegt werden. Das Format wird automatisch
erkannt (CSV, JSON oder YAML); für CSV lässt sich der Trenner wählen
(automatisch, Komma, Semikolon oder Tabulator). Die Daten lassen sich direkt
ins Formular einfügen oder als Datei hochladen.
Unterstützte Felder: `name` (Pflicht), `description`, `category`, `websiteUrl`,
`iconUrl`, `features`, `tags` (`features`/`tags` sind mit `|` getrennte Listen).
**CSV-Beispiel:**
```csv
name;description;category;features;tags
Grafana;Observability- und Dashboard-Plattform;Monitoring;Dashboards|Alerts;devops
Prometheus;Zeitreihen-Datenbank;Monitoring;Metrics;devops
```
**JSON-Beispiel:**
```json
[
{ "name": "Grafana", "category": "Monitoring", "features": ["Dashboards"] },
{ "name": "Prometheus", "category": "Monitoring" }
]
```
**YAML-Beispiel:**
```yaml
- name: Grafana
category: Monitoring
features:
- Dashboards
- name: Prometheus
category: Monitoring
```
Beim Import werden einzelne fehlerhafte Zeilen übersprungen und mit Zeilennummer
aufgelistet; korrekte Zeilen werden trotzdem angelegt. Jeder Import erzeugt
Audit-Log-Einträge.
## Tab „Audit-Log"
+2 -2
View File
@@ -10,7 +10,7 @@ Besuch bis zum Anlegen und Bewerten eines Tools.
## 1. Anmelden
Die meisten Aktionen (Tool anlegen, bewerten, Watchlist, Vergleichen) erfordern
Die meisten Aktionen (Tool anlegen, bewerten, Merkliste, Vergleichen) erfordern
ein Konto. Klicke unten links auf **Anmelden**. Je nach Konfiguration der
Instanz hast du zwei Möglichkeiten:
@@ -51,7 +51,7 @@ Siehe [Bewerten](/docs/handbook/bewerten).
## 5. Weiterführend
- [Tools vergleichen](/docs/handbook/vergleichen)
- [Watchlist](/docs/handbook/watchlist)
- [Merkliste](/docs/handbook/watchlist)
- [Analytics](/docs/handbook/analytics)
- [Pläne & Berechtigungen](/docs/handbook/plaene)
- [Administration](/docs/handbook/administration)
+1
View File
@@ -22,6 +22,7 @@ tools**. Users maintain a shared catalog of tools, submit ratings
| **Record costs** | Enter license and cost models per tool | Premium |
| **Analytics** | Statistics, top tools, distributions | Everyone |
| **Trash** | Restore soft-deleted tools or delete them permanently | Premium |
| **Import** | Create multiple tools via CSV, JSON or YAML | Admin (premium) |
| **Admin** | User management, audit log, system information | Admin |
| **Redundancy detection** | Automatic duplicate detection | Admin |
+2 -1
View File
@@ -18,11 +18,12 @@ die richtige Wahl zu treffen.
| **Tool anlegen** | Neues Tool mit Beschreibung, Kategorie, Features & Tags eintragen | Angemeldet |
| **Tool bearbeiten/löschen** | Eigene Tools pflegen (Ersteller:in oder Admin) | Angemeldet |
| **Bewerten** | Nützlichkeit & Bedienbarkeit (15) plus Kommentar vergeben | Angemeldet |
| **Watchlist** | Tools als Favoriten speichern | Premium |
| **Merkliste** | Tools als Favoriten speichern | Premium |
| **Vergleichen** | Tools nebeneinander gegenüberstellen | Premium |
| **Kosten erfassen** | Lizenz- und Kostenmodelle je Tool eintragen | Premium |
| **Analytics** | Statistiken, Top-Tools, Verteilungen | Alle |
| **Papierkorb** | Soft-gelöschte Tools wiederherstellen oder endgültig löschen | Premium |
| **Import** | Mehrere Tools per CSV, JSON oder YAML anlegen | Admin (Premium) |
| **Admin** | Nutzerverwaltung, Audit-Log, Systeminformationen | Admin |
| **Redundanz** | Automatische Doppelungs-Erkennung | Admin |
+2 -2
View File
@@ -26,7 +26,7 @@ Der aktive Modus steht im Endpunkt
Dein Profil (Avatar, Name, E-Mail, Tarif) siehst du unten links im
Benutzermenü. Dort stehen dir folgende Aktionen zur Verfügung:
- **Watchlist** — deine gespeicherten Tools (nur mit dem entsprechenden Tarif).
- **Merkliste** — deine gespeicherten Tools (nur mit dem entsprechenden Tarif).
- **Papierkorb** — wiederherstellbare, gelöschte Tools (Premium/Enterprise).
- **Passwort ändern** — für lokale Konten direkt in toolr; für OIDC-Konten wird
die Passwortverwaltung im Identitätsanbieter angeboten.
@@ -51,7 +51,7 @@ API-Referenz: [`POST /auth/me/password`](/docs/reference/endpoints/auth#changeMy
- die **Listenansicht** und **Dichte** im Bereich Tools durchsuchen anpassen
(siehe [Tools finden & durchsuchen](/docs/handbook/tools-finden)).
Deine Präferenzen (inkl. Watchlist) werden im Endpunkt
Deine Präferenzen (inkl. Merkliste) werden im Endpunkt
[`GET /auth/me/preferences`](/docs/reference/endpoints/auth#getMePreferences)
gespeichert und über [`PUT /auth/me/preferences`](/docs/reference/endpoints/auth#updateMePreferences)
aktualisiert.
+1
View File
@@ -26,6 +26,7 @@ Premium/Enterprise unlock the following features:
| `watchlist` | Favorites list | [Watchlist](/docs/handbook/watchlist) |
| `trash` | Trash (soft-deleted tools) | [Trash](/docs/handbook/papierkorb) |
| `costs` | Record cost/license models | [Recording costs](/docs/handbook/kosten) |
| `tool-import` | Import tools via CSV/JSON/YAML (admin) | [Administration](/docs/handbook/administration) |
If you are missing a feature, the app shows an **upgrade notice** with a link
to the plan management.
+3 -2
View File
@@ -13,7 +13,7 @@ Feature-Beschränkungen.
| Tarif | Beschreibung |
| --- | --- |
| **Free** | Grundfunktionen: suchen, filtern, ansehen, Analytics |
| **Premium** | Zusätzlich Watchlist, Vergleichen, Papierkorb, Kosten |
| **Premium** | Zusätzlich Merkliste, Vergleichen, Papierkorb, Kosten |
| **Enterprise** | Alle Premium-Features + erweiterter Support |
### Feature-Berechtigungen
@@ -23,9 +23,10 @@ Premium/Enterprise schalten folgende Features frei:
| Feature | Funktion | Mehr erfahren |
| --- | --- | --- |
| `compare` | Tools vergleichen | [Vergleichen](/docs/handbook/vergleichen) |
| `watchlist` | Favoritenliste | [Watchlist](/docs/handbook/watchlist) |
| `watchlist` | Favoritenliste | [Merkliste](/docs/handbook/watchlist) |
| `trash` | Papierkorb (soft gelöschte Tools) | [Papierkorb](/docs/handbook/papierkorb) |
| `costs` | Kosten-/Lizenzmodelle erfassen | [Kosten erfassen](/docs/handbook/kosten) |
| `tool-import` | Tools per CSV/JSON/YAML importieren (Admin) | [Administration](/docs/handbook/administration) |
Fehlt dir ein Feature, zeigt die App einen **Upgrade-Hinweis** mit Link zur
Tarifverwaltung.
+1 -1
View File
@@ -19,7 +19,7 @@ Ohne Eingabe zeigt die Palette:
- **Zuletzt angesehen** — die letzten 5 Tools, die du besucht hast.
- **Navigation** — Tools durchsuchen, Tool hinzufügen, Analytics sowie
(abhängig von Berechtigungen) Watchlist, Papierkorb und Admin.
(abhängig von Berechtigungen) Merkliste, Papierkorb und Admin.
### Suche
+2 -2
View File
@@ -58,12 +58,12 @@ In der Tabellenansicht sind die Spalten **Tool**, **Bewertung** und **Anzahl
Bewertungen** sortierbar. Beim Überfahren einer Zeile erscheint eine Vorschau
mit Bewertungsdetails, Tags und Mini-Balken.
## Auswählen für Vergleich & Watchlist
## Auswählen für Vergleich & Merkliste
- Auf jeder Karte/Zeile findest du ein **Vergleichs-Icon**, mit dem du Tools zur
[Vergleichsleiste](/docs/handbook/vergleichen) hinzufügst.
- Das **Lesezeichen-Icon** speichert Tools in deiner
[Watchlist](/docs/handbook/watchlist) (nur mit dem entsprechenden Tarif).
[Merkliste](/docs/handbook/watchlist) (nur mit dem entsprechenden Tarif).
## API
+10 -10
View File
@@ -1,14 +1,14 @@
---
title: Watchlist
title: Merkliste
order: 8
---
# Watchlist
# Merkliste
Die **Watchlist** ist eine persönliche Favoritenliste. Tools darin kannst du
Die **Merkliste** ist eine persönliche Favoritenliste. Tools darin kannst du
jederzeit per Klick wieder aufrufen und vergleichen.
> Die Watchlist ist ein **Premium-Feature** (Premium/Enterprise) und steht
> Die Merkliste ist ein **Premium-Feature** (Premium/Enterprise) und steht
> Admins immer zur Verfügung.
## Voraussetzung
@@ -21,18 +21,18 @@ beim Lesezeichen ein Hinweis auf den Tarifwechsel
- Auf jeder Karte/Zeile im Bereich **Tools durchsuchen** findest du das
**Lesezeichen-Icon**.
- Ein Klick speichert das Tool in deiner Watchlist — das Icon wird gefüllt.
- Ein Klick speichert das Tool in deiner Merkliste — das Icon wird gefüllt.
- Ein erneuter Klick entfernt es wieder.
## Watchlist ansehen
## Merkliste ansehen
Öffne die Watchlist über das Benutzermenü oder die Seitenleiste. Sie zeigt alle
Öffne die Merkliste über das Benutzermenü oder die Seitenleiste. Sie zeigt alle
gespeicherten Tools als Karten. Das gefüllte Lesezeichen auf einer Karte
entfernt das Tool aus der Liste.
## Wo wird die Watchlist gespeichert?
## Wo wird die Merkliste gespeichert?
Die Watchlist ist eine Liste von Tool-IDs in deinen **Benutzerpräferenzen**.
Die Merkliste ist eine Liste von Tool-IDs in deinen **Benutzerpräferenzen**.
Damit ist sie geräteübergreifend mit deinem Konto verbunden.
API-Referenz: [`GET /auth/me/watchlist`](/docs/reference/endpoints/auth#getMeWatchlist).
API-Referenz: [`GET /auth/me/watchlist`](/docs/reference/endpoints/auth#getMeMerkliste).
+1 -1
View File
@@ -6,7 +6,7 @@
- **Vollständige Dokumentations-Site** in mkdocs-Optik unter `/docs`:
- **Handbuch** mit verständlichen Erklärungen zu allen Features
(Erste Schritte, Tool anlegen, Bewertungen, Vergleichen, Watchlist,
(Erste Schritte, Tool anlegen, Bewertungen, Vergleichen, Merkliste,
Analytics, Administration, Datenmodell).
- **Automatisch generierte Referenz** aus `lib/api-spec/openapi.yaml`:
alle Endpunkte und Datenfelder (Typ, Pflichtstatus, Constraints) —
+1 -1
View File
@@ -12,7 +12,7 @@
und führt zur Standalone-Doku.
- **User Guide komplett überarbeitet**: 17 Handbuch-Seiten mit
Schritt-für-Schritt-Anleitungen für alle Funktionen (Tools finden, Tool
anlegen, Bewerten, Watchlist, Vergleichen, Kosten, Analytics, Pläne,
anlegen, Bewerten, Merkliste, Vergleichen, Kosten, Analytics, Pläne,
Administration, Redundanz, Papierkorb, Tastenkürzel, Datenmodell).
## Fixes & Verbesserungen
+1 -1
View File
@@ -10,7 +10,7 @@
(`/docs/vX.Y.Z/…`) — inklusive Handbuch und Referenz, wie sie zum
Release-Zeitpunkt galten. Die letzten 7 Versionen bleiben verfügbar.
- **Hilfe-Buttons in Formularen (NetBox-Stil)**: Bei Bewertung, Tool
anlegen/bearbeiten, Kosten, Vergleichen, Watchlist und Analytics gibt es
anlegen/bearbeiten, Kosten, Vergleichen, Merkliste und Analytics gibt es
jetzt ein Hilfe-Icon, das direkt zur passenden Anleitung im Handbuch führt.
## Fixes & Verbesserungen
+1 -1
View File
@@ -6,7 +6,7 @@
- **Komplette Lokalisierung der App (Deutsch/Englisch)**: Alle Seiten und
Komponenten sind jetzt übersetzt — Tool-Detailseite, Verwaltung, Papierkorb,
Redundanz-Dashboard, Vergleichen, Watchlist, Analytics, Tool anlegen und
Redundanz-Dashboard, Vergleichen, Merkliste, Analytics, Tool anlegen und
bearbeiten, Admin-Bereich inkl. Tools-Tab, Kategorie-Combobox und
Theme-Umschalter. Die Sprachauswahl oben rechts wechselt die gesamte
Oberfläche.
+45
View File
@@ -0,0 +1,45 @@
# v0.8.5 — Release Notes
**Date:** 2026-08-04 · **Tag:** [`v0.8.5`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.5)
## New features
- **Help buttons across the app**: The main pages (Home, Browse tools, Trash,
Redundancy dashboard, Admin tools) as well as the premium dialog and the
password dialog now show a book icon next to the title that links directly
to the matching manual page (following the existing buttons on the tool
detail, create, edit, compare, watchlist and analytics pages).
## Fixes & improvements
- **Translated submit button when creating a tool**: The "Submit Tool" /
"Adding Tool…" button was still hardcoded and appeared in English in the
German UI. It is now translated, e.g. "Tool einreichen" / "Füge Tool
hinzu…" in German.
- **More translated UI strings**: View mode (Grid/Table/Rows), list density
(Compact/Comfortable), minimum rating in the filter and the password hint
"min. 6 characters" are now translated correctly.
- **Consistent docs terminology**: The manual now consistently calls the
watchlist "Merkliste" in all German pages and release notes; the English
version stays "Watchlist". The `watchlist` permission name and the URLs
are unchanged.
- All locales are in sync (462 keys per language, 0 missing).
## API changes
- No changes to the API.
## Operations / upgrade
- **Env vars:** unchanged.
- **Migration:** none.
- **Breaking changes:** none.
## Known limitations
- None.
## Links
- Commit: [`70e72da`](https://git.kubebase.de/admin/tool-evaluator/commit/70e72da)
- Tag: [`v0.8.5`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.5)
+46
View File
@@ -0,0 +1,46 @@
# v0.8.5 — Release Notes
**Datum:** 2026-08-04 · **Tag:** [`v0.8.5`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.5)
## Neue Features
- **Hilfe-Buttons in der gesamten App**: Auf den Hauptseiten (Start, Tools
durchsuchen, Papierkorb, Redundanz-Dashboard, Admin-Tools) sowie in
Premium-Dialog und Passwort-Dialog gibt es jetzt ein Buch-Icon neben dem
Titel, das direkt zur passenden Anleitung im Handbuch verlinkt (analog zu
den bestehenden Buttons auf Tool-Detail-, Anlegen-, Bearbeiten-,
Vergleichen-, Merkliste- und Analytics-Seiten).
## Fixes & Verbesserungen
- **Submit-Button beim Tool-Anlegen übersetzt**: Der Button „Submit Tool" /
„Adding Tool…" war noch hartkodiert und erschien auf Deutsch auf Englisch.
Jetzt: „Tool einreichen" / „Füge Tool hinzu…" (bzw. die englischen
Entsprechungen).
- **Weitere übersetzte UI-Strings**: Ansichtsmodus (Raster/Tabelle/Zeilen),
Listendichte (Kompakt/Komfortabel), Mindestbewertung im Filter sowie der
Passwort-Hinweis „min. 6 Zeichen" werden jetzt korrekt übersetzt.
- **Doku-Terminologie konsistent**: Das Handbuch heißt die Merkliste jetzt
einheitlich „Merkliste" (statt „Watchlist") in allen deutschen Seiten und
Release-Notes; die englische Fassung bleibt „Watchlist". Die
Permissions-Bezeichnung `watchlist` und die URLs sind unverändert.
- Alle Locales synchron (462 Keys pro Sprache, 0 fehlend).
## API-Änderungen
- Keine Änderungen an der API.
## Betrieb / Upgrade
- **Env-Vars:** unverändert.
- **Migration:** keine.
- **Breaking Changes:** keine.
## Bekannte Einschränkungen
- Keine.
## Links
- Commit: [`70e72da`](https://git.kubebase.de/admin/tool-evaluator/commit/70e72da)
- Tag: [`v0.8.5`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.5)
+49
View File
@@ -0,0 +1,49 @@
# v0.8.6 — Release Notes
**Date:** 2026-08-04 · **Tag:** [`v0.8.6`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.6)
## New features
- **Bulk tool import in the admin area (premium)**: Admins can now create
multiple tools at once via the new **Import** button in the "Tools" tab.
The format is detected automatically (CSV, JSON or YAML); for CSV the
delimiter can be chosen (auto-detect, comma, semicolon or tab). Data can be
pasted directly or uploaded as a file. Faulty rows are skipped and listed
with their row number while correct rows are still created. Every import
produces audit-log entries. The feature is enabled via the `tool-import`
feature (premium/enterprise); admins always pass the check.
## Improvements
- **NetBox-style help buttons**: The previously attached book icon and the
`FieldHelp` question marks (which showed a German tooltip in the English
layout) have been removed. Forms (create tool, edit tool, rate, password
dialog) now show a "Hilfe"/"Help" outline button with a question-mark icon
at the top right that opens the corresponding handbook page — fully
localized.
- All locales are in sync (482 keys per language, 0 missing).
## API changes
- **New:** `POST /admin/tools/import` (admin + `tool-import` feature).
Request body: `format` (`auto|csv|json|yaml`, default `auto`), `delimiter`
(`auto|comma|semicolon|tab`, default `auto`), `data` (string). Response:
`{ imported, total, errors: [{ row, error }] }`.
## Operations / upgrade
- **Env vars:** unchanged.
- **Migration:** none.
- **Breaking changes:** none. The import endpoint is only available to
premium/enterprise admins; free/user accounts keep standard access.
## Known limitations
- CSV columns whose header does not map to a supported field are ignored;
only the defined fields (`name`, `description`, `category`, `websiteUrl`,
`iconUrl`, `features`, `tags`) are taken over.
## Links
- Commit: [`8f2fd89`](https://git.kubebase.de/admin/tool-evaluator/commit/8f2fd89)
- Tag: [`v0.8.6`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.6)
+50
View File
@@ -0,0 +1,50 @@
# v0.8.6 — Release Notes
**Datum:** 2026-08-04 · **Tag:** [`v0.8.6`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.6)
## Neue Features
- **Bulk-Tool-Import im Admin (Premium)**: Admins können über den neuen Button
**Importieren** im Tab „Tools" mehrere Tools gleichzeitig anlegen. Das Format
wird automatisch erkannt (CSV, JSON oder YAML); für CSV ist der Trenner
wählbar (automatisch, Komma, Semikolon oder Tabulator). Daten lassen sich
direkt einfügen oder als Datei hochladen. Fehlerhafte Zeilen werden übersprungen
und mit Zeilennummer aufgelistet, korrekte weiterhin angelegt. Jeder Import
erzeugt Audit-Log-Einträge. Das Feature ist über das `tool-import`-Feature
(Premium/Enterprise) freigeschaltet; Admins passieren den Check immer.
## Verbesserungen
- **Hilfe-Buttons im NetBox-Stil**: Das zuvor angebundene Buch-Icon und die
`FieldHelp`-Fragezeichen (die im englischen Layout deutschen Tooltip zeigten)
wurden entfernt. Formulare (Tool anlegen, Tool bearbeiten, Bewerten,
Passwort-Dialog) haben jetzt oben rechts einen „Hilfe"/„Help"-Outline-Button mit
Fragezeichen-Icon, der die zugehörige Handbuch-Seite öffnet — vollständig
übersetzt.
- Alle Locales synchron (482 Keys pro Sprache, 0 fehlend).
## API-Änderungen
- **Neu:** `POST /admin/tools/import` (Admin + `tool-import`-Feature).
Request-Body: `format` (`auto|csv|json|yaml`, Default `auto`), `delimiter`
(`auto|comma|semicolon|tab`, Default `auto`), `data` (String). Antwort:
`{ imported, total, errors: [{ row, error }] }`.
## Betrieb / Upgrade
- **Env-Vars:** unverändert.
- **Migration:** keine.
- **Breaking Changes:** keine. Der Import-Endpunkt ist ausschließlich für
Premium/Enterprise-Admins; Free-/User-Konten erhalten weiterhin den Standard-
Zugriff.
## Bekannte Einschränkungen
- CSV-Spalten, die in der Kopfzeile nicht als unterstützte Felder erkannt werden,
werden ignoriert; nur die definierten Felder (`name`, `description`,
`category`, `websiteUrl`, `iconUrl`, `features`, `tags`) werden übernommen.
## Links
- Commit: [`8f2fd89`](https://git.kubebase.de/admin/tool-evaluator/commit/8f2fd89)
- Tag: [`v0.8.6`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.6)
@@ -219,6 +219,43 @@ export interface ToolInput {
tags?: string[];
}
export type ToolImportBodyFormat = typeof ToolImportBodyFormat[keyof typeof ToolImportBodyFormat];
export const ToolImportBodyFormat = {
auto: 'auto',
csv: 'csv',
json: 'json',
yaml: 'yaml',
} as const;
export type ToolImportBodyDelimiter = typeof ToolImportBodyDelimiter[keyof typeof ToolImportBodyDelimiter];
export const ToolImportBodyDelimiter = {
auto: 'auto',
comma: 'comma',
semicolon: 'semicolon',
tab: 'tab',
} as const;
export interface ToolImportBody {
format?: ToolImportBodyFormat;
delimiter?: ToolImportBodyDelimiter;
data: string;
}
export type ToolImportResponseErrorsItem = {
row?: number;
error?: string;
};
export interface ToolImportResponse {
imported: number;
total: number;
errors: ToolImportResponseErrorsItem[];
}
export interface ToolUpdate {
/** @minLength 1 */
name?: string;
+73
View File
@@ -45,6 +45,8 @@ import type {
RestoreTools200,
SetPasswordInput,
Tool,
ToolImportBody,
ToolImportResponse,
ToolInput,
ToolUpdate,
ToolWithStats,
@@ -2969,3 +2971,74 @@ export function useListAuditLogs<TData = Awaited<ReturnType<typeof listAuditLogs
export const getImportToolsUrl = () => {
return `/api/admin/tools/import`
}
/**
* @summary Import tools in bulk (premium feature, admin only)
*/
export const importTools = async (toolImportBody: ToolImportBody, options?: Parameters<typeof customFetch>[1]): Promise<ToolImportResponse> => {
return customFetch<ToolImportResponse>(getImportToolsUrl(),
{
...options,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(toolImportBody)
}
);}
export const getImportToolsMutationOptions = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof importTools>>, TError,{data: BodyType<ToolImportBody>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof importTools>>, TError,{data: BodyType<ToolImportBody>}, TContext> => {
const mutationKey = ['importTools'];
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 importTools>>, {data: BodyType<ToolImportBody>}> = (props) => {
const {data} = props ?? {};
return importTools(data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type ImportToolsMutationResult = NonNullable<Awaited<ReturnType<typeof importTools>>>
export type ImportToolsMutationBody = BodyType<ToolImportBody>
export type ImportToolsMutationError = ErrorType<ErrorResponse>
/**
* @summary Import tools in bulk (premium feature, admin only)
*/
export const useImportTools = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof importTools>>, TError,{data: BodyType<ToolImportBody>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationResult<
Awaited<ReturnType<typeof importTools>>,
TError,
{data: BodyType<ToolImportBody>},
TContext
> => {
return useMutation(getImportToolsMutationOptions(options));
}
+64
View File
@@ -924,6 +924,37 @@ paths:
items:
$ref: "#/components/schemas/AuditLog"
/admin/tools/import:
post:
operationId: importTools
tags: [admin]
summary: Import tools in bulk (premium feature, admin only)
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ToolImportBody"
responses:
"200":
description: Import result
content:
application/json:
schema:
$ref: "#/components/schemas/ToolImportResponse"
"400":
description: Validation or parse error
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"403":
description: Premium feature required
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
components:
schemas:
HealthStatus:
@@ -1191,6 +1222,39 @@ components:
items:
type: string
ToolImportBody:
type: object
required: [data]
properties:
format:
type: string
enum: [auto, csv, json, yaml]
default: auto
delimiter:
type: string
enum: [auto, comma, semicolon, tab]
default: auto
data:
type: string
ToolImportResponse:
type: object
required: [imported, total, errors]
properties:
imported:
type: integer
total:
type: integer
errors:
type: array
items:
type: object
properties:
row:
type: integer
error:
type: string
ToolUpdate:
type: object
properties:
+22
View File
@@ -719,3 +719,25 @@ export const ListAuditLogsResponseItem = zod.object({
export const ListAuditLogsResponse = zod.array(ListAuditLogsResponseItem)
/**
* @summary Import tools in bulk (premium feature, admin only)
*/
export const importToolsBodyFormatDefault = `auto`;
export const importToolsBodyDelimiterDefault = `auto`;
export const ImportToolsBody = zod.object({
"format": zod.enum(['auto', 'csv', 'json', 'yaml']).default(importToolsBodyFormatDefault),
"delimiter": zod.enum(['auto', 'comma', 'semicolon', 'tab']).default(importToolsBodyDelimiterDefault),
"data": zod.string()
})
export const ImportToolsResponse = zod.object({
"imported": zod.int(),
"total": zod.int(),
"errors": zod.array(zod.object({
"row": zod.int().optional(),
"error": zod.string().optional()
}))
})
+5
View File
@@ -37,6 +37,11 @@ export * from './restoreTools200';
export * from './scoreBucket';
export * from './setPasswordInput';
export * from './tool';
export * from './toolImportBody';
export * from './toolImportBodyDelimiter';
export * from './toolImportBodyFormat';
export * from './toolImportResponse';
export * from './toolImportResponseErrorsItem';
export * from './toolInput';
export * from './toolUpdate';
export * from './toolWithStats';
@@ -0,0 +1,15 @@
/**
* Generated by orval v8.23.0 🍺
* Do not edit manually.
* Api
* ToolRate API Tool listing and rating platform
* OpenAPI spec version: 0.1.0
*/
import type { ToolImportBodyDelimiter } from './toolImportBodyDelimiter';
import type { ToolImportBodyFormat } from './toolImportBodyFormat';
export interface ToolImportBody {
format?: ToolImportBodyFormat;
delimiter?: ToolImportBodyDelimiter;
data: string;
}
@@ -0,0 +1,17 @@
/**
* Generated by orval v8.23.0 🍺
* Do not edit manually.
* Api
* ToolRate API Tool listing and rating platform
* OpenAPI spec version: 0.1.0
*/
export type ToolImportBodyDelimiter = typeof ToolImportBodyDelimiter[keyof typeof ToolImportBodyDelimiter];
export const ToolImportBodyDelimiter = {
auto: 'auto',
comma: 'comma',
semicolon: 'semicolon',
tab: 'tab',
} as const;
@@ -0,0 +1,17 @@
/**
* Generated by orval v8.23.0 🍺
* Do not edit manually.
* Api
* ToolRate API Tool listing and rating platform
* OpenAPI spec version: 0.1.0
*/
export type ToolImportBodyFormat = typeof ToolImportBodyFormat[keyof typeof ToolImportBodyFormat];
export const ToolImportBodyFormat = {
auto: 'auto',
csv: 'csv',
json: 'json',
yaml: 'yaml',
} as const;
@@ -0,0 +1,14 @@
/**
* Generated by orval v8.23.0 🍺
* Do not edit manually.
* Api
* ToolRate API Tool listing and rating platform
* OpenAPI spec version: 0.1.0
*/
import type { ToolImportResponseErrorsItem } from './toolImportResponseErrorsItem';
export interface ToolImportResponse {
imported: number;
total: number;
errors: ToolImportResponseErrorsItem[];
}
@@ -0,0 +1,12 @@
/**
* Generated by orval v8.23.0 🍺
* Do not edit manually.
* Api
* ToolRate API Tool listing and rating platform
* OpenAPI spec version: 0.1.0
*/
export type ToolImportResponseErrorsItem = {
row?: number;
error?: string;
};
+18 -22
View File
@@ -239,6 +239,9 @@ importers:
pino-http:
specifier: 11.0.0
version: 11.0.0
yaml:
specifier: 'catalog:'
version: 2.9.0
zod:
specifier: 'catalog:'
version: 4.4.3
@@ -368,7 +371,7 @@ importers:
version: 0.0.6
'@tailwindcss/vite':
specifier: 'catalog:'
version: 4.3.3(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.8.4))
version: 4.3.3(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.9.0))
'@types/node':
specifier: 'catalog:'
version: 26.1.2
@@ -380,7 +383,7 @@ importers:
version: 19.2.4(@types/react@19.2.18)
'@vitejs/plugin-react':
specifier: 'catalog:'
version: 6.0.5(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.8.4))
version: 6.0.5(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.9.0))
chokidar:
specifier: 5.0.0
version: 5.0.0
@@ -455,7 +458,7 @@ importers:
version: 1.1.2(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
vite:
specifier: 'catalog:'
version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.8.4)
version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.9.0)
zod:
specifier: 'catalog:'
version: 4.4.3
@@ -560,7 +563,7 @@ importers:
version: 0.5.20(tailwindcss@4.3.3)
'@tailwindcss/vite':
specifier: 'catalog:'
version: 4.3.3(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.8.4))
version: 4.3.3(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.9.0))
'@tanstack/react-query':
specifier: 'catalog:'
version: 5.101.4(react@19.2.8)
@@ -578,7 +581,7 @@ importers:
version: 19.2.4(@types/react@19.2.18)
'@vitejs/plugin-react':
specifier: 'catalog:'
version: 6.0.5(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.8.4))
version: 6.0.5(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.9.0))
'@workspace/api-client-react':
specifier: workspace:*
version: link:../../lib/api-client-react
@@ -662,7 +665,7 @@ importers:
version: 1.1.2(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
vite:
specifier: 'catalog:'
version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.8.4)
version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.9.0)
wouter:
specifier: 'catalog:'
version: 3.10.0(react@19.2.8)
@@ -3752,11 +3755,6 @@ packages:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
yaml@2.8.4:
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
engines: {node: '>= 14.6'}
hasBin: true
yaml@2.9.0:
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
engines: {node: '>= 14.6'}
@@ -4961,7 +4959,7 @@ snapshots:
dependencies:
'@scalar/helpers': 0.9.2
pathe: 2.0.3
yaml: 2.8.4
yaml: 2.9.0
'@scalar/openapi-parser@0.28.11':
dependencies:
@@ -4974,7 +4972,7 @@ snapshots:
ajv-formats: 3.0.1
jsonpointer: 5.0.1
leven: 4.1.0
yaml: 2.8.4
yaml: 2.9.0
'@scalar/openapi-types@0.8.0': {}
@@ -5041,12 +5039,12 @@ snapshots:
postcss-selector-parser: 6.0.10
tailwindcss: 4.3.3
'@tailwindcss/vite@4.3.3(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.8.4))':
'@tailwindcss/vite@4.3.3(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.9.0))':
dependencies:
'@tailwindcss/node': 4.3.3
'@tailwindcss/oxide': 4.3.3
tailwindcss: 4.3.3
vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.8.4)
vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.9.0)
'@tanstack/query-core@5.101.4': {}
@@ -5246,10 +5244,10 @@ snapshots:
'@typescript/typescript-win32-x64@7.0.2':
optional: true
'@vitejs/plugin-react@6.0.5(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.8.4))':
'@vitejs/plugin-react@6.0.5(vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.9.0))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.8.4)
vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.9.0)
accepts@2.0.0:
dependencies:
@@ -6485,7 +6483,7 @@ snapshots:
markdown-it: 15.0.0
minimatch: 10.2.5
typescript: 7.0.2
yaml: 2.8.4
yaml: 2.9.0
typescript@7.0.2:
optionalDependencies:
@@ -6575,7 +6573,7 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.8.4):
vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.4)(yaml@2.9.0):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.5
@@ -6588,7 +6586,7 @@ snapshots:
fsevents: 2.3.3
jiti: 2.7.0
tsx: 4.23.4
yaml: 2.8.4
yaml: 2.9.0
which@2.0.2:
dependencies:
@@ -6605,8 +6603,6 @@ snapshots:
xtend@4.0.2: {}
yaml@2.8.4: {}
yaml@2.9.0: {}
yocto-queue@1.2.2: {}