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
This commit is contained in:
opencode
2026-08-04 23:09:11 +02:00
parent d47db6d386
commit 8f2fd89847
38 changed files with 870 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.
@@ -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: {}