Compare commits

...

2 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
40 changed files with 969 additions and 159 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;
@@ -15,7 +15,6 @@ import {
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { GuideHelp } from "@/components/guide-help";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
@@ -24,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();
@@ -37,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);
@@ -106,15 +107,22 @@ export function AdminToolsTab() {
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4 flex-wrap">
<div className="space-y-1.5">
<CardTitle className="flex items-center gap-2">
{t("adminTools.allTools")}
<GuideHelp guide="administration" label={t("adminTools.allTools")} />
</CardTitle>
<CardTitle>{t("adminTools.allTools")}</CardTitle>
<CardDescription>
{selectedIds.length > 0 ? t("adminTools.selected", { count: selectedIds.length }) : t("adminTools.toolCount", { count: allTools.length })}
</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
@@ -233,6 +241,15 @@ export function AdminToolsTab() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ToolImportDialog
open={importOpen}
onOpenChange={setImportOpen}
onImported={() => {
toast({ title: t("adminTools.toastImportDone") });
invalidate();
}}
/>
</Card>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -164,11 +164,9 @@ export function UserMenu() {
<Dialog open={pwOpen} onOpenChange={setPwOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{t("auth.changePassword")}
<GuideHelp guide="konto" label={t("auth.changePassword")} />
</DialogTitle>
<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">
+22 -2
View File
@@ -57,6 +57,7 @@
"language": "Sprache",
"viewDetails": "Details ansehen",
"guideTooltip": "Anleitung in der Dokumentation",
"help": "Hilfe",
"viewMode": "Ansichtsmodus",
"viewGrid": "Raster",
"viewTable": "Tabelle",
@@ -304,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"
@@ -402,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",
+22 -2
View File
@@ -57,6 +57,7 @@
"language": "Language",
"viewDetails": "View details",
"guideTooltip": "Guide in the documentation",
"help": "Help",
"viewMode": "View mode",
"viewGrid": "Grid",
"viewTable": "Table",
@@ -304,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"
@@ -402,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>
+1 -3
View File
@@ -7,7 +7,6 @@ import {
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Layout } from "@/components/layout";
import { GuideHelp } from "@/components/guide-help";
import { ToolCard } from "@/components/tool-card";
import { Star, Wrench, MessageSquare, ArrowRight, Plus } from "lucide-react";
import { Link } from "wouter";
@@ -25,9 +24,8 @@ 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 flex items-center gap-2">
<h1 className="text-3xl font-bold tracking-tight mb-2">
{t("home.welcome")}
<GuideHelp guide="getting-started" label={t("home.welcome")} />
</h1>
<p className="text-muted-foreground">{t("home.tagline")}</p>
</div>
+1 -5
View File
@@ -1,7 +1,6 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Layout } from "@/components/layout";
import { GuideHelp } from "@/components/guide-help";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -47,10 +46,7 @@ export default function RedundancyPage() {
<div className="space-y-6">
<div className="flex items-center gap-3">
<AlertTriangle className="w-6 h-6 text-amber-500" />
<h1 className="text-3xl font-bold flex items-center gap-2">
{t("redundancy.title")}
<GuideHelp guide="redundanz" label={t("redundancy.title")} />
</h1>
<h1 className="text-3xl font-bold">{t("redundancy.title")}</h1>
</div>
<p className="text-muted-foreground">
{t("redundancy.subtitle")}
+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>
+9 -15
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>
@@ -6,7 +6,6 @@ import {
} from "@workspace/api-client-react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { Layout } from "@/components/layout";
import { GuideHelp } from "@/components/guide-help";
import { ToolCard } from "@/components/tool-card";
import { ToolCardWide } from "@/components/tool-card-wide";
import { ToolRow, TABLE_GRID } from "@/components/tool-row";
@@ -231,9 +230,8 @@ 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 flex items-center gap-2">
<h1 className="text-3xl font-bold tracking-tight mb-2">
{t("nav.browseTools")}
<GuideHelp guide="tools-finden" label={t("browse.subtitle")} />
</h1>
<p className="text-muted-foreground">{t("browse.subtitle")}</p>
</div>
@@ -525,10 +523,7 @@ export default function ToolsBrowse() {
<AlertDialog open={compareUpsellOpen} onOpenChange={setCompareUpsellOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
{t("browse.comparePremiumTitle")}
<GuideHelp guide="plaene" label={t("browse.comparePremiumTitle")} />
</AlertDialogTitle>
<AlertDialogTitle>{t("browse.comparePremiumTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("browse.comparePremiumSub")}
</AlertDialogDescription>
+1 -3
View File
@@ -15,7 +15,6 @@ import {
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { Layout } from "@/components/layout";
import { GuideHelp } from "@/components/guide-help";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -157,9 +156,8 @@ export default function Trash() {
<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("trash.title")}
<GuideHelp guide="papierkorb" label={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"
+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 |
+1
View File
@@ -23,6 +23,7 @@ die richtige Wahl zu treffen.
| **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 |
+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.
+1
View File
@@ -26,6 +26,7 @@ Premium/Enterprise schalten folgende Features frei:
| `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.
+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: {}