feat(import): bulk tool import (CSV/JSON/YAML) for admins; NetBox-style help button
Build & Push Docker Image / build (push) Successful in 2m49s
Build & Push Docker Image / build (push) Successful in 2m49s
- Add POST /admin/tools/import with format auto-detect, CSV delimiters
(comma/semicolon/tab), per-row validation via CreateToolBody, bulk insert,
audit log entries per imported tool; gated by new 'tool-import' feature
flag (premium/enterprise; admins always pass)
- Add tool-import-dialog UI (format tabs, delimiter select, textarea, file
upload, result/error list) behind hasFeature('tool-import')
- Replace FieldHelp question marks and bare GuideHelp links with a NetBox-style
'Hilfe/Help' outline button (HelpCircle + text) in form headers only
- Sync locales to 482 keys per language (de/en), update handbook docs
(administration import section, index/plaene feature tables), regenerate
API client + zod schemas, add yaml dependency
This commit is contained in:
@@ -15,7 +15,6 @@ import {
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GuideHelp } from "@/components/guide-help";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
@@ -24,12 +23,13 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||
import { Search, Trash2, ExternalLink, Pencil, Star } from "lucide-react";
|
||||
import { ToolImportDialog } from "@/components/tool-import-dialog";
|
||||
import { Search, Trash2, ExternalLink, Pencil, Star, Upload } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export function AdminToolsTab() {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin, isLoading: authLoading } = useAuth();
|
||||
const { isAdmin, hasFeature, isLoading: authLoading } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -37,6 +37,7 @@ export function AdminToolsTab() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [confirmTrash, setConfirmTrash] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setSearch(searchInput), 300);
|
||||
@@ -106,15 +107,22 @@ export function AdminToolsTab() {
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{t("adminTools.allTools")}
|
||||
<GuideHelp guide="administration" label={t("adminTools.allTools")} />
|
||||
</CardTitle>
|
||||
<CardTitle>{t("adminTools.allTools")}</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedIds.length > 0 ? t("adminTools.selected", { count: selectedIds.length }) : t("adminTools.toolCount", { count: allTools.length })}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{hasFeature("tool-import") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setImportOpen(true)}
|
||||
data-testid="button-tool-import"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" /> {t("adminTools.importButton")}
|
||||
</Button>
|
||||
)}
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -233,6 +241,15 @@ export function AdminToolsTab() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<ToolImportDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
onImported={() => {
|
||||
toast({ title: t("adminTools.toastImportDone") });
|
||||
invalidate();
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
export function FieldHelp({
|
||||
schema,
|
||||
field,
|
||||
children,
|
||||
}: {
|
||||
schema: string;
|
||||
field: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const label = children ?? field;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={`/docs/reference/schemas/${schema}#${field}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`Help: ${label}`}
|
||||
data-testid={`help-${schema}-${field}`}
|
||||
className="inline-flex shrink-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<HelpCircle className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label} — Details in der Dokumentation</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { BookOpen } from "lucide-react";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
export function GuideHelp({
|
||||
@@ -9,21 +11,24 @@ export function GuideHelp({
|
||||
guide: string;
|
||||
label: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={`/docs/handbook/${guide}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`Help: ${label}`}
|
||||
data-testid={`guide-${guide}`}
|
||||
className="inline-flex shrink-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<BookOpen className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
<Button asChild variant="outline" size="sm" className="gap-1.5 text-muted-foreground">
|
||||
<Link
|
||||
href={`/docs/handbook/${guide}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`${t("common.help")}: ${label}`}
|
||||
data-testid={`guide-${guide}`}
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
{t("common.help")}
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label} — Anleitung in der Dokumentation</TooltipContent>
|
||||
<TooltipContent>{label} — {t("common.guideTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useImportTools, type ToolImportResponse } from "@workspace/api-client-react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Upload, FileUp, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
|
||||
type Format = "auto" | "csv" | "json" | "yaml";
|
||||
type Delimiter = "auto" | "comma" | "semicolon" | "tab";
|
||||
|
||||
export function ToolImportDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onImported,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onImported: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const importTools = useImportTools();
|
||||
const [format, setFormat] = useState<Format>("auto");
|
||||
const [delimiter, setDelimiter] = useState<Delimiter>("auto");
|
||||
const [data, setData] = useState("");
|
||||
const [result, setResult] = useState<ToolImportResponse | null>(null);
|
||||
|
||||
function handleFile(file: File) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setData(String(reader.result ?? ""));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
function handleImport() {
|
||||
importTools.mutate(
|
||||
{ data: { format, delimiter, data } },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
setResult(res);
|
||||
if (res.imported > 0) onImported();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleClose(open: boolean) {
|
||||
if (!importTools.isPending) {
|
||||
onOpenChange(open);
|
||||
if (!open) {
|
||||
setData("");
|
||||
setResult(null);
|
||||
setFormat("auto");
|
||||
setDelimiter("auto");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = data.trim().length > 0 && !importTools.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Upload className="w-4 h-4" />
|
||||
{t("adminTools.importTitle")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<p className="text-sm text-muted-foreground">{t("adminTools.importSub")}</p>
|
||||
|
||||
<Tabs value={format} onValueChange={(v) => setFormat(v as Format)}>
|
||||
<TabsList className="w-full grid grid-cols-4">
|
||||
<TabsTrigger value="auto">{t("adminTools.importFormatAuto")}</TabsTrigger>
|
||||
<TabsTrigger value="csv">CSV</TabsTrigger>
|
||||
<TabsTrigger value="json">JSON</TabsTrigger>
|
||||
<TabsTrigger value="yaml">YAML</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="auto" className="text-xs text-muted-foreground pt-2">
|
||||
{t("adminTools.importFormatAutoSub")}
|
||||
</TabsContent>
|
||||
<TabsContent value="csv" className="pt-2 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("adminTools.importDelimiter")}</Label>
|
||||
<Select value={delimiter} onValueChange={(v) => setDelimiter(v as Delimiter)}>
|
||||
<SelectTrigger className="w-52">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("adminTools.importDelimiterAuto")}</SelectItem>
|
||||
<SelectItem value="comma">{t("adminTools.importDelimiterComma")}</SelectItem>
|
||||
<SelectItem value="semicolon">{t("adminTools.importDelimiterSemicolon")}</SelectItem>
|
||||
<SelectItem value="tab">{t("adminTools.importDelimiterTab")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("adminTools.importCsvSub")}</p>
|
||||
</TabsContent>
|
||||
<TabsContent value="json" className="pt-2 space-y-3">
|
||||
<p className="text-xs text-muted-foreground">{t("adminTools.importJsonSub")}</p>
|
||||
</TabsContent>
|
||||
<TabsContent value="yaml" className="pt-2 space-y-3">
|
||||
<p className="text-xs text-muted-foreground">{t("adminTools.importYamlSub")}</p>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="outline" size="sm" className="gap-2" onClick={() => document.getElementById("tool-import-file")?.click()}>
|
||||
<FileUp className="w-4 h-4" />
|
||||
{t("adminTools.importFile")}
|
||||
</Button>
|
||||
<input
|
||||
id="tool-import-file"
|
||||
type="file"
|
||||
accept=".csv,.json,.yaml,.yml,.txt,text/plain,application/json"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleFile(f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={data}
|
||||
onChange={(e) => setData(e.target.value)}
|
||||
placeholder={t("adminTools.importTextareaPlaceholder")}
|
||||
className="min-h-[220px] font-mono text-sm"
|
||||
data-testid="input-tool-import-data"
|
||||
/>
|
||||
|
||||
{importTools.isError && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>{importTools.error?.data?.error ?? importTools.error?.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 rounded-md border border-primary/20 bg-primary/5 px-3 py-2 text-sm">
|
||||
<CheckCircle2 className="w-4 h-4 text-primary shrink-0" />
|
||||
<span>{t("adminTools.importDone", { count: result.imported, total: result.total })}</span>
|
||||
</div>
|
||||
{result.errors.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium flex items-center gap-1.5">
|
||||
<AlertCircle className="w-4 h-4 text-amber-500" />
|
||||
{t("adminTools.importErrors", { count: result.errors.length })}
|
||||
</p>
|
||||
<div className="max-h-40 overflow-y-auto rounded-md border bg-muted/40 divide-y divide-border">
|
||||
{result.errors.map((err, i) => (
|
||||
<div key={i} className="flex items-start gap-2 px-3 py-1.5 text-xs">
|
||||
<span className="font-mono text-muted-foreground shrink-0">
|
||||
{t("adminTools.importErrorRow", { row: err.row })}:
|
||||
</span>
|
||||
<span className="text-foreground break-words">{err.error}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleClose(false)} disabled={importTools.isPending}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={!canSubmit}>
|
||||
{importTools.isPending ? t("adminTools.importing") : t("adminTools.importButton")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -164,11 +164,9 @@ export function UserMenu() {
|
||||
|
||||
<Dialog open={pwOpen} onOpenChange={setPwOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{t("auth.changePassword")}
|
||||
<GuideHelp guide="konto" label={t("auth.changePassword")} />
|
||||
</DialogTitle>
|
||||
<DialogHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
|
||||
<DialogTitle>{t("auth.changePassword")}</DialogTitle>
|
||||
<GuideHelp guide="konto" label={t("auth.changePassword")} />
|
||||
</DialogHeader>
|
||||
{!user?.isLocal ? (
|
||||
<div className="space-y-4 py-2">
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"language": "Sprache",
|
||||
"viewDetails": "Details ansehen",
|
||||
"guideTooltip": "Anleitung in der Dokumentation",
|
||||
"help": "Hilfe",
|
||||
"viewMode": "Ansichtsmodus",
|
||||
"viewGrid": "Raster",
|
||||
"viewTable": "Tabelle",
|
||||
@@ -304,7 +305,6 @@
|
||||
"parameter": "Parameter",
|
||||
"noResults": "Keine Treffer",
|
||||
"fields": "Felder",
|
||||
"fieldHelpHint": "Hinweis: Formular-Felder verlinken per ?-Icon direkt zu den jeweiligen Zeilen dieser Tabelle.",
|
||||
"name": "Name",
|
||||
"in": "In",
|
||||
"requestBody": "Request-Body"
|
||||
@@ -402,7 +402,27 @@
|
||||
"moveToTrashAction": "In den Papierkorb verschieben",
|
||||
"toastMoved": "Tools in den Papierkorb verschoben",
|
||||
"toastMovedSub": "{{count}} Tool(s) in den Papierkorb verschoben.",
|
||||
"toastMoveFailed": "Verschieben in den Papierkorb fehlgeschlagen"
|
||||
"toastMoveFailed": "Verschieben in den Papierkorb fehlgeschlagen",
|
||||
"importTitle": "Tools importieren",
|
||||
"importSub": "Mehrere Tools auf einmal importieren — als CSV, JSON oder YAML (Premium-Feature).",
|
||||
"importButton": "Importieren",
|
||||
"importFormatAuto": "Auto",
|
||||
"importFormatAutoSub": "Das Format wird automatisch erkannt (CSV, JSON oder YAML).",
|
||||
"importDelimiter": "CSV-Trenner",
|
||||
"importDelimiterAuto": "Automatisch",
|
||||
"importDelimiterComma": "Komma",
|
||||
"importDelimiterSemicolon": "Semikolon",
|
||||
"importDelimiterTab": "Tabulator",
|
||||
"importCsvSub": "Spalten: name, description, category, websiteUrl, iconUrl, features, tags. Features/Tags durch | getrennt. Erste Zeile enthält die Spaltennamen.",
|
||||
"importJsonSub": "JSON-Objekt oder Array von Objekten mit den Feldern name, description, category, websiteUrl, iconUrl, features, tags.",
|
||||
"importYamlSub": "YAML-Liste von Objekten (oder ein einzelnes Objekt) mit den Feldern name, description, category, websiteUrl, iconUrl, features, tags.",
|
||||
"importFile": "Datei laden",
|
||||
"importTextareaPlaceholder": "name;description;category;features;tags\nMein Tool;Eine tolle Beschreibung;Monitoring;Deployment|Open Source;devops",
|
||||
"importing": "Wird importiert…",
|
||||
"importDone": "{{count}} von {{total}} Tools erfolgreich importiert.",
|
||||
"importErrors": "{{count}} Zeilen mit Fehlern:",
|
||||
"importErrorRow": "Zeile {{row}}",
|
||||
"toastImportDone": "Tools erfolgreich importiert"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Plattform-Analysen",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"language": "Language",
|
||||
"viewDetails": "View details",
|
||||
"guideTooltip": "Guide in the documentation",
|
||||
"help": "Help",
|
||||
"viewMode": "View mode",
|
||||
"viewGrid": "Grid",
|
||||
"viewTable": "Table",
|
||||
@@ -304,7 +305,6 @@
|
||||
"parameter": "Parameter",
|
||||
"noResults": "No results",
|
||||
"fields": "Fields",
|
||||
"fieldHelpHint": "Note: form fields link via the ? icon directly to the respective rows of this table.",
|
||||
"name": "Name",
|
||||
"in": "In",
|
||||
"requestBody": "Request Body"
|
||||
@@ -402,7 +402,27 @@
|
||||
"moveToTrashAction": "Move to trash",
|
||||
"toastMoved": "Tools moved to trash",
|
||||
"toastMovedSub": "{{count}} tool(s) moved to trash.",
|
||||
"toastMoveFailed": "Failed to move to trash"
|
||||
"toastMoveFailed": "Failed to move to trash",
|
||||
"importTitle": "Import tools",
|
||||
"importSub": "Import multiple tools at once — as CSV, JSON or YAML (premium feature).",
|
||||
"importButton": "Import",
|
||||
"importFormatAuto": "Auto",
|
||||
"importFormatAutoSub": "The format is detected automatically (CSV, JSON or YAML).",
|
||||
"importDelimiter": "CSV delimiter",
|
||||
"importDelimiterAuto": "Auto-detect",
|
||||
"importDelimiterComma": "Comma",
|
||||
"importDelimiterSemicolon": "Semicolon",
|
||||
"importDelimiterTab": "Tab",
|
||||
"importCsvSub": "Columns: name, description, category, websiteUrl, iconUrl, features, tags. Separate features/tags with |. The first line holds the column names.",
|
||||
"importJsonSub": "A JSON object or array of objects with the fields name, description, category, websiteUrl, iconUrl, features, tags.",
|
||||
"importYamlSub": "A YAML list of objects (or a single object) with the fields name, description, category, websiteUrl, iconUrl, features, tags.",
|
||||
"importFile": "Upload file",
|
||||
"importTextareaPlaceholder": "name;description;category;features;tags\nMy Tool;A great description;Monitoring;Deployment|Open Source;devops",
|
||||
"importing": "Importing…",
|
||||
"importDone": "{{count}} of {{total}} tools imported successfully.",
|
||||
"importErrors": "{{count}} rows with errors:",
|
||||
"importErrorRow": "Row {{row}}",
|
||||
"toastImportDone": "Tools imported successfully"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Platform Analytics",
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from "@workspace/api-client-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Layout } from "@/components/layout";
|
||||
import { GuideHelp } from "@/components/guide-help";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
@@ -51,9 +50,8 @@ export default function Analytics() {
|
||||
<Layout>
|
||||
<div className="space-y-6 pb-10">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||
{t("analytics.title")}
|
||||
<GuideHelp guide="analytics" label={t("analytics.title")} />
|
||||
</h1>
|
||||
<p className="text-muted-foreground">{t("analytics.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Link, useSearch } from "wouter";
|
||||
import { Layout } from "@/components/layout";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { RatingStars } from "@/components/rating-stars";
|
||||
import { GuideHelp } from "@/components/guide-help";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -102,9 +101,8 @@ export default function Compare() {
|
||||
<Layout>
|
||||
<div className="space-y-6 pb-10">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||
{t("compare.title")}
|
||||
<GuideHelp guide="vergleichen" label={t("compare.title")} />
|
||||
</h1>
|
||||
<p className="text-muted-foreground">{t("compare.subtitle", { count: list.length })}</p>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
ExternalLink,
|
||||
FileText,
|
||||
GitBranch,
|
||||
HelpCircle,
|
||||
Library,
|
||||
Menu,
|
||||
Search,
|
||||
@@ -591,10 +590,6 @@ function SchemaView({ schema }: { schema: SchemaModel }) {
|
||||
{schema.description && <p className="text-muted-foreground">{schema.description}</p>}
|
||||
</div>
|
||||
<FieldTable fields={schema.fields} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<HelpCircle className="h-3.5 w-3.5 inline mr-1" />
|
||||
{t("docs.fieldHelpHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Toc headings={headings} title={t("docs.fields")} />
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Layout } from "@/components/layout";
|
||||
import { GuideHelp } from "@/components/guide-help";
|
||||
import { ToolCard } from "@/components/tool-card";
|
||||
import { Star, Wrench, MessageSquare, ArrowRight, Plus } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
@@ -25,9 +24,8 @@ export default function Home() {
|
||||
<div className="space-y-8 pb-8">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||
{t("home.welcome")}
|
||||
<GuideHelp guide="getting-started" label={t("home.welcome")} />
|
||||
</h1>
|
||||
<p className="text-muted-foreground">{t("home.tagline")}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Layout } from "@/components/layout";
|
||||
import { GuideHelp } from "@/components/guide-help";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -47,10 +46,7 @@ export default function RedundancyPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="w-6 h-6 text-amber-500" />
|
||||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
||||
{t("redundancy.title")}
|
||||
<GuideHelp guide="redundanz" label={t("redundancy.title")} />
|
||||
</h1>
|
||||
<h1 className="text-3xl font-bold">{t("redundancy.title")}</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{t("redundancy.subtitle")}
|
||||
|
||||
@@ -60,7 +60,6 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { customFetch } from "@workspace/api-client-react";
|
||||
import { recordRecentTool } from "@/lib/recent-tools";
|
||||
import { FieldHelp } from "@/components/field-help";
|
||||
import { GuideHelp } from "@/components/guide-help";
|
||||
|
||||
const ratingSchema = z.object({
|
||||
@@ -616,9 +615,8 @@ export default function ToolDetail() {
|
||||
<Dialog open={costDialogOpen} onOpenChange={(o) => { if (!o) setCostDialogOpen(false); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<DialogTitle>
|
||||
{editCost ? t("detail.edit") + " " + t("detail.costs") : t("detail.addCost")}
|
||||
<GuideHelp guide="kosten" label={t("detail.costs")} />
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("detail.costDialogSub")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -768,12 +766,12 @@ export default function ToolDetail() {
|
||||
|
||||
{isReviewFormOpen && (
|
||||
<Card className="border-primary shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{t("detail.addReview")}
|
||||
<GuideHelp guide="bewerten" label={t("detail.addReview")} />
|
||||
</CardTitle>
|
||||
<CardDescription>{t("detail.shareExperience", { name: tool.name })}</CardDescription>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle>{t("detail.addReview")}</CardTitle>
|
||||
<CardDescription>{t("detail.shareExperience", { name: tool.name })}</CardDescription>
|
||||
</div>
|
||||
<GuideHelp guide="bewerten" label={t("detail.addReview")} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
@@ -786,7 +784,6 @@ export default function ToolDetail() {
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
{t("detail.usefulness")}
|
||||
<FieldHelp schema="RatingInput" field="usefulness">{t("detail.usefulness")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<div className="py-2">
|
||||
<RatingStars
|
||||
@@ -807,7 +804,6 @@ export default function ToolDetail() {
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
{t("detail.usability")}
|
||||
<FieldHelp schema="RatingInput" field="usability">{t("detail.usability")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<div className="py-2">
|
||||
<RatingStars
|
||||
@@ -830,7 +826,6 @@ export default function ToolDetail() {
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
{t("detail.commentOptional")}
|
||||
<FieldHelp schema="RatingInput" field="comment">{t("detail.commentLabel")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
@@ -851,7 +846,6 @@ export default function ToolDetail() {
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
{t("detail.nameOptional")}
|
||||
<FieldHelp schema="RatingInput" field="reviewerName">{t("detail.nameLabel")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t("detail.anonymousPlaceholder")} {...field} />
|
||||
|
||||
@@ -30,7 +30,6 @@ import { CategoryCombobox } from "@/components/category-combobox";
|
||||
import { FeatureInput } from "@/components/feature-input";
|
||||
import { TagInput } from "@/components/tag-input";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { FieldHelp } from "@/components/field-help";
|
||||
import { GuideHelp } from "@/components/guide-help";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -163,13 +162,15 @@ export default function ToolEdit() {
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Pencil className="w-5 h-5 text-primary" />
|
||||
{t("toolForm.toolDetails")}
|
||||
<GuideHelp guide="tool-bearbeiten" label={t("toolForm.toolDetails")} />
|
||||
</CardTitle>
|
||||
<CardDescription>{t("toolForm.toolDetailsEditSub")}</CardDescription>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Pencil className="w-5 h-5 text-primary" />
|
||||
{t("toolForm.toolDetails")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("toolForm.toolDetailsEditSub")}</CardDescription>
|
||||
</div>
|
||||
<GuideHelp guide="tool-bearbeiten" label={t("toolForm.toolDetails")} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
@@ -182,7 +183,6 @@ export default function ToolEdit() {
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
{t("toolForm.name")}
|
||||
<FieldHelp schema="ToolInput" field="name">{t("toolForm.name")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t("toolForm.name")} {...field} />
|
||||
@@ -198,7 +198,6 @@ export default function ToolEdit() {
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
{t("toolForm.category")}
|
||||
<FieldHelp schema="ToolInput" field="category">{t("toolForm.category")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<CategoryCombobox value={field.value} onChange={field.onChange} />
|
||||
@@ -216,7 +215,6 @@ export default function ToolEdit() {
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
{t("toolForm.websiteUrlOptional")}
|
||||
<FieldHelp schema="ToolInput" field="websiteUrl">{t("toolForm.websiteUrl")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t("toolForm.urlPlaceholder")} type="url" {...field} />
|
||||
@@ -233,7 +231,6 @@ export default function ToolEdit() {
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
{t("toolForm.iconUrlOptional")}
|
||||
<FieldHelp schema="ToolInput" field="iconUrl">{t("toolForm.iconUrl")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -269,7 +266,6 @@ export default function ToolEdit() {
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
{t("toolForm.description")}
|
||||
<FieldHelp schema="ToolInput" field="description">{t("toolForm.description")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
@@ -288,7 +284,6 @@ export default function ToolEdit() {
|
||||
<div>
|
||||
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||
{t("toolForm.features")}
|
||||
<FieldHelp schema="ToolInput" field="features">{t("toolForm.features")}</FieldHelp>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{t("toolForm.featuresEditSub")}</p>
|
||||
</div>
|
||||
@@ -335,7 +330,6 @@ export default function ToolEdit() {
|
||||
<div>
|
||||
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||
{t("toolForm.tags")}
|
||||
<FieldHelp schema="ToolInput" field="tags">{t("toolForm.tags")}</FieldHelp>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{t("toolForm.tagsEditSub")}</p>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,6 @@ import { CategoryCombobox } from "@/components/category-combobox";
|
||||
import { FeatureInput } from "@/components/feature-input";
|
||||
import { TagInput } from "@/components/tag-input";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { FieldHelp } from "@/components/field-help";
|
||||
import { GuideHelp } from "@/components/guide-help";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -126,13 +125,15 @@ export default function ToolNew() {
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Wrench className="w-5 h-5 text-primary" />
|
||||
{t("toolForm.toolDetails")}
|
||||
<GuideHelp guide="tool-anlegen" label={t("toolForm.toolDetails")} />
|
||||
</CardTitle>
|
||||
<CardDescription>{t("toolForm.toolDetailsNewSub")}</CardDescription>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Wrench className="w-5 h-5 text-primary" />
|
||||
{t("toolForm.toolDetails")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("toolForm.toolDetailsNewSub")}</CardDescription>
|
||||
</div>
|
||||
<GuideHelp guide="tool-anlegen" label={t("toolForm.toolDetails")} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
@@ -145,7 +146,6 @@ export default function ToolNew() {
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
{t("toolForm.name")}
|
||||
<FieldHelp schema="ToolInput" field="name">{t("toolForm.name")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t("toolForm.namePlaceholder")} {...field} data-testid="input-tool-name" />
|
||||
@@ -162,7 +162,6 @@ export default function ToolNew() {
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
{t("toolForm.category")}
|
||||
<FieldHelp schema="ToolInput" field="category">{t("toolForm.category")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<CategoryCombobox
|
||||
@@ -183,7 +182,6 @@ export default function ToolNew() {
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
{t("toolForm.websiteUrlOptional")}
|
||||
<FieldHelp schema="ToolInput" field="websiteUrl">{t("toolForm.websiteUrl")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t("toolForm.urlPlaceholder")} type="url" {...field} data-testid="input-tool-url" />
|
||||
@@ -200,7 +198,6 @@ export default function ToolNew() {
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
{t("toolForm.iconUrlOptional")}
|
||||
<FieldHelp schema="ToolInput" field="iconUrl">{t("toolForm.iconUrl")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -237,7 +234,6 @@ export default function ToolNew() {
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
{t("toolForm.description")}
|
||||
<FieldHelp schema="ToolInput" field="description">{t("toolForm.description")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
@@ -257,7 +253,6 @@ export default function ToolNew() {
|
||||
<div>
|
||||
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||
{t("toolForm.features")}
|
||||
<FieldHelp schema="ToolInput" field="features">{t("toolForm.features")}</FieldHelp>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{t("toolForm.featuresNewSub")}</p>
|
||||
</div>
|
||||
@@ -313,7 +308,6 @@ export default function ToolNew() {
|
||||
<div>
|
||||
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||
{t("toolForm.tags")}
|
||||
<FieldHelp schema="ToolInput" field="tags">{t("toolForm.tags")}</FieldHelp>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{t("toolForm.tagsHelp")}</p>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
} from "@workspace/api-client-react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { Layout } from "@/components/layout";
|
||||
import { GuideHelp } from "@/components/guide-help";
|
||||
import { ToolCard } from "@/components/tool-card";
|
||||
import { ToolCardWide } from "@/components/tool-card-wide";
|
||||
import { ToolRow, TABLE_GRID } from "@/components/tool-row";
|
||||
@@ -231,9 +230,8 @@ export default function ToolsBrowse() {
|
||||
<div className="space-y-6 pb-8">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||
{t("nav.browseTools")}
|
||||
<GuideHelp guide="tools-finden" label={t("browse.subtitle")} />
|
||||
</h1>
|
||||
<p className="text-muted-foreground">{t("browse.subtitle")}</p>
|
||||
</div>
|
||||
@@ -525,10 +523,7 @@ export default function ToolsBrowse() {
|
||||
<AlertDialog open={compareUpsellOpen} onOpenChange={setCompareUpsellOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
{t("browse.comparePremiumTitle")}
|
||||
<GuideHelp guide="plaene" label={t("browse.comparePremiumTitle")} />
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("browse.comparePremiumTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("browse.comparePremiumSub")}
|
||||
</AlertDialogDescription>
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Layout } from "@/components/layout";
|
||||
import { GuideHelp } from "@/components/guide-help";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -157,9 +156,8 @@ export default function Trash() {
|
||||
<Layout>
|
||||
<div className="space-y-6 pb-10">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||
{t("trash.title")}
|
||||
<GuideHelp guide="papierkorb" label={t("trash.title")} />
|
||||
</h1>
|
||||
<p className="text-muted-foreground">{t("trash.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useWatchlist } from "@/hooks/use-watchlist";
|
||||
import { Layout } from "@/components/layout";
|
||||
import { ToolCard } from "@/components/tool-card";
|
||||
import { GuideHelp } from "@/components/guide-help";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ShieldAlert, Bookmark } from "lucide-react";
|
||||
@@ -43,9 +42,8 @@ export default function Watchlist() {
|
||||
<Layout>
|
||||
<div className="space-y-6 pb-10">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||
{t("watchlist.title")}
|
||||
<GuideHelp guide="watchlist" label={t("watchlist.title")} />
|
||||
</h1>
|
||||
<p className="text-muted-foreground">{t("watchlist.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user