Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca980fce09 | |||
| 8f2fd89847 | |||
| d47db6d386 | |||
| 70e72dac71 |
@@ -23,6 +23,7 @@
|
|||||||
"openid-client": "6.8.4",
|
"openid-client": "6.8.4",
|
||||||
"pino": "10.3.1",
|
"pino": "10.3.1",
|
||||||
"pino-http": "11.0.0",
|
"pino-http": "11.0.0",
|
||||||
|
"yaml": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { type Request, type Response, type NextFunction } from "express";
|
|||||||
|
|
||||||
const TIER_FEATURES: Record<string, string[]> = {
|
const TIER_FEATURES: Record<string, string[]> = {
|
||||||
free: ["browse", "rate", "search"],
|
free: ["browse", "rate", "search"],
|
||||||
premium: ["browse", "rate", "search", "similar-tools", "costs", "redundancy", "analytics-advanced", "trash", "compare", "watchlist"],
|
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", "sso", "audit-export", "api-access"],
|
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[] {
|
export function getEntitlements(tier: string | undefined, role: string | undefined): string[] {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Router, type IRouter } from "express";
|
import { Router, type IRouter } from "express";
|
||||||
import { eq, and, sql } from "drizzle-orm";
|
import { eq, and, sql } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { parse as parseYaml } from "yaml";
|
||||||
import { db, toolsTable, ratingsTable, toolCostsTable, toolRelationsTable } from "@workspace/db";
|
import { db, toolsTable, ratingsTable, toolCostsTable, toolRelationsTable } from "@workspace/db";
|
||||||
import { requireAdmin } from "../middleware/auth";
|
import { requireAdmin } from "../middleware/auth";
|
||||||
|
import { requireFeature } from "../middleware/feature";
|
||||||
import { writeAuditLog } from "../lib/audit";
|
import { writeAuditLog } from "../lib/audit";
|
||||||
|
import { CreateToolBody } from "@workspace/api-zod";
|
||||||
|
|
||||||
const EvaluateBody = z.object({
|
const EvaluateBody = z.object({
|
||||||
toolId: z.coerce.number().int().positive(),
|
toolId: z.coerce.number().int().positive(),
|
||||||
@@ -219,4 +222,184 @@ router.post("/admin/redundancy/evaluate", requireAdmin, async (req, res): Promis
|
|||||||
res.json({ ok: true });
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -23,12 +23,13 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||||
import { 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";
|
import { format } from "date-fns";
|
||||||
|
|
||||||
export function AdminToolsTab() {
|
export function AdminToolsTab() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isAdmin, isLoading: authLoading } = useAuth();
|
const { isAdmin, hasFeature, isLoading: authLoading } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ export function AdminToolsTab() {
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
const [confirmTrash, setConfirmTrash] = useState(false);
|
const [confirmTrash, setConfirmTrash] = useState(false);
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => setSearch(searchInput), 300);
|
const t = setTimeout(() => setSearch(searchInput), 300);
|
||||||
@@ -111,6 +113,16 @@ export function AdminToolsTab() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<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">
|
<div className="relative">
|
||||||
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -229,6 +241,15 @@ export function AdminToolsTab() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<ToolImportDialog
|
||||||
|
open={importOpen}
|
||||||
|
onOpenChange={setImportOpen}
|
||||||
|
onImported={() => {
|
||||||
|
toast({ title: t("adminTools.toastImportDone") });
|
||||||
|
invalidate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Maximize2, Minimize2 } from "lucide-react";
|
import { Maximize2, Minimize2 } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export type Density = "cozy" | "compact";
|
export type Density = "cozy" | "compact";
|
||||||
|
|
||||||
@@ -10,8 +11,13 @@ export function DensityToggle({
|
|||||||
value: Density;
|
value: Density;
|
||||||
onValueChange: (d: Density) => void;
|
onValueChange: (d: Density) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const densityLabel = value === "compact" ? t("common.densityCompact") : t("common.densityComfortable");
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2" title={`Density: ${value === "compact" ? "Compact" : "Comfortable"}`}>
|
<div
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
title={t("common.densityTitle", { density: densityLabel })}
|
||||||
|
>
|
||||||
<Maximize2 className="w-4 h-4 text-muted-foreground" aria-hidden />
|
<Maximize2 className="w-4 h-4 text-muted-foreground" aria-hidden />
|
||||||
<Slider
|
<Slider
|
||||||
className="w-16"
|
className="w-16"
|
||||||
@@ -20,7 +26,7 @@ export function DensityToggle({
|
|||||||
step={1}
|
step={1}
|
||||||
value={[value === "compact" ? 1 : 0]}
|
value={[value === "compact" ? 1 : 0]}
|
||||||
onValueChange={([v]) => onValueChange(v === 1 ? "compact" : "cozy")}
|
onValueChange={([v]) => onValueChange(v === 1 ? "compact" : "cozy")}
|
||||||
aria-label="List density"
|
aria-label={t("common.listDensity")}
|
||||||
/>
|
/>
|
||||||
<Minimize2 className="w-4 h-4 text-muted-foreground" aria-hidden />
|
<Minimize2 className="w-4 h-4 text-muted-foreground" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { HelpCircle } from "lucide-react";
|
|
||||||
import { Link } from "wouter";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
|
|
||||||
export function FieldHelp({
|
|
||||||
schema,
|
|
||||||
field,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
schema: string;
|
|
||||||
field: string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const label = children ?? field;
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Link
|
|
||||||
href={`/docs/reference/schemas/${schema}#${field}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
aria-label={`Help: ${label}`}
|
|
||||||
data-testid={`help-${schema}-${field}`}
|
|
||||||
className="inline-flex shrink-0 text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<HelpCircle className="h-3.5 w-3.5" />
|
|
||||||
</Link>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{label} — Details in der Dokumentation</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -105,7 +105,7 @@ export function FilterPopover({
|
|||||||
step={0.5}
|
step={0.5}
|
||||||
value={[minRating ?? 0]}
|
value={[minRating ?? 0]}
|
||||||
onValueChange={([v]) => onChange({ minRating: v })}
|
onValueChange={([v]) => onChange({ minRating: v })}
|
||||||
aria-label="Minimum rating"
|
aria-label={t("filter.minRating")}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm tabular-nums w-8 text-right text-muted-foreground">
|
<span className="text-sm tabular-nums w-8 text-right text-muted-foreground">
|
||||||
{minRating != null ? `${minRating.toFixed(1)}+` : t("filter.any")}
|
{minRating != null ? `${minRating.toFixed(1)}+` : t("filter.any")}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { BookOpen } from "lucide-react";
|
import { HelpCircle } from "lucide-react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
export function GuideHelp({
|
export function GuideHelp({
|
||||||
@@ -9,21 +11,24 @@ export function GuideHelp({
|
|||||||
guide: string;
|
guide: string;
|
||||||
label: string;
|
label: string;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Link
|
<Button asChild variant="outline" size="sm" className="gap-1.5 text-muted-foreground">
|
||||||
href={`/docs/handbook/${guide}`}
|
<Link
|
||||||
target="_blank"
|
href={`/docs/handbook/${guide}`}
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
aria-label={`Help: ${label}`}
|
rel="noreferrer"
|
||||||
data-testid={`guide-${guide}`}
|
aria-label={`${t("common.help")}: ${label}`}
|
||||||
className="inline-flex shrink-0 text-muted-foreground hover:text-foreground transition-colors"
|
data-testid={`guide-${guide}`}
|
||||||
>
|
>
|
||||||
<BookOpen className="h-3.5 w-3.5" />
|
<HelpCircle className="h-4 w-4" />
|
||||||
</Link>
|
{t("common.help")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{label} — Anleitung in der Dokumentation</TooltipContent>
|
<TooltipContent>{label} — {t("common.guideTooltip")}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useImportTools, type ToolImportResponse } from "@workspace/api-client-react";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Upload, FileUp, CheckCircle2, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
type Format = "auto" | "csv" | "json" | "yaml";
|
||||||
|
type Delimiter = "auto" | "comma" | "semicolon" | "tab";
|
||||||
|
|
||||||
|
export function ToolImportDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onImported,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onImported: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const importTools = useImportTools();
|
||||||
|
const [format, setFormat] = useState<Format>("auto");
|
||||||
|
const [delimiter, setDelimiter] = useState<Delimiter>("auto");
|
||||||
|
const [data, setData] = useState("");
|
||||||
|
const [result, setResult] = useState<ToolImportResponse | null>(null);
|
||||||
|
|
||||||
|
function handleFile(file: File) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
setData(String(reader.result ?? ""));
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImport() {
|
||||||
|
importTools.mutate(
|
||||||
|
{ data: { format, delimiter, data } },
|
||||||
|
{
|
||||||
|
onSuccess: (res) => {
|
||||||
|
setResult(res);
|
||||||
|
if (res.imported > 0) onImported();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose(open: boolean) {
|
||||||
|
if (!importTools.isPending) {
|
||||||
|
onOpenChange(open);
|
||||||
|
if (!open) {
|
||||||
|
setData("");
|
||||||
|
setResult(null);
|
||||||
|
setFormat("auto");
|
||||||
|
setDelimiter("auto");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit = data.trim().length > 0 && !importTools.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
{t("adminTools.importTitle")}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<p className="text-sm text-muted-foreground">{t("adminTools.importSub")}</p>
|
||||||
|
|
||||||
|
<Tabs value={format} onValueChange={(v) => setFormat(v as Format)}>
|
||||||
|
<TabsList className="w-full grid grid-cols-4">
|
||||||
|
<TabsTrigger value="auto">{t("adminTools.importFormatAuto")}</TabsTrigger>
|
||||||
|
<TabsTrigger value="csv">CSV</TabsTrigger>
|
||||||
|
<TabsTrigger value="json">JSON</TabsTrigger>
|
||||||
|
<TabsTrigger value="yaml">YAML</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="auto" className="text-xs text-muted-foreground pt-2">
|
||||||
|
{t("adminTools.importFormatAutoSub")}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="csv" className="pt-2 space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("adminTools.importDelimiter")}</Label>
|
||||||
|
<Select value={delimiter} onValueChange={(v) => setDelimiter(v as Delimiter)}>
|
||||||
|
<SelectTrigger className="w-52">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">{t("adminTools.importDelimiterAuto")}</SelectItem>
|
||||||
|
<SelectItem value="comma">{t("adminTools.importDelimiterComma")}</SelectItem>
|
||||||
|
<SelectItem value="semicolon">{t("adminTools.importDelimiterSemicolon")}</SelectItem>
|
||||||
|
<SelectItem value="tab">{t("adminTools.importDelimiterTab")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("adminTools.importCsvSub")}</p>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="json" className="pt-2 space-y-3">
|
||||||
|
<p className="text-xs text-muted-foreground">{t("adminTools.importJsonSub")}</p>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="yaml" className="pt-2 space-y-3">
|
||||||
|
<p className="text-xs text-muted-foreground">{t("adminTools.importYamlSub")}</p>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button type="button" variant="outline" size="sm" className="gap-2" onClick={() => document.getElementById("tool-import-file")?.click()}>
|
||||||
|
<FileUp className="w-4 h-4" />
|
||||||
|
{t("adminTools.importFile")}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
id="tool-import-file"
|
||||||
|
type="file"
|
||||||
|
accept=".csv,.json,.yaml,.yml,.txt,text/plain,application/json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) handleFile(f);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
value={data}
|
||||||
|
onChange={(e) => setData(e.target.value)}
|
||||||
|
placeholder={t("adminTools.importTextareaPlaceholder")}
|
||||||
|
className="min-h-[220px] font-mono text-sm"
|
||||||
|
data-testid="input-tool-import-data"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{importTools.isError && (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||||
|
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||||
|
<span>{importTools.error?.data?.error ?? importTools.error?.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-primary/20 bg-primary/5 px-3 py-2 text-sm">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-primary shrink-0" />
|
||||||
|
<span>{t("adminTools.importDone", { count: result.imported, total: result.total })}</span>
|
||||||
|
</div>
|
||||||
|
{result.errors.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium flex items-center gap-1.5">
|
||||||
|
<AlertCircle className="w-4 h-4 text-amber-500" />
|
||||||
|
{t("adminTools.importErrors", { count: result.errors.length })}
|
||||||
|
</p>
|
||||||
|
<div className="max-h-40 overflow-y-auto rounded-md border bg-muted/40 divide-y divide-border">
|
||||||
|
{result.errors.map((err, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2 px-3 py-1.5 text-xs">
|
||||||
|
<span className="font-mono text-muted-foreground shrink-0">
|
||||||
|
{t("adminTools.importErrorRow", { row: err.row })}:
|
||||||
|
</span>
|
||||||
|
<span className="text-foreground break-words">{err.error}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => handleClose(false)} disabled={importTools.isPending}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleImport} disabled={!canSubmit}>
|
||||||
|
{importTools.isPending ? t("adminTools.importing") : t("adminTools.importButton")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { PasswordInput } from "@/components/password-input";
|
import { PasswordInput } from "@/components/password-input";
|
||||||
|
import { GuideHelp } from "@/components/guide-help";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { useChangeMyPassword, useGetPasswordRedirect, getGetPasswordRedirectQueryKey } from "@workspace/api-client-react";
|
import { useChangeMyPassword, useGetPasswordRedirect, getGetPasswordRedirectQueryKey } from "@workspace/api-client-react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -163,8 +164,9 @@ export function UserMenu() {
|
|||||||
|
|
||||||
<Dialog open={pwOpen} onOpenChange={setPwOpen}>
|
<Dialog open={pwOpen} onOpenChange={setPwOpen}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
|
||||||
<DialogTitle>{t("auth.changePassword")}</DialogTitle>
|
<DialogTitle>{t("auth.changePassword")}</DialogTitle>
|
||||||
|
<GuideHelp guide="konto" label={t("auth.changePassword")} />
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{!user?.isLocal ? (
|
{!user?.isLocal ? (
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
@@ -193,7 +195,7 @@ export function UserMenu() {
|
|||||||
<PasswordInput
|
<PasswordInput
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
placeholder="min. 6 characters"
|
placeholder={t("auth.passwordMinLength")}
|
||||||
data-testid="input-new-password"
|
data-testid="input-new-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { LayoutGrid, List, Rows3 } from "lucide-react";
|
import { LayoutGrid, List, Rows3 } from "lucide-react";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export type ViewMode = "grid" | "table" | "rows";
|
export type ViewMode = "grid" | "table" | "rows";
|
||||||
|
|
||||||
const OPTIONS: { value: ViewMode; label: string; Icon: typeof LayoutGrid }[] = [
|
const OPTIONS: { value: ViewMode; labelKey: string; Icon: typeof LayoutGrid }[] = [
|
||||||
{ value: "grid", label: "Grid", Icon: LayoutGrid },
|
{ value: "grid", labelKey: "common.viewGrid", Icon: LayoutGrid },
|
||||||
{ value: "table", label: "Table", Icon: List },
|
{ value: "table", labelKey: "common.viewTable", Icon: List },
|
||||||
{ value: "rows", label: "Rows", Icon: Rows3 },
|
{ value: "rows", labelKey: "common.viewRows", Icon: Rows3 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function ViewToggle({
|
export function ViewToggle({
|
||||||
@@ -16,6 +17,7 @@ export function ViewToggle({
|
|||||||
value: ViewMode;
|
value: ViewMode;
|
||||||
onValueChange: (v: ViewMode) => void;
|
onValueChange: (v: ViewMode) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
type="single"
|
type="single"
|
||||||
@@ -25,10 +27,16 @@ export function ViewToggle({
|
|||||||
}}
|
}}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label="View mode"
|
aria-label={t("common.viewMode")}
|
||||||
>
|
>
|
||||||
{OPTIONS.map(({ value: v, label, Icon }) => (
|
{OPTIONS.map(({ value: v, labelKey, Icon }) => (
|
||||||
<ToggleGroupItem key={v} value={v} aria-label={label} title={label} data-testid={`view-${v}`}>
|
<ToggleGroupItem
|
||||||
|
key={v}
|
||||||
|
value={v}
|
||||||
|
aria-label={t(labelKey)}
|
||||||
|
title={t(labelKey)}
|
||||||
|
data-testid={`view-${v}`}
|
||||||
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<Icon className="w-4 h-4" />
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"currentPassword": "Aktuelles Passwort",
|
"currentPassword": "Aktuelles Passwort",
|
||||||
"newPassword": "Neues Passwort",
|
"newPassword": "Neues Passwort",
|
||||||
"confirmPassword": "Passwort bestätigen",
|
"confirmPassword": "Passwort bestätigen",
|
||||||
|
"passwordMinLength": "mind. 6 Zeichen",
|
||||||
"pwMismatch": "Passwörter stimmen nicht überein",
|
"pwMismatch": "Passwörter stimmen nicht überein",
|
||||||
"pwTooShort": "Passwort muss mindestens 8 Zeichen haben",
|
"pwTooShort": "Passwort muss mindestens 8 Zeichen haben",
|
||||||
"pwChanged": "Passwort geändert",
|
"pwChanged": "Passwort geändert",
|
||||||
@@ -54,8 +55,17 @@
|
|||||||
"all": "Alle",
|
"all": "Alle",
|
||||||
"none": "Keine",
|
"none": "Keine",
|
||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
"viewDetails": "Details ansehen",
|
"viewDetails": "Details ansehen",
|
||||||
"guideTooltip": "Anleitung in der Dokumentation"
|
"guideTooltip": "Anleitung in der Dokumentation",
|
||||||
|
"help": "Hilfe",
|
||||||
|
"viewMode": "Ansichtsmodus",
|
||||||
|
"viewGrid": "Raster",
|
||||||
|
"viewTable": "Tabelle",
|
||||||
|
"viewRows": "Zeilen",
|
||||||
|
"listDensity": "Listendichte",
|
||||||
|
"densityCompact": "Kompakt",
|
||||||
|
"densityComfortable": "Komfortabel",
|
||||||
|
"densityTitle": "Dichte: {{density}}"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Willkommen bei toolr",
|
"welcome": "Willkommen bei toolr",
|
||||||
@@ -295,7 +305,6 @@
|
|||||||
"parameter": "Parameter",
|
"parameter": "Parameter",
|
||||||
"noResults": "Keine Treffer",
|
"noResults": "Keine Treffer",
|
||||||
"fields": "Felder",
|
"fields": "Felder",
|
||||||
"fieldHelpHint": "Hinweis: Formular-Felder verlinken per ?-Icon direkt zu den jeweiligen Zeilen dieser Tabelle.",
|
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"in": "In",
|
"in": "In",
|
||||||
"requestBody": "Request-Body"
|
"requestBody": "Request-Body"
|
||||||
@@ -393,7 +402,27 @@
|
|||||||
"moveToTrashAction": "In den Papierkorb verschieben",
|
"moveToTrashAction": "In den Papierkorb verschieben",
|
||||||
"toastMoved": "Tools in den Papierkorb verschoben",
|
"toastMoved": "Tools in den Papierkorb verschoben",
|
||||||
"toastMovedSub": "{{count}} Tool(s) 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": {
|
"analytics": {
|
||||||
"title": "Plattform-Analysen",
|
"title": "Plattform-Analysen",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"currentPassword": "Current password",
|
"currentPassword": "Current password",
|
||||||
"newPassword": "New password",
|
"newPassword": "New password",
|
||||||
"confirmPassword": "Confirm password",
|
"confirmPassword": "Confirm password",
|
||||||
|
"passwordMinLength": "min. 6 characters",
|
||||||
"pwMismatch": "Passwords do not match",
|
"pwMismatch": "Passwords do not match",
|
||||||
"pwTooShort": "Password must be at least 8 characters",
|
"pwTooShort": "Password must be at least 8 characters",
|
||||||
"pwChanged": "Password changed",
|
"pwChanged": "Password changed",
|
||||||
@@ -54,8 +55,17 @@
|
|||||||
"all": "All",
|
"all": "All",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"viewDetails": "View details",
|
"viewDetails": "View details",
|
||||||
"guideTooltip": "Guide in the documentation"
|
"guideTooltip": "Guide in the documentation",
|
||||||
|
"help": "Help",
|
||||||
|
"viewMode": "View mode",
|
||||||
|
"viewGrid": "Grid",
|
||||||
|
"viewTable": "Table",
|
||||||
|
"viewRows": "Rows",
|
||||||
|
"listDensity": "List density",
|
||||||
|
"densityCompact": "Compact",
|
||||||
|
"densityComfortable": "Comfortable",
|
||||||
|
"densityTitle": "Density: {{density}}"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Welcome to toolr",
|
"welcome": "Welcome to toolr",
|
||||||
@@ -295,7 +305,6 @@
|
|||||||
"parameter": "Parameter",
|
"parameter": "Parameter",
|
||||||
"noResults": "No results",
|
"noResults": "No results",
|
||||||
"fields": "Fields",
|
"fields": "Fields",
|
||||||
"fieldHelpHint": "Note: form fields link via the ? icon directly to the respective rows of this table.",
|
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"in": "In",
|
"in": "In",
|
||||||
"requestBody": "Request Body"
|
"requestBody": "Request Body"
|
||||||
@@ -393,7 +402,27 @@
|
|||||||
"moveToTrashAction": "Move to trash",
|
"moveToTrashAction": "Move to trash",
|
||||||
"toastMoved": "Tools moved to trash",
|
"toastMoved": "Tools moved to trash",
|
||||||
"toastMovedSub": "{{count}} tool(s) 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": {
|
"analytics": {
|
||||||
"title": "Platform Analytics",
|
"title": "Platform Analytics",
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
} from "@workspace/api-client-react";
|
} from "@workspace/api-client-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Layout } from "@/components/layout";
|
import { Layout } from "@/components/layout";
|
||||||
import { GuideHelp } from "@/components/guide-help";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
@@ -51,9 +50,8 @@ export default function Analytics() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6 pb-10">
|
<div className="space-y-6 pb-10">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
|
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||||
{t("analytics.title")}
|
{t("analytics.title")}
|
||||||
<GuideHelp guide="analytics" label={t("analytics.title")} />
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">{t("analytics.subtitle")}</p>
|
<p className="text-muted-foreground">{t("analytics.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { Link, useSearch } from "wouter";
|
|||||||
import { Layout } from "@/components/layout";
|
import { Layout } from "@/components/layout";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { RatingStars } from "@/components/rating-stars";
|
import { RatingStars } from "@/components/rating-stars";
|
||||||
import { GuideHelp } from "@/components/guide-help";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
@@ -102,9 +101,8 @@ export default function Compare() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6 pb-10">
|
<div className="space-y-6 pb-10">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
|
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||||
{t("compare.title")}
|
{t("compare.title")}
|
||||||
<GuideHelp guide="vergleichen" label={t("compare.title")} />
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">{t("compare.subtitle", { count: list.length })}</p>
|
<p className="text-muted-foreground">{t("compare.subtitle", { count: list.length })}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
HelpCircle,
|
|
||||||
Library,
|
Library,
|
||||||
Menu,
|
Menu,
|
||||||
Search,
|
Search,
|
||||||
@@ -591,10 +590,6 @@ function SchemaView({ schema }: { schema: SchemaModel }) {
|
|||||||
{schema.description && <p className="text-muted-foreground">{schema.description}</p>}
|
{schema.description && <p className="text-muted-foreground">{schema.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
<FieldTable fields={schema.fields} />
|
<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>
|
</div>
|
||||||
<Toc headings={headings} title={t("docs.fields")} />
|
<Toc headings={headings} title={t("docs.fields")} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ export default function Home() {
|
|||||||
<div className="space-y-8 pb-8">
|
<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 className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("home.welcome")}</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||||
|
{t("home.welcome")}
|
||||||
|
</h1>
|
||||||
<p className="text-muted-foreground">{t("home.tagline")}</p>
|
<p className="text-muted-foreground">{t("home.tagline")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { customFetch } from "@workspace/api-client-react";
|
import { customFetch } from "@workspace/api-client-react";
|
||||||
import { recordRecentTool } from "@/lib/recent-tools";
|
import { recordRecentTool } from "@/lib/recent-tools";
|
||||||
import { FieldHelp } from "@/components/field-help";
|
|
||||||
import { GuideHelp } from "@/components/guide-help";
|
import { GuideHelp } from "@/components/guide-help";
|
||||||
|
|
||||||
const ratingSchema = z.object({
|
const ratingSchema = z.object({
|
||||||
@@ -616,9 +615,8 @@ export default function ToolDetail() {
|
|||||||
<Dialog open={costDialogOpen} onOpenChange={(o) => { if (!o) setCostDialogOpen(false); }}>
|
<Dialog open={costDialogOpen} onOpenChange={(o) => { if (!o) setCostDialogOpen(false); }}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle>
|
||||||
{editCost ? t("detail.edit") + " " + t("detail.costs") : t("detail.addCost")}
|
{editCost ? t("detail.edit") + " " + t("detail.costs") : t("detail.addCost")}
|
||||||
<GuideHelp guide="kosten" label={t("detail.costs")} />
|
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>{t("detail.costDialogSub")}</DialogDescription>
|
<DialogDescription>{t("detail.costDialogSub")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -768,12 +766,12 @@ export default function ToolDetail() {
|
|||||||
|
|
||||||
{isReviewFormOpen && (
|
{isReviewFormOpen && (
|
||||||
<Card className="border-primary shadow-sm">
|
<Card className="border-primary shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="space-y-1.5">
|
||||||
{t("detail.addReview")}
|
<CardTitle>{t("detail.addReview")}</CardTitle>
|
||||||
<GuideHelp guide="bewerten" label={t("detail.addReview")} />
|
<CardDescription>{t("detail.shareExperience", { name: tool.name })}</CardDescription>
|
||||||
</CardTitle>
|
</div>
|
||||||
<CardDescription>{t("detail.shareExperience", { name: tool.name })}</CardDescription>
|
<GuideHelp guide="bewerten" label={t("detail.addReview")} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -786,7 +784,6 @@ export default function ToolDetail() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
{t("detail.usefulness")}
|
{t("detail.usefulness")}
|
||||||
<FieldHelp schema="RatingInput" field="usefulness">{t("detail.usefulness")}</FieldHelp>
|
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
<RatingStars
|
<RatingStars
|
||||||
@@ -807,7 +804,6 @@ export default function ToolDetail() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
{t("detail.usability")}
|
{t("detail.usability")}
|
||||||
<FieldHelp schema="RatingInput" field="usability">{t("detail.usability")}</FieldHelp>
|
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
<RatingStars
|
<RatingStars
|
||||||
@@ -830,7 +826,6 @@ export default function ToolDetail() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
{t("detail.commentOptional")}
|
{t("detail.commentOptional")}
|
||||||
<FieldHelp schema="RatingInput" field="comment">{t("detail.commentLabel")}</FieldHelp>
|
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -851,7 +846,6 @@ export default function ToolDetail() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
{t("detail.nameOptional")}
|
{t("detail.nameOptional")}
|
||||||
<FieldHelp schema="RatingInput" field="reviewerName">{t("detail.nameLabel")}</FieldHelp>
|
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder={t("detail.anonymousPlaceholder")} {...field} />
|
<Input placeholder={t("detail.anonymousPlaceholder")} {...field} />
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import { CategoryCombobox } from "@/components/category-combobox";
|
|||||||
import { FeatureInput } from "@/components/feature-input";
|
import { FeatureInput } from "@/components/feature-input";
|
||||||
import { TagInput } from "@/components/tag-input";
|
import { TagInput } from "@/components/tag-input";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { FieldHelp } from "@/components/field-help";
|
|
||||||
import { GuideHelp } from "@/components/guide-help";
|
import { GuideHelp } from "@/components/guide-help";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -163,13 +162,15 @@ export default function ToolEdit() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="space-y-1.5">
|
||||||
<Pencil className="w-5 h-5 text-primary" />
|
<CardTitle className="flex items-center gap-2">
|
||||||
{t("toolForm.toolDetails")}
|
<Pencil className="w-5 h-5 text-primary" />
|
||||||
<GuideHelp guide="tool-bearbeiten" label={t("toolForm.toolDetails")} />
|
{t("toolForm.toolDetails")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>{t("toolForm.toolDetailsEditSub")}</CardDescription>
|
<CardDescription>{t("toolForm.toolDetailsEditSub")}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<GuideHelp guide="tool-bearbeiten" label={t("toolForm.toolDetails")} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -182,7 +183,6 @@ export default function ToolEdit() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
{t("toolForm.name")}
|
{t("toolForm.name")}
|
||||||
<FieldHelp schema="ToolInput" field="name">{t("toolForm.name")}</FieldHelp>
|
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder={t("toolForm.name")} {...field} />
|
<Input placeholder={t("toolForm.name")} {...field} />
|
||||||
@@ -198,7 +198,6 @@ export default function ToolEdit() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
{t("toolForm.category")}
|
{t("toolForm.category")}
|
||||||
<FieldHelp schema="ToolInput" field="category">{t("toolForm.category")}</FieldHelp>
|
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<CategoryCombobox value={field.value} onChange={field.onChange} />
|
<CategoryCombobox value={field.value} onChange={field.onChange} />
|
||||||
@@ -216,7 +215,6 @@ export default function ToolEdit() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
{t("toolForm.websiteUrlOptional")}
|
{t("toolForm.websiteUrlOptional")}
|
||||||
<FieldHelp schema="ToolInput" field="websiteUrl">{t("toolForm.websiteUrl")}</FieldHelp>
|
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder={t("toolForm.urlPlaceholder")} type="url" {...field} />
|
<Input placeholder={t("toolForm.urlPlaceholder")} type="url" {...field} />
|
||||||
@@ -233,7 +231,6 @@ export default function ToolEdit() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
{t("toolForm.iconUrlOptional")}
|
{t("toolForm.iconUrlOptional")}
|
||||||
<FieldHelp schema="ToolInput" field="iconUrl">{t("toolForm.iconUrl")}</FieldHelp>
|
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -269,7 +266,6 @@ export default function ToolEdit() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
{t("toolForm.description")}
|
{t("toolForm.description")}
|
||||||
<FieldHelp schema="ToolInput" field="description">{t("toolForm.description")}</FieldHelp>
|
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -288,7 +284,6 @@ export default function ToolEdit() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||||
{t("toolForm.features")}
|
{t("toolForm.features")}
|
||||||
<FieldHelp schema="ToolInput" field="features">{t("toolForm.features")}</FieldHelp>
|
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground">{t("toolForm.featuresEditSub")}</p>
|
<p className="text-sm text-muted-foreground">{t("toolForm.featuresEditSub")}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,7 +330,6 @@ export default function ToolEdit() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||||
{t("toolForm.tags")}
|
{t("toolForm.tags")}
|
||||||
<FieldHelp schema="ToolInput" field="tags">{t("toolForm.tags")}</FieldHelp>
|
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground">{t("toolForm.tagsEditSub")}</p>
|
<p className="text-sm text-muted-foreground">{t("toolForm.tagsEditSub")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { CategoryCombobox } from "@/components/category-combobox";
|
|||||||
import { FeatureInput } from "@/components/feature-input";
|
import { FeatureInput } from "@/components/feature-input";
|
||||||
import { TagInput } from "@/components/tag-input";
|
import { TagInput } from "@/components/tag-input";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { FieldHelp } from "@/components/field-help";
|
|
||||||
import { GuideHelp } from "@/components/guide-help";
|
import { GuideHelp } from "@/components/guide-help";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -126,13 +125,15 @@ export default function ToolNew() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="space-y-1.5">
|
||||||
<Wrench className="w-5 h-5 text-primary" />
|
<CardTitle className="flex items-center gap-2">
|
||||||
{t("toolForm.toolDetails")}
|
<Wrench className="w-5 h-5 text-primary" />
|
||||||
<GuideHelp guide="tool-anlegen" label={t("toolForm.toolDetails")} />
|
{t("toolForm.toolDetails")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>{t("toolForm.toolDetailsNewSub")}</CardDescription>
|
<CardDescription>{t("toolForm.toolDetailsNewSub")}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<GuideHelp guide="tool-anlegen" label={t("toolForm.toolDetails")} />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -145,7 +146,6 @@ export default function ToolNew() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
{t("toolForm.name")}
|
{t("toolForm.name")}
|
||||||
<FieldHelp schema="ToolInput" field="name">{t("toolForm.name")}</FieldHelp>
|
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder={t("toolForm.namePlaceholder")} {...field} data-testid="input-tool-name" />
|
<Input placeholder={t("toolForm.namePlaceholder")} {...field} data-testid="input-tool-name" />
|
||||||
@@ -162,7 +162,6 @@ export default function ToolNew() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
{t("toolForm.category")}
|
{t("toolForm.category")}
|
||||||
<FieldHelp schema="ToolInput" field="category">{t("toolForm.category")}</FieldHelp>
|
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<CategoryCombobox
|
<CategoryCombobox
|
||||||
@@ -183,7 +182,6 @@ export default function ToolNew() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
{t("toolForm.websiteUrlOptional")}
|
{t("toolForm.websiteUrlOptional")}
|
||||||
<FieldHelp schema="ToolInput" field="websiteUrl">{t("toolForm.websiteUrl")}</FieldHelp>
|
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder={t("toolForm.urlPlaceholder")} type="url" {...field} data-testid="input-tool-url" />
|
<Input placeholder={t("toolForm.urlPlaceholder")} type="url" {...field} data-testid="input-tool-url" />
|
||||||
@@ -200,7 +198,6 @@ export default function ToolNew() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
{t("toolForm.iconUrlOptional")}
|
{t("toolForm.iconUrlOptional")}
|
||||||
<FieldHelp schema="ToolInput" field="iconUrl">{t("toolForm.iconUrl")}</FieldHelp>
|
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -237,7 +234,6 @@ export default function ToolNew() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
{t("toolForm.description")}
|
{t("toolForm.description")}
|
||||||
<FieldHelp schema="ToolInput" field="description">{t("toolForm.description")}</FieldHelp>
|
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -257,7 +253,6 @@ export default function ToolNew() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||||
{t("toolForm.features")}
|
{t("toolForm.features")}
|
||||||
<FieldHelp schema="ToolInput" field="features">{t("toolForm.features")}</FieldHelp>
|
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground">{t("toolForm.featuresNewSub")}</p>
|
<p className="text-sm text-muted-foreground">{t("toolForm.featuresNewSub")}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -313,7 +308,6 @@ export default function ToolNew() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||||
{t("toolForm.tags")}
|
{t("toolForm.tags")}
|
||||||
<FieldHelp schema="ToolInput" field="tags">{t("toolForm.tags")}</FieldHelp>
|
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground">{t("toolForm.tagsHelp")}</p>
|
<p className="text-sm text-muted-foreground">{t("toolForm.tagsHelp")}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -369,7 +363,7 @@ export default function ToolNew() {
|
|||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
data-testid="button-submit-tool"
|
data-testid="button-submit-tool"
|
||||||
>
|
>
|
||||||
{createTool.isPending ? "Adding Tool..." : "Submit Tool"}
|
{createTool.isPending ? t("toolForm.addingTool") : t("toolForm.submitTool")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -230,7 +230,9 @@ export default function ToolsBrowse() {
|
|||||||
<div className="space-y-6 pb-8">
|
<div className="space-y-6 pb-8">
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("nav.browseTools")}</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||||
|
{t("nav.browseTools")}
|
||||||
|
</h1>
|
||||||
<p className="text-muted-foreground">{t("browse.subtitle")}</p>
|
<p className="text-muted-foreground">{t("browse.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
|
|||||||
@@ -156,7 +156,9 @@ export default function Trash() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6 pb-10">
|
<div className="space-y-6 pb-10">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("trash.title")}</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||||
|
{t("trash.title")}
|
||||||
|
</h1>
|
||||||
<p className="text-muted-foreground">{t("trash.subtitle")}</p>
|
<p className="text-muted-foreground">{t("trash.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useAuth } from "@/hooks/use-auth";
|
|||||||
import { useWatchlist } from "@/hooks/use-watchlist";
|
import { useWatchlist } from "@/hooks/use-watchlist";
|
||||||
import { Layout } from "@/components/layout";
|
import { Layout } from "@/components/layout";
|
||||||
import { ToolCard } from "@/components/tool-card";
|
import { ToolCard } from "@/components/tool-card";
|
||||||
import { GuideHelp } from "@/components/guide-help";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { ShieldAlert, Bookmark } from "lucide-react";
|
import { ShieldAlert, Bookmark } from "lucide-react";
|
||||||
@@ -43,9 +42,8 @@ export default function Watchlist() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6 pb-10">
|
<div className="space-y-6 pb-10">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
|
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||||
{t("watchlist.title")}
|
{t("watchlist.title")}
|
||||||
<GuideHelp guide="watchlist" label={t("watchlist.title")} />
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">{t("watchlist.subtitle")}</p>
|
<p className="text-muted-foreground">{t("watchlist.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,6 +36,46 @@ Central access to the tool catalog.
|
|||||||
- **Bulk action:** select multiple tools and move them to the trash
|
- **Bulk action:** select multiple tools and move them to the trash
|
||||||
(confirmation dialog; soft-deleted tools are removed from all public views
|
(confirmation dialog; soft-deleted tools are removed from all public views
|
||||||
and can be restored or permanently deleted).
|
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
|
## "Audit log" tab
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,46 @@ Zentraler Zugriff auf den Tool-Katalog.
|
|||||||
- **Massenaktion:** mehrere Tools auswählen und in den Papierkorb verschieben
|
- **Massenaktion:** mehrere Tools auswählen und in den Papierkorb verschieben
|
||||||
(Bestätigungsdialog; soft gelöschte Tools sind aus allen öffentlichen Ansichten
|
(Bestätigungsdialog; soft gelöschte Tools sind aus allen öffentlichen Ansichten
|
||||||
entfernt und können wiederhergestellt oder endgültig gelöscht werden).
|
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"
|
## Tab „Audit-Log"
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Besuch bis zum Anlegen und Bewerten eines Tools.
|
|||||||
|
|
||||||
## 1. Anmelden
|
## 1. Anmelden
|
||||||
|
|
||||||
Die meisten Aktionen (Tool anlegen, bewerten, Watchlist, Vergleichen) erfordern
|
Die meisten Aktionen (Tool anlegen, bewerten, Merkliste, Vergleichen) erfordern
|
||||||
ein Konto. Klicke unten links auf **Anmelden**. Je nach Konfiguration der
|
ein Konto. Klicke unten links auf **Anmelden**. Je nach Konfiguration der
|
||||||
Instanz hast du zwei Möglichkeiten:
|
Instanz hast du zwei Möglichkeiten:
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ Siehe [Bewerten](/docs/handbook/bewerten).
|
|||||||
## 5. Weiterführend
|
## 5. Weiterführend
|
||||||
|
|
||||||
- [Tools vergleichen](/docs/handbook/vergleichen)
|
- [Tools vergleichen](/docs/handbook/vergleichen)
|
||||||
- [Watchlist](/docs/handbook/watchlist)
|
- [Merkliste](/docs/handbook/watchlist)
|
||||||
- [Analytics](/docs/handbook/analytics)
|
- [Analytics](/docs/handbook/analytics)
|
||||||
- [Pläne & Berechtigungen](/docs/handbook/plaene)
|
- [Pläne & Berechtigungen](/docs/handbook/plaene)
|
||||||
- [Administration](/docs/handbook/administration)
|
- [Administration](/docs/handbook/administration)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ tools**. Users maintain a shared catalog of tools, submit ratings
|
|||||||
| **Record costs** | Enter license and cost models per tool | Premium |
|
| **Record costs** | Enter license and cost models per tool | Premium |
|
||||||
| **Analytics** | Statistics, top tools, distributions | Everyone |
|
| **Analytics** | Statistics, top tools, distributions | Everyone |
|
||||||
| **Trash** | Restore soft-deleted tools or delete them permanently | Premium |
|
| **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 |
|
| **Admin** | User management, audit log, system information | Admin |
|
||||||
| **Redundancy detection** | Automatic duplicate detection | Admin |
|
| **Redundancy detection** | Automatic duplicate detection | Admin |
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ die richtige Wahl zu treffen.
|
|||||||
| **Tool anlegen** | Neues Tool mit Beschreibung, Kategorie, Features & Tags eintragen | Angemeldet |
|
| **Tool anlegen** | Neues Tool mit Beschreibung, Kategorie, Features & Tags eintragen | Angemeldet |
|
||||||
| **Tool bearbeiten/löschen** | Eigene Tools pflegen (Ersteller:in oder Admin) | Angemeldet |
|
| **Tool bearbeiten/löschen** | Eigene Tools pflegen (Ersteller:in oder Admin) | Angemeldet |
|
||||||
| **Bewerten** | Nützlichkeit & Bedienbarkeit (1–5) plus Kommentar vergeben | Angemeldet |
|
| **Bewerten** | Nützlichkeit & Bedienbarkeit (1–5) plus Kommentar vergeben | Angemeldet |
|
||||||
| **Watchlist** | Tools als Favoriten speichern | Premium |
|
| **Merkliste** | Tools als Favoriten speichern | Premium |
|
||||||
| **Vergleichen** | Tools nebeneinander gegenüberstellen | Premium |
|
| **Vergleichen** | Tools nebeneinander gegenüberstellen | Premium |
|
||||||
| **Kosten erfassen** | Lizenz- und Kostenmodelle je Tool eintragen | Premium |
|
| **Kosten erfassen** | Lizenz- und Kostenmodelle je Tool eintragen | Premium |
|
||||||
| **Analytics** | Statistiken, Top-Tools, Verteilungen | Alle |
|
| **Analytics** | Statistiken, Top-Tools, Verteilungen | Alle |
|
||||||
| **Papierkorb** | Soft-gelöschte Tools wiederherstellen oder endgültig löschen | Premium |
|
| **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 |
|
| **Admin** | Nutzerverwaltung, Audit-Log, Systeminformationen | Admin |
|
||||||
| **Redundanz** | Automatische Doppelungs-Erkennung | Admin |
|
| **Redundanz** | Automatische Doppelungs-Erkennung | Admin |
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Der aktive Modus steht im Endpunkt
|
|||||||
Dein Profil (Avatar, Name, E-Mail, Tarif) siehst du unten links im
|
Dein Profil (Avatar, Name, E-Mail, Tarif) siehst du unten links im
|
||||||
Benutzermenü. Dort stehen dir folgende Aktionen zur Verfügung:
|
Benutzermenü. Dort stehen dir folgende Aktionen zur Verfügung:
|
||||||
|
|
||||||
- **Watchlist** — deine gespeicherten Tools (nur mit dem entsprechenden Tarif).
|
- **Merkliste** — deine gespeicherten Tools (nur mit dem entsprechenden Tarif).
|
||||||
- **Papierkorb** — wiederherstellbare, gelöschte Tools (Premium/Enterprise).
|
- **Papierkorb** — wiederherstellbare, gelöschte Tools (Premium/Enterprise).
|
||||||
- **Passwort ändern** — für lokale Konten direkt in toolr; für OIDC-Konten wird
|
- **Passwort ändern** — für lokale Konten direkt in toolr; für OIDC-Konten wird
|
||||||
die Passwortverwaltung im Identitätsanbieter angeboten.
|
die Passwortverwaltung im Identitätsanbieter angeboten.
|
||||||
@@ -51,7 +51,7 @@ API-Referenz: [`POST /auth/me/password`](/docs/reference/endpoints/auth#changeMy
|
|||||||
- die **Listenansicht** und **Dichte** im Bereich Tools durchsuchen anpassen
|
- die **Listenansicht** und **Dichte** im Bereich Tools durchsuchen anpassen
|
||||||
(siehe [Tools finden & durchsuchen](/docs/handbook/tools-finden)).
|
(siehe [Tools finden & durchsuchen](/docs/handbook/tools-finden)).
|
||||||
|
|
||||||
Deine Präferenzen (inkl. Watchlist) werden im Endpunkt
|
Deine Präferenzen (inkl. Merkliste) werden im Endpunkt
|
||||||
[`GET /auth/me/preferences`](/docs/reference/endpoints/auth#getMePreferences)
|
[`GET /auth/me/preferences`](/docs/reference/endpoints/auth#getMePreferences)
|
||||||
gespeichert und über [`PUT /auth/me/preferences`](/docs/reference/endpoints/auth#updateMePreferences)
|
gespeichert und über [`PUT /auth/me/preferences`](/docs/reference/endpoints/auth#updateMePreferences)
|
||||||
aktualisiert.
|
aktualisiert.
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ Premium/Enterprise unlock the following features:
|
|||||||
| `watchlist` | Favorites list | [Watchlist](/docs/handbook/watchlist) |
|
| `watchlist` | Favorites list | [Watchlist](/docs/handbook/watchlist) |
|
||||||
| `trash` | Trash (soft-deleted tools) | [Trash](/docs/handbook/papierkorb) |
|
| `trash` | Trash (soft-deleted tools) | [Trash](/docs/handbook/papierkorb) |
|
||||||
| `costs` | Record cost/license models | [Recording costs](/docs/handbook/kosten) |
|
| `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
|
If you are missing a feature, the app shows an **upgrade notice** with a link
|
||||||
to the plan management.
|
to the plan management.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Feature-Beschränkungen.
|
|||||||
| Tarif | Beschreibung |
|
| Tarif | Beschreibung |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Free** | Grundfunktionen: suchen, filtern, ansehen, Analytics |
|
| **Free** | Grundfunktionen: suchen, filtern, ansehen, Analytics |
|
||||||
| **Premium** | Zusätzlich Watchlist, Vergleichen, Papierkorb, Kosten |
|
| **Premium** | Zusätzlich Merkliste, Vergleichen, Papierkorb, Kosten |
|
||||||
| **Enterprise** | Alle Premium-Features + erweiterter Support |
|
| **Enterprise** | Alle Premium-Features + erweiterter Support |
|
||||||
|
|
||||||
### Feature-Berechtigungen
|
### Feature-Berechtigungen
|
||||||
@@ -23,9 +23,10 @@ Premium/Enterprise schalten folgende Features frei:
|
|||||||
| Feature | Funktion | Mehr erfahren |
|
| Feature | Funktion | Mehr erfahren |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `compare` | Tools vergleichen | [Vergleichen](/docs/handbook/vergleichen) |
|
| `compare` | Tools vergleichen | [Vergleichen](/docs/handbook/vergleichen) |
|
||||||
| `watchlist` | Favoritenliste | [Watchlist](/docs/handbook/watchlist) |
|
| `watchlist` | Favoritenliste | [Merkliste](/docs/handbook/watchlist) |
|
||||||
| `trash` | Papierkorb (soft gelöschte Tools) | [Papierkorb](/docs/handbook/papierkorb) |
|
| `trash` | Papierkorb (soft gelöschte Tools) | [Papierkorb](/docs/handbook/papierkorb) |
|
||||||
| `costs` | Kosten-/Lizenzmodelle erfassen | [Kosten erfassen](/docs/handbook/kosten) |
|
| `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
|
Fehlt dir ein Feature, zeigt die App einen **Upgrade-Hinweis** mit Link zur
|
||||||
Tarifverwaltung.
|
Tarifverwaltung.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Ohne Eingabe zeigt die Palette:
|
|||||||
|
|
||||||
- **Zuletzt angesehen** — die letzten 5 Tools, die du besucht hast.
|
- **Zuletzt angesehen** — die letzten 5 Tools, die du besucht hast.
|
||||||
- **Navigation** — Tools durchsuchen, Tool hinzufügen, Analytics sowie
|
- **Navigation** — Tools durchsuchen, Tool hinzufügen, Analytics sowie
|
||||||
(abhängig von Berechtigungen) Watchlist, Papierkorb und Admin.
|
(abhängig von Berechtigungen) Merkliste, Papierkorb und Admin.
|
||||||
|
|
||||||
### Suche
|
### Suche
|
||||||
|
|
||||||
|
|||||||
@@ -58,12 +58,12 @@ In der Tabellenansicht sind die Spalten **Tool**, **Bewertung** und **Anzahl
|
|||||||
Bewertungen** sortierbar. Beim Überfahren einer Zeile erscheint eine Vorschau
|
Bewertungen** sortierbar. Beim Überfahren einer Zeile erscheint eine Vorschau
|
||||||
mit Bewertungsdetails, Tags und Mini-Balken.
|
mit Bewertungsdetails, Tags und Mini-Balken.
|
||||||
|
|
||||||
## Auswählen für Vergleich & Watchlist
|
## Auswählen für Vergleich & Merkliste
|
||||||
|
|
||||||
- Auf jeder Karte/Zeile findest du ein **Vergleichs-Icon**, mit dem du Tools zur
|
- Auf jeder Karte/Zeile findest du ein **Vergleichs-Icon**, mit dem du Tools zur
|
||||||
[Vergleichsleiste](/docs/handbook/vergleichen) hinzufügst.
|
[Vergleichsleiste](/docs/handbook/vergleichen) hinzufügst.
|
||||||
- Das **Lesezeichen-Icon** speichert Tools in deiner
|
- Das **Lesezeichen-Icon** speichert Tools in deiner
|
||||||
[Watchlist](/docs/handbook/watchlist) (nur mit dem entsprechenden Tarif).
|
[Merkliste](/docs/handbook/watchlist) (nur mit dem entsprechenden Tarif).
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
|||||||
+10
-10
@@ -1,14 +1,14 @@
|
|||||||
---
|
---
|
||||||
title: Watchlist
|
title: Merkliste
|
||||||
order: 8
|
order: 8
|
||||||
---
|
---
|
||||||
|
|
||||||
# Watchlist
|
# Merkliste
|
||||||
|
|
||||||
Die **Watchlist** ist eine persönliche Favoritenliste. Tools darin kannst du
|
Die **Merkliste** ist eine persönliche Favoritenliste. Tools darin kannst du
|
||||||
jederzeit per Klick wieder aufrufen und vergleichen.
|
jederzeit per Klick wieder aufrufen und vergleichen.
|
||||||
|
|
||||||
> Die Watchlist ist ein **Premium-Feature** (Premium/Enterprise) und steht
|
> Die Merkliste ist ein **Premium-Feature** (Premium/Enterprise) und steht
|
||||||
> Admins immer zur Verfügung.
|
> Admins immer zur Verfügung.
|
||||||
|
|
||||||
## Voraussetzung
|
## Voraussetzung
|
||||||
@@ -21,18 +21,18 @@ beim Lesezeichen ein Hinweis auf den Tarifwechsel
|
|||||||
|
|
||||||
- Auf jeder Karte/Zeile im Bereich **Tools durchsuchen** findest du das
|
- Auf jeder Karte/Zeile im Bereich **Tools durchsuchen** findest du das
|
||||||
**Lesezeichen-Icon**.
|
**Lesezeichen-Icon**.
|
||||||
- Ein Klick speichert das Tool in deiner Watchlist — das Icon wird gefüllt.
|
- Ein Klick speichert das Tool in deiner Merkliste — das Icon wird gefüllt.
|
||||||
- Ein erneuter Klick entfernt es wieder.
|
- Ein erneuter Klick entfernt es wieder.
|
||||||
|
|
||||||
## Watchlist ansehen
|
## Merkliste ansehen
|
||||||
|
|
||||||
Öffne die Watchlist über das Benutzermenü oder die Seitenleiste. Sie zeigt alle
|
Öffne die Merkliste über das Benutzermenü oder die Seitenleiste. Sie zeigt alle
|
||||||
gespeicherten Tools als Karten. Das gefüllte Lesezeichen auf einer Karte
|
gespeicherten Tools als Karten. Das gefüllte Lesezeichen auf einer Karte
|
||||||
entfernt das Tool aus der Liste.
|
entfernt das Tool aus der Liste.
|
||||||
|
|
||||||
## Wo wird die Watchlist gespeichert?
|
## Wo wird die Merkliste gespeichert?
|
||||||
|
|
||||||
Die Watchlist ist eine Liste von Tool-IDs in deinen **Benutzerpräferenzen**.
|
Die Merkliste ist eine Liste von Tool-IDs in deinen **Benutzerpräferenzen**.
|
||||||
Damit ist sie geräteübergreifend mit deinem Konto verbunden.
|
Damit ist sie geräteübergreifend mit deinem Konto verbunden.
|
||||||
|
|
||||||
API-Referenz: [`GET /auth/me/watchlist`](/docs/reference/endpoints/auth#getMeWatchlist).
|
API-Referenz: [`GET /auth/me/watchlist`](/docs/reference/endpoints/auth#getMeMerkliste).
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
- **Vollständige Dokumentations-Site** in mkdocs-Optik unter `/docs`:
|
- **Vollständige Dokumentations-Site** in mkdocs-Optik unter `/docs`:
|
||||||
- **Handbuch** mit verständlichen Erklärungen zu allen Features
|
- **Handbuch** mit verständlichen Erklärungen zu allen Features
|
||||||
(Erste Schritte, Tool anlegen, Bewertungen, Vergleichen, Watchlist,
|
(Erste Schritte, Tool anlegen, Bewertungen, Vergleichen, Merkliste,
|
||||||
Analytics, Administration, Datenmodell).
|
Analytics, Administration, Datenmodell).
|
||||||
- **Automatisch generierte Referenz** aus `lib/api-spec/openapi.yaml`:
|
- **Automatisch generierte Referenz** aus `lib/api-spec/openapi.yaml`:
|
||||||
alle Endpunkte und Datenfelder (Typ, Pflichtstatus, Constraints) —
|
alle Endpunkte und Datenfelder (Typ, Pflichtstatus, Constraints) —
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
und führt zur Standalone-Doku.
|
und führt zur Standalone-Doku.
|
||||||
- **User Guide komplett überarbeitet**: 17 Handbuch-Seiten mit
|
- **User Guide komplett überarbeitet**: 17 Handbuch-Seiten mit
|
||||||
Schritt-für-Schritt-Anleitungen für alle Funktionen (Tools finden, Tool
|
Schritt-für-Schritt-Anleitungen für alle Funktionen (Tools finden, Tool
|
||||||
anlegen, Bewerten, Watchlist, Vergleichen, Kosten, Analytics, Pläne,
|
anlegen, Bewerten, Merkliste, Vergleichen, Kosten, Analytics, Pläne,
|
||||||
Administration, Redundanz, Papierkorb, Tastenkürzel, Datenmodell).
|
Administration, Redundanz, Papierkorb, Tastenkürzel, Datenmodell).
|
||||||
|
|
||||||
## Fixes & Verbesserungen
|
## Fixes & Verbesserungen
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
(`/docs/vX.Y.Z/…`) — inklusive Handbuch und Referenz, wie sie zum
|
(`/docs/vX.Y.Z/…`) — inklusive Handbuch und Referenz, wie sie zum
|
||||||
Release-Zeitpunkt galten. Die letzten 7 Versionen bleiben verfügbar.
|
Release-Zeitpunkt galten. Die letzten 7 Versionen bleiben verfügbar.
|
||||||
- **Hilfe-Buttons in Formularen (NetBox-Stil)**: Bei Bewertung, Tool
|
- **Hilfe-Buttons in Formularen (NetBox-Stil)**: Bei Bewertung, Tool
|
||||||
anlegen/bearbeiten, Kosten, Vergleichen, Watchlist und Analytics gibt es
|
anlegen/bearbeiten, Kosten, Vergleichen, Merkliste und Analytics gibt es
|
||||||
jetzt ein Hilfe-Icon, das direkt zur passenden Anleitung im Handbuch führt.
|
jetzt ein Hilfe-Icon, das direkt zur passenden Anleitung im Handbuch führt.
|
||||||
|
|
||||||
## Fixes & Verbesserungen
|
## Fixes & Verbesserungen
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
- **Komplette Lokalisierung der App (Deutsch/Englisch)**: Alle Seiten und
|
- **Komplette Lokalisierung der App (Deutsch/Englisch)**: Alle Seiten und
|
||||||
Komponenten sind jetzt übersetzt — Tool-Detailseite, Verwaltung, Papierkorb,
|
Komponenten sind jetzt übersetzt — Tool-Detailseite, Verwaltung, Papierkorb,
|
||||||
Redundanz-Dashboard, Vergleichen, Watchlist, Analytics, Tool anlegen und
|
Redundanz-Dashboard, Vergleichen, Merkliste, Analytics, Tool anlegen und
|
||||||
bearbeiten, Admin-Bereich inkl. Tools-Tab, Kategorie-Combobox und
|
bearbeiten, Admin-Bereich inkl. Tools-Tab, Kategorie-Combobox und
|
||||||
Theme-Umschalter. Die Sprachauswahl oben rechts wechselt die gesamte
|
Theme-Umschalter. Die Sprachauswahl oben rechts wechselt die gesamte
|
||||||
Oberfläche.
|
Oberfläche.
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# v0.8.5 — Release Notes
|
||||||
|
|
||||||
|
**Date:** 2026-08-04 · **Tag:** [`v0.8.5`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.5)
|
||||||
|
|
||||||
|
## New features
|
||||||
|
|
||||||
|
- **Help buttons across the app**: The main pages (Home, Browse tools, Trash,
|
||||||
|
Redundancy dashboard, Admin tools) as well as the premium dialog and the
|
||||||
|
password dialog now show a book icon next to the title that links directly
|
||||||
|
to the matching manual page (following the existing buttons on the tool
|
||||||
|
detail, create, edit, compare, watchlist and analytics pages).
|
||||||
|
|
||||||
|
## Fixes & improvements
|
||||||
|
|
||||||
|
- **Translated submit button when creating a tool**: The "Submit Tool" /
|
||||||
|
"Adding Tool…" button was still hardcoded and appeared in English in the
|
||||||
|
German UI. It is now translated, e.g. "Tool einreichen" / "Füge Tool
|
||||||
|
hinzu…" in German.
|
||||||
|
- **More translated UI strings**: View mode (Grid/Table/Rows), list density
|
||||||
|
(Compact/Comfortable), minimum rating in the filter and the password hint
|
||||||
|
"min. 6 characters" are now translated correctly.
|
||||||
|
- **Consistent docs terminology**: The manual now consistently calls the
|
||||||
|
watchlist "Merkliste" in all German pages and release notes; the English
|
||||||
|
version stays "Watchlist". The `watchlist` permission name and the URLs
|
||||||
|
are unchanged.
|
||||||
|
- All locales are in sync (462 keys per language, 0 missing).
|
||||||
|
|
||||||
|
## API changes
|
||||||
|
|
||||||
|
- No changes to the API.
|
||||||
|
|
||||||
|
## Operations / upgrade
|
||||||
|
|
||||||
|
- **Env vars:** unchanged.
|
||||||
|
- **Migration:** none.
|
||||||
|
- **Breaking changes:** none.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- None.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Commit: [`70e72da`](https://git.kubebase.de/admin/tool-evaluator/commit/70e72da)
|
||||||
|
- Tag: [`v0.8.5`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.5)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# v0.8.5 — Release Notes
|
||||||
|
|
||||||
|
**Datum:** 2026-08-04 · **Tag:** [`v0.8.5`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.5)
|
||||||
|
|
||||||
|
## Neue Features
|
||||||
|
|
||||||
|
- **Hilfe-Buttons in der gesamten App**: Auf den Hauptseiten (Start, Tools
|
||||||
|
durchsuchen, Papierkorb, Redundanz-Dashboard, Admin-Tools) sowie in
|
||||||
|
Premium-Dialog und Passwort-Dialog gibt es jetzt ein Buch-Icon neben dem
|
||||||
|
Titel, das direkt zur passenden Anleitung im Handbuch verlinkt (analog zu
|
||||||
|
den bestehenden Buttons auf Tool-Detail-, Anlegen-, Bearbeiten-,
|
||||||
|
Vergleichen-, Merkliste- und Analytics-Seiten).
|
||||||
|
|
||||||
|
## Fixes & Verbesserungen
|
||||||
|
|
||||||
|
- **Submit-Button beim Tool-Anlegen übersetzt**: Der Button „Submit Tool" /
|
||||||
|
„Adding Tool…" war noch hartkodiert und erschien auf Deutsch auf Englisch.
|
||||||
|
Jetzt: „Tool einreichen" / „Füge Tool hinzu…" (bzw. die englischen
|
||||||
|
Entsprechungen).
|
||||||
|
- **Weitere übersetzte UI-Strings**: Ansichtsmodus (Raster/Tabelle/Zeilen),
|
||||||
|
Listendichte (Kompakt/Komfortabel), Mindestbewertung im Filter sowie der
|
||||||
|
Passwort-Hinweis „min. 6 Zeichen" werden jetzt korrekt übersetzt.
|
||||||
|
- **Doku-Terminologie konsistent**: Das Handbuch heißt die Merkliste jetzt
|
||||||
|
einheitlich „Merkliste" (statt „Watchlist") in allen deutschen Seiten und
|
||||||
|
Release-Notes; die englische Fassung bleibt „Watchlist". Die
|
||||||
|
Permissions-Bezeichnung `watchlist` und die URLs sind unverändert.
|
||||||
|
- Alle Locales synchron (462 Keys pro Sprache, 0 fehlend).
|
||||||
|
|
||||||
|
## API-Änderungen
|
||||||
|
|
||||||
|
- Keine Änderungen an der API.
|
||||||
|
|
||||||
|
## Betrieb / Upgrade
|
||||||
|
|
||||||
|
- **Env-Vars:** unverändert.
|
||||||
|
- **Migration:** keine.
|
||||||
|
- **Breaking Changes:** keine.
|
||||||
|
|
||||||
|
## Bekannte Einschränkungen
|
||||||
|
|
||||||
|
- Keine.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Commit: [`70e72da`](https://git.kubebase.de/admin/tool-evaluator/commit/70e72da)
|
||||||
|
- Tag: [`v0.8.5`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.5)
|
||||||
@@ -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)
|
||||||
@@ -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[];
|
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 {
|
export interface ToolUpdate {
|
||||||
/** @minLength 1 */
|
/** @minLength 1 */
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ import type {
|
|||||||
RestoreTools200,
|
RestoreTools200,
|
||||||
SetPasswordInput,
|
SetPasswordInput,
|
||||||
Tool,
|
Tool,
|
||||||
|
ToolImportBody,
|
||||||
|
ToolImportResponse,
|
||||||
ToolInput,
|
ToolInput,
|
||||||
ToolUpdate,
|
ToolUpdate,
|
||||||
ToolWithStats,
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -924,6 +924,37 @@ paths:
|
|||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/AuditLog"
|
$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:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
HealthStatus:
|
HealthStatus:
|
||||||
@@ -1191,6 +1222,39 @@ components:
|
|||||||
items:
|
items:
|
||||||
type: string
|
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:
|
ToolUpdate:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -719,3 +719,25 @@ export const ListAuditLogsResponseItem = zod.object({
|
|||||||
export const ListAuditLogsResponse = zod.array(ListAuditLogsResponseItem)
|
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()
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export * from './restoreTools200';
|
|||||||
export * from './scoreBucket';
|
export * from './scoreBucket';
|
||||||
export * from './setPasswordInput';
|
export * from './setPasswordInput';
|
||||||
export * from './tool';
|
export * from './tool';
|
||||||
|
export * from './toolImportBody';
|
||||||
|
export * from './toolImportBodyDelimiter';
|
||||||
|
export * from './toolImportBodyFormat';
|
||||||
|
export * from './toolImportResponse';
|
||||||
|
export * from './toolImportResponseErrorsItem';
|
||||||
export * from './toolInput';
|
export * from './toolInput';
|
||||||
export * from './toolUpdate';
|
export * from './toolUpdate';
|
||||||
export * from './toolWithStats';
|
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;
|
||||||
|
};
|
||||||
Generated
+18
-22
@@ -239,6 +239,9 @@ importers:
|
|||||||
pino-http:
|
pino-http:
|
||||||
specifier: 11.0.0
|
specifier: 11.0.0
|
||||||
version: 11.0.0
|
version: 11.0.0
|
||||||
|
yaml:
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 2.9.0
|
||||||
zod:
|
zod:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 4.4.3
|
version: 4.4.3
|
||||||
@@ -368,7 +371,7 @@ importers:
|
|||||||
version: 0.0.6
|
version: 0.0.6
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: 'catalog:'
|
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':
|
'@types/node':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 26.1.2
|
version: 26.1.2
|
||||||
@@ -380,7 +383,7 @@ importers:
|
|||||||
version: 19.2.4(@types/react@19.2.18)
|
version: 19.2.4(@types/react@19.2.18)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: 'catalog:'
|
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:
|
chokidar:
|
||||||
specifier: 5.0.0
|
specifier: 5.0.0
|
||||||
version: 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)
|
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:
|
vite:
|
||||||
specifier: 'catalog:'
|
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:
|
zod:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 4.4.3
|
version: 4.4.3
|
||||||
@@ -560,7 +563,7 @@ importers:
|
|||||||
version: 0.5.20(tailwindcss@4.3.3)
|
version: 0.5.20(tailwindcss@4.3.3)
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: 'catalog:'
|
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':
|
'@tanstack/react-query':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 5.101.4(react@19.2.8)
|
version: 5.101.4(react@19.2.8)
|
||||||
@@ -578,7 +581,7 @@ importers:
|
|||||||
version: 19.2.4(@types/react@19.2.18)
|
version: 19.2.4(@types/react@19.2.18)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: 'catalog:'
|
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':
|
'@workspace/api-client-react':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../lib/api-client-react
|
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)
|
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:
|
vite:
|
||||||
specifier: 'catalog:'
|
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:
|
wouter:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 3.10.0(react@19.2.8)
|
version: 3.10.0(react@19.2.8)
|
||||||
@@ -3752,11 +3755,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
|
|
||||||
yaml@2.8.4:
|
|
||||||
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
|
|
||||||
engines: {node: '>= 14.6'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
yaml@2.9.0:
|
yaml@2.9.0:
|
||||||
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
|
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
|
||||||
engines: {node: '>= 14.6'}
|
engines: {node: '>= 14.6'}
|
||||||
@@ -4961,7 +4959,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@scalar/helpers': 0.9.2
|
'@scalar/helpers': 0.9.2
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
yaml: 2.8.4
|
yaml: 2.9.0
|
||||||
|
|
||||||
'@scalar/openapi-parser@0.28.11':
|
'@scalar/openapi-parser@0.28.11':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -4974,7 +4972,7 @@ snapshots:
|
|||||||
ajv-formats: 3.0.1
|
ajv-formats: 3.0.1
|
||||||
jsonpointer: 5.0.1
|
jsonpointer: 5.0.1
|
||||||
leven: 4.1.0
|
leven: 4.1.0
|
||||||
yaml: 2.8.4
|
yaml: 2.9.0
|
||||||
|
|
||||||
'@scalar/openapi-types@0.8.0': {}
|
'@scalar/openapi-types@0.8.0': {}
|
||||||
|
|
||||||
@@ -5041,12 +5039,12 @@ snapshots:
|
|||||||
postcss-selector-parser: 6.0.10
|
postcss-selector-parser: 6.0.10
|
||||||
tailwindcss: 4.3.3
|
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:
|
dependencies:
|
||||||
'@tailwindcss/node': 4.3.3
|
'@tailwindcss/node': 4.3.3
|
||||||
'@tailwindcss/oxide': 4.3.3
|
'@tailwindcss/oxide': 4.3.3
|
||||||
tailwindcss: 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': {}
|
'@tanstack/query-core@5.101.4': {}
|
||||||
|
|
||||||
@@ -5246,10 +5244,10 @@ snapshots:
|
|||||||
'@typescript/typescript-win32-x64@7.0.2':
|
'@typescript/typescript-win32-x64@7.0.2':
|
||||||
optional: true
|
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:
|
dependencies:
|
||||||
'@rolldown/pluginutils': 1.0.1
|
'@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:
|
accepts@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -6485,7 +6483,7 @@ snapshots:
|
|||||||
markdown-it: 15.0.0
|
markdown-it: 15.0.0
|
||||||
minimatch: 10.2.5
|
minimatch: 10.2.5
|
||||||
typescript: 7.0.2
|
typescript: 7.0.2
|
||||||
yaml: 2.8.4
|
yaml: 2.9.0
|
||||||
|
|
||||||
typescript@7.0.2:
|
typescript@7.0.2:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@@ -6575,7 +6573,7 @@ snapshots:
|
|||||||
d3-time: 3.1.0
|
d3-time: 3.1.0
|
||||||
d3-timer: 3.0.1
|
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:
|
dependencies:
|
||||||
lightningcss: 1.33.0
|
lightningcss: 1.33.0
|
||||||
picomatch: 4.0.5
|
picomatch: 4.0.5
|
||||||
@@ -6588,7 +6586,7 @@ snapshots:
|
|||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 2.7.0
|
jiti: 2.7.0
|
||||||
tsx: 4.23.4
|
tsx: 4.23.4
|
||||||
yaml: 2.8.4
|
yaml: 2.9.0
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -6605,8 +6603,6 @@ snapshots:
|
|||||||
|
|
||||||
xtend@4.0.2: {}
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
yaml@2.8.4: {}
|
|
||||||
|
|
||||||
yaml@2.9.0: {}
|
yaml@2.9.0: {}
|
||||||
|
|
||||||
yocto-queue@1.2.2: {}
|
yocto-queue@1.2.2: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user