feat(import): bulk tool import (CSV/JSON/YAML) for admins; NetBox-style help button
Build & Push Docker Image / build (push) Successful in 2m49s

- Add POST /admin/tools/import with format auto-detect, CSV delimiters
  (comma/semicolon/tab), per-row validation via CreateToolBody, bulk insert,
  audit log entries per imported tool; gated by new 'tool-import' feature
  flag (premium/enterprise; admins always pass)
- Add tool-import-dialog UI (format tabs, delimiter select, textarea, file
  upload, result/error list) behind hasFeature('tool-import')
- Replace FieldHelp question marks and bare GuideHelp links with a NetBox-style
  'Hilfe/Help' outline button (HelpCircle + text) in form headers only
- Sync locales to 482 keys per language (de/en), update handbook docs
  (administration import section, index/plaene feature tables), regenerate
  API client + zod schemas, add yaml dependency
This commit is contained in:
opencode
2026-08-04 23:09:11 +02:00
parent d47db6d386
commit 8f2fd89847
38 changed files with 870 additions and 159 deletions
+1 -3
View File
@@ -7,7 +7,6 @@ import {
} from "@workspace/api-client-react";
import { useTranslation } from "react-i18next";
import { Layout } from "@/components/layout";
import { GuideHelp } from "@/components/guide-help";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
@@ -51,9 +50,8 @@ export default function Analytics() {
<Layout>
<div className="space-y-6 pb-10">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
<h1 className="text-3xl font-bold tracking-tight mb-2">
{t("analytics.title")}
<GuideHelp guide="analytics" label={t("analytics.title")} />
</h1>
<p className="text-muted-foreground">{t("analytics.subtitle")}</p>
</div>
+1 -3
View File
@@ -8,7 +8,6 @@ import { Link, useSearch } from "wouter";
import { Layout } from "@/components/layout";
import { useAuth } from "@/hooks/use-auth";
import { RatingStars } from "@/components/rating-stars";
import { GuideHelp } from "@/components/guide-help";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -102,9 +101,8 @@ export default function Compare() {
<Layout>
<div className="space-y-6 pb-10">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
<h1 className="text-3xl font-bold tracking-tight mb-2">
{t("compare.title")}
<GuideHelp guide="vergleichen" label={t("compare.title")} />
</h1>
<p className="text-muted-foreground">{t("compare.subtitle", { count: list.length })}</p>
</div>
-5
View File
@@ -30,7 +30,6 @@ import {
ExternalLink,
FileText,
GitBranch,
HelpCircle,
Library,
Menu,
Search,
@@ -591,10 +590,6 @@ function SchemaView({ schema }: { schema: SchemaModel }) {
{schema.description && <p className="text-muted-foreground">{schema.description}</p>}
</div>
<FieldTable fields={schema.fields} />
<p className="text-xs text-muted-foreground">
<HelpCircle className="h-3.5 w-3.5 inline mr-1" />
{t("docs.fieldHelpHint")}
</p>
</div>
<Toc headings={headings} title={t("docs.fields")} />
</div>
+1 -3
View File
@@ -7,7 +7,6 @@ import {
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Layout } from "@/components/layout";
import { GuideHelp } from "@/components/guide-help";
import { ToolCard } from "@/components/tool-card";
import { Star, Wrench, MessageSquare, ArrowRight, Plus } from "lucide-react";
import { Link } from "wouter";
@@ -25,9 +24,8 @@ export default function Home() {
<div className="space-y-8 pb-8">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
<h1 className="text-3xl font-bold tracking-tight mb-2">
{t("home.welcome")}
<GuideHelp guide="getting-started" label={t("home.welcome")} />
</h1>
<p className="text-muted-foreground">{t("home.tagline")}</p>
</div>
+1 -5
View File
@@ -1,7 +1,6 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Layout } from "@/components/layout";
import { GuideHelp } from "@/components/guide-help";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -47,10 +46,7 @@ export default function RedundancyPage() {
<div className="space-y-6">
<div className="flex items-center gap-3">
<AlertTriangle className="w-6 h-6 text-amber-500" />
<h1 className="text-3xl font-bold flex items-center gap-2">
{t("redundancy.title")}
<GuideHelp guide="redundanz" label={t("redundancy.title")} />
</h1>
<h1 className="text-3xl font-bold">{t("redundancy.title")}</h1>
</div>
<p className="text-muted-foreground">
{t("redundancy.subtitle")}
+7 -13
View File
@@ -60,7 +60,6 @@ import {
} from "@/components/ui/alert-dialog";
import { customFetch } from "@workspace/api-client-react";
import { recordRecentTool } from "@/lib/recent-tools";
import { FieldHelp } from "@/components/field-help";
import { GuideHelp } from "@/components/guide-help";
const ratingSchema = z.object({
@@ -616,9 +615,8 @@ export default function ToolDetail() {
<Dialog open={costDialogOpen} onOpenChange={(o) => { if (!o) setCostDialogOpen(false); }}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<DialogTitle>
{editCost ? t("detail.edit") + " " + t("detail.costs") : t("detail.addCost")}
<GuideHelp guide="kosten" label={t("detail.costs")} />
</DialogTitle>
<DialogDescription>{t("detail.costDialogSub")}</DialogDescription>
</DialogHeader>
@@ -768,12 +766,12 @@ export default function ToolDetail() {
{isReviewFormOpen && (
<Card className="border-primary shadow-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
{t("detail.addReview")}
<GuideHelp guide="bewerten" label={t("detail.addReview")} />
</CardTitle>
<CardDescription>{t("detail.shareExperience", { name: tool.name })}</CardDescription>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="space-y-1.5">
<CardTitle>{t("detail.addReview")}</CardTitle>
<CardDescription>{t("detail.shareExperience", { name: tool.name })}</CardDescription>
</div>
<GuideHelp guide="bewerten" label={t("detail.addReview")} />
</CardHeader>
<CardContent>
<Form {...form}>
@@ -786,7 +784,6 @@ export default function ToolDetail() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("detail.usefulness")}
<FieldHelp schema="RatingInput" field="usefulness">{t("detail.usefulness")}</FieldHelp>
</FormLabel>
<div className="py-2">
<RatingStars
@@ -807,7 +804,6 @@ export default function ToolDetail() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("detail.usability")}
<FieldHelp schema="RatingInput" field="usability">{t("detail.usability")}</FieldHelp>
</FormLabel>
<div className="py-2">
<RatingStars
@@ -830,7 +826,6 @@ export default function ToolDetail() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("detail.commentOptional")}
<FieldHelp schema="RatingInput" field="comment">{t("detail.commentLabel")}</FieldHelp>
</FormLabel>
<FormControl>
<Textarea
@@ -851,7 +846,6 @@ export default function ToolDetail() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("detail.nameOptional")}
<FieldHelp schema="RatingInput" field="reviewerName">{t("detail.nameLabel")}</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder={t("detail.anonymousPlaceholder")} {...field} />
+9 -15
View File
@@ -30,7 +30,6 @@ import { CategoryCombobox } from "@/components/category-combobox";
import { FeatureInput } from "@/components/feature-input";
import { TagInput } from "@/components/tag-input";
import { useAuth } from "@/hooks/use-auth";
import { FieldHelp } from "@/components/field-help";
import { GuideHelp } from "@/components/guide-help";
import { useTranslation } from "react-i18next";
@@ -163,13 +162,15 @@ export default function ToolEdit() {
</Card>
) : (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Pencil className="w-5 h-5 text-primary" />
{t("toolForm.toolDetails")}
<GuideHelp guide="tool-bearbeiten" label={t("toolForm.toolDetails")} />
</CardTitle>
<CardDescription>{t("toolForm.toolDetailsEditSub")}</CardDescription>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="space-y-1.5">
<CardTitle className="flex items-center gap-2">
<Pencil className="w-5 h-5 text-primary" />
{t("toolForm.toolDetails")}
</CardTitle>
<CardDescription>{t("toolForm.toolDetailsEditSub")}</CardDescription>
</div>
<GuideHelp guide="tool-bearbeiten" label={t("toolForm.toolDetails")} />
</CardHeader>
<CardContent>
<Form {...form}>
@@ -182,7 +183,6 @@ export default function ToolEdit() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.name")}
<FieldHelp schema="ToolInput" field="name">{t("toolForm.name")}</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder={t("toolForm.name")} {...field} />
@@ -198,7 +198,6 @@ export default function ToolEdit() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.category")}
<FieldHelp schema="ToolInput" field="category">{t("toolForm.category")}</FieldHelp>
</FormLabel>
<FormControl>
<CategoryCombobox value={field.value} onChange={field.onChange} />
@@ -216,7 +215,6 @@ export default function ToolEdit() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.websiteUrlOptional")}
<FieldHelp schema="ToolInput" field="websiteUrl">{t("toolForm.websiteUrl")}</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder={t("toolForm.urlPlaceholder")} type="url" {...field} />
@@ -233,7 +231,6 @@ export default function ToolEdit() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.iconUrlOptional")}
<FieldHelp schema="ToolInput" field="iconUrl">{t("toolForm.iconUrl")}</FieldHelp>
</FormLabel>
<FormControl>
<div className="flex items-center gap-3">
@@ -269,7 +266,6 @@ export default function ToolEdit() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.description")}
<FieldHelp schema="ToolInput" field="description">{t("toolForm.description")}</FieldHelp>
</FormLabel>
<FormControl>
<Textarea
@@ -288,7 +284,6 @@ export default function ToolEdit() {
<div>
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
{t("toolForm.features")}
<FieldHelp schema="ToolInput" field="features">{t("toolForm.features")}</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">{t("toolForm.featuresEditSub")}</p>
</div>
@@ -335,7 +330,6 @@ export default function ToolEdit() {
<div>
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
{t("toolForm.tags")}
<FieldHelp schema="ToolInput" field="tags">{t("toolForm.tags")}</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">{t("toolForm.tagsEditSub")}</p>
</div>
+9 -15
View File
@@ -18,7 +18,6 @@ import { CategoryCombobox } from "@/components/category-combobox";
import { FeatureInput } from "@/components/feature-input";
import { TagInput } from "@/components/tag-input";
import { useAuth } from "@/hooks/use-auth";
import { FieldHelp } from "@/components/field-help";
import { GuideHelp } from "@/components/guide-help";
import { useTranslation } from "react-i18next";
@@ -126,13 +125,15 @@ export default function ToolNew() {
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Wrench className="w-5 h-5 text-primary" />
{t("toolForm.toolDetails")}
<GuideHelp guide="tool-anlegen" label={t("toolForm.toolDetails")} />
</CardTitle>
<CardDescription>{t("toolForm.toolDetailsNewSub")}</CardDescription>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="space-y-1.5">
<CardTitle className="flex items-center gap-2">
<Wrench className="w-5 h-5 text-primary" />
{t("toolForm.toolDetails")}
</CardTitle>
<CardDescription>{t("toolForm.toolDetailsNewSub")}</CardDescription>
</div>
<GuideHelp guide="tool-anlegen" label={t("toolForm.toolDetails")} />
</CardHeader>
<CardContent>
<Form {...form}>
@@ -145,7 +146,6 @@ export default function ToolNew() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.name")}
<FieldHelp schema="ToolInput" field="name">{t("toolForm.name")}</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder={t("toolForm.namePlaceholder")} {...field} data-testid="input-tool-name" />
@@ -162,7 +162,6 @@ export default function ToolNew() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.category")}
<FieldHelp schema="ToolInput" field="category">{t("toolForm.category")}</FieldHelp>
</FormLabel>
<FormControl>
<CategoryCombobox
@@ -183,7 +182,6 @@ export default function ToolNew() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.websiteUrlOptional")}
<FieldHelp schema="ToolInput" field="websiteUrl">{t("toolForm.websiteUrl")}</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder={t("toolForm.urlPlaceholder")} type="url" {...field} data-testid="input-tool-url" />
@@ -200,7 +198,6 @@ export default function ToolNew() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.iconUrlOptional")}
<FieldHelp schema="ToolInput" field="iconUrl">{t("toolForm.iconUrl")}</FieldHelp>
</FormLabel>
<FormControl>
<div className="flex items-center gap-3">
@@ -237,7 +234,6 @@ export default function ToolNew() {
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
{t("toolForm.description")}
<FieldHelp schema="ToolInput" field="description">{t("toolForm.description")}</FieldHelp>
</FormLabel>
<FormControl>
<Textarea
@@ -257,7 +253,6 @@ export default function ToolNew() {
<div>
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
{t("toolForm.features")}
<FieldHelp schema="ToolInput" field="features">{t("toolForm.features")}</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">{t("toolForm.featuresNewSub")}</p>
</div>
@@ -313,7 +308,6 @@ export default function ToolNew() {
<div>
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
{t("toolForm.tags")}
<FieldHelp schema="ToolInput" field="tags">{t("toolForm.tags")}</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">{t("toolForm.tagsHelp")}</p>
</div>
@@ -6,7 +6,6 @@ import {
} from "@workspace/api-client-react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { Layout } from "@/components/layout";
import { GuideHelp } from "@/components/guide-help";
import { ToolCard } from "@/components/tool-card";
import { ToolCardWide } from "@/components/tool-card-wide";
import { ToolRow, TABLE_GRID } from "@/components/tool-row";
@@ -231,9 +230,8 @@ export default function ToolsBrowse() {
<div className="space-y-6 pb-8">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
<h1 className="text-3xl font-bold tracking-tight mb-2">
{t("nav.browseTools")}
<GuideHelp guide="tools-finden" label={t("browse.subtitle")} />
</h1>
<p className="text-muted-foreground">{t("browse.subtitle")}</p>
</div>
@@ -525,10 +523,7 @@ export default function ToolsBrowse() {
<AlertDialog open={compareUpsellOpen} onOpenChange={setCompareUpsellOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
{t("browse.comparePremiumTitle")}
<GuideHelp guide="plaene" label={t("browse.comparePremiumTitle")} />
</AlertDialogTitle>
<AlertDialogTitle>{t("browse.comparePremiumTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("browse.comparePremiumSub")}
</AlertDialogDescription>
+1 -3
View File
@@ -15,7 +15,6 @@ import {
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { Layout } from "@/components/layout";
import { GuideHelp } from "@/components/guide-help";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -157,9 +156,8 @@ export default function Trash() {
<Layout>
<div className="space-y-6 pb-10">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
<h1 className="text-3xl font-bold tracking-tight mb-2">
{t("trash.title")}
<GuideHelp guide="papierkorb" label={t("trash.title")} />
</h1>
<p className="text-muted-foreground">{t("trash.subtitle")}</p>
</div>
+1 -3
View File
@@ -6,7 +6,6 @@ import { useAuth } from "@/hooks/use-auth";
import { useWatchlist } from "@/hooks/use-watchlist";
import { Layout } from "@/components/layout";
import { ToolCard } from "@/components/tool-card";
import { GuideHelp } from "@/components/guide-help";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ShieldAlert, Bookmark } from "lucide-react";
@@ -43,9 +42,8 @@ export default function Watchlist() {
<Layout>
<div className="space-y-6 pb-10">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
<h1 className="text-3xl font-bold tracking-tight mb-2">
{t("watchlist.title")}
<GuideHelp guide="watchlist" label={t("watchlist.title")} />
</h1>
<p className="text-muted-foreground">{t("watchlist.subtitle")}</p>
</div>