Compare commits

...

4 Commits

Author SHA1 Message Date
opencode d47db6d386 docs: add v0.8.5 release notes
Build & Push Docker Image / build (push) Successful in 3m43s
2026-08-04 21:01:58 +02:00
opencode 70e72dac71 fix(i18n): translate submit button, density/view toggles; rename watchlist guide to Merkliste; add guide help buttons
Build & Push Docker Image / build (push) Successful in 2m31s
2026-08-04 20:43:36 +02:00
opencode 63aa14914f docs: add v0.8.4 release notes
Build & Push Docker Image / build (push) Successful in 2m45s
2026-08-04 19:52:36 +02:00
opencode 3de59ccaaf feat(i18n): full app + docs localization (de/en)
Build & Push Docker Image / build (push) Successful in 2m33s
- translate remaining pages/components (admin, analytics, redundancy,
  trash, compare, browse, watchlist, admin tools tab, category combobox,
  theme toggle, tool preview card, tool-new/edit) to t() calls
- locales: 453 keys each, parity verified
- docs: bilingual handbook + release notes via *.en.md variants,
  language-aware docs.tsx (markdown paths, nav titles, search index),
  LanguageSwitcher in docs header
- generate-docs: emit per-locale handbook/release/search output,
  localized index.json fields (fileEn/titleEn), en-aware snapshots
2026-08-04 08:35:05 +02:00
96 changed files with 4068 additions and 479 deletions
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "wouter";
import {
useListTools,
@@ -10,11 +11,11 @@ import {
getListAllTagsQueryKey,
getGetTopToolsQueryKey,
getGetAnalyticsSummaryQueryKey,
type ToolWithStats,
} from "@workspace/api-client-react";
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";
@@ -27,6 +28,7 @@ import { Search, Trash2, ExternalLink, Pencil, Star } from "lucide-react";
import { format } from "date-fns";
export function AdminToolsTab() {
const { t } = useTranslation();
const { isAdmin, isLoading: authLoading } = useAuth();
const { toast } = useToast();
const queryClient = useQueryClient();
@@ -82,20 +84,20 @@ export function AdminToolsTab() {
{ data: { ids } },
{
onSuccess: (res) => {
toast({ title: "Tools moved to trash", description: `${res.trashed ?? ids.length} tool(s) moved to trash.` });
toast({ title: t("adminTools.toastMoved"), description: t("adminTools.toastMovedSub", { count: res.trashed ?? ids.length }) });
setSelected(new Set());
setConfirmTrash(false);
invalidate();
},
onError: (err) => {
toast({ title: "Failed to move to trash", description: err.data?.error ?? err.message, variant: "destructive" });
toast({ title: t("adminTools.toastMoveFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
},
},
);
}
if (!authLoading && !isAdmin) {
return <p className="text-sm text-muted-foreground py-8 text-center">Admin access required.</p>;
return <p className="text-sm text-muted-foreground py-8 text-center">{t("adminTools.accessRequired")}</p>;
}
const selectedIds = [...selected];
@@ -104,9 +106,12 @@ export function AdminToolsTab() {
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4 flex-wrap">
<div className="space-y-1.5">
<CardTitle>All Tools</CardTitle>
<CardTitle className="flex items-center gap-2">
{t("adminTools.allTools")}
<GuideHelp guide="administration" label={t("adminTools.allTools")} />
</CardTitle>
<CardDescription>
{selectedIds.length > 0 ? `${selectedIds.length} selected` : `${allTools.length} tool(s)`}
{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">
@@ -114,7 +119,7 @@ export function AdminToolsTab() {
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-8 w-56"
placeholder="Search tools…"
placeholder={t("adminTools.searchPlaceholder")}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
@@ -125,7 +130,7 @@ export function AdminToolsTab() {
disabled={selectedIds.length === 0 || trash.isPending}
onClick={() => setConfirmTrash(true)}
>
<Trash2 className="w-4 h-4 mr-2" /> Move to trash ({selectedIds.length})
<Trash2 className="w-4 h-4 mr-2" /> {t("adminTools.moveToTrash", { count: selectedIds.length })}
</Button>
</div>
</CardHeader>
@@ -135,7 +140,7 @@ export function AdminToolsTab() {
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : allTools.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">{search ? "No tools match your search." : "No tools yet."}</p>
<p className="text-sm text-muted-foreground py-8 text-center">{search ? t("adminTools.noMatch") : t("adminTools.noToolsYet")}</p>
) : (
<Table>
<TableHeader>
@@ -144,58 +149,58 @@ export function AdminToolsTab() {
<Checkbox
checked={selected.size === allTools.length && allTools.length > 0}
onCheckedChange={toggleAll}
aria-label="Select all"
aria-label={t("adminTools.selectAll")}
/>
</TableHead>
<TableHead>Name</TableHead>
<TableHead>Category</TableHead>
<TableHead>Rating</TableHead>
<TableHead>Created by</TableHead>
<TableHead>Created at</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead>{t("adminTools.name")}</TableHead>
<TableHead>{t("adminTools.category")}</TableHead>
<TableHead>{t("adminTools.rating")}</TableHead>
<TableHead>{t("adminTools.createdBy")}</TableHead>
<TableHead>{t("adminTools.createdAt")}</TableHead>
<TableHead className="text-right">{t("adminTools.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{allTools.map((t: ToolWithStats) => (
<TableRow key={t.id} className={selected.has(t.id) ? "bg-muted/40" : undefined}>
{allTools.map((tool) => (
<TableRow key={tool.id} className={selected.has(tool.id) ? "bg-muted/40" : undefined}>
<TableCell>
<Checkbox
checked={selected.has(t.id)}
onCheckedChange={() => toggle(t.id)}
aria-label={`Select ${t.name}`}
checked={selected.has(tool.id)}
onCheckedChange={() => toggle(tool.id)}
aria-label={t("adminTools.selectName", { name: tool.name })}
/>
</TableCell>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell className="font-medium">{tool.name}</TableCell>
<TableCell>
<Badge variant="outline">{t.category}</Badge>
<Badge variant="outline">{tool.category}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{t.ratingCount > 0 ? (
{tool.ratingCount > 0 ? (
<span className="inline-flex items-center gap-1">
<Star className="w-3 h-3 fill-amber-500 text-amber-500" />
{t.avgCombined != null ? t.avgCombined.toFixed(1) : "—"} ({t.ratingCount})
{tool.avgCombined != null ? tool.avgCombined.toFixed(1) : "—"} ({tool.ratingCount})
</span>
) : (
"—"
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{t.createdBy ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{tool.createdBy ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{format(new Date(t.createdAt), "dd.MM.yyyy")}
{format(new Date(tool.createdAt), "dd.MM.yyyy")}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
<Link href={`/tools/${t.id}`}><ExternalLink className="w-3.5 h-3.5" /></Link>
<Link href={`/tools/${tool.id}`}><ExternalLink className="w-3.5 h-3.5" /></Link>
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
<Link href={`/tools/${t.id}/edit`}><Pencil className="w-3.5 h-3.5" /></Link>
<Link href={`/tools/${tool.id}/edit`}><Pencil className="w-3.5 h-3.5" /></Link>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => handleTrash([t.id])}
onClick={() => handleTrash([tool.id])}
disabled={trash.isPending}
>
<Trash2 className="w-3.5 h-3.5" />
@@ -212,18 +217,18 @@ export function AdminToolsTab() {
<AlertDialog open={confirmTrash} onOpenChange={setConfirmTrash}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Move {selectedIds.length} tool(s) to trash?</AlertDialogTitle>
<AlertDialogTitle>{t("adminTools.confirmTitle", { count: selectedIds.length })}</AlertDialogTitle>
<AlertDialogDescription>
The selected tools will be hidden from all public views and moved to the trash, where they can be restored or permanently deleted.
{t("adminTools.confirmSub")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => handleTrash(selectedIds)}
>
Move to trash
{t("adminTools.moveToTrashAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -1,4 +1,5 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
@@ -23,7 +24,8 @@ interface CategoryComboboxProps {
placeholder?: string;
}
export function CategoryCombobox({ value, onChange, placeholder = "Select or type a category..." }: CategoryComboboxProps) {
export function CategoryCombobox({ value, onChange, placeholder }: CategoryComboboxProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState(value);
@@ -49,6 +51,8 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
(c) => c.toLowerCase() === inputValue.toLowerCase()
);
const resolvedPlaceholder = placeholder ?? t("category.placeholder");
function select(val: string) {
onChange(val);
setInputValue(val);
@@ -66,7 +70,7 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
data-testid="button-category-combobox"
>
<span className={cn(!value && "text-muted-foreground")}>
{value || placeholder}
{value || resolvedPlaceholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -74,7 +78,7 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
<PopoverContent className="w-full p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search or enter new category..."
placeholder={t("category.searchPlaceholder")}
value={inputValue}
onValueChange={(v) => {
setInputValue(v);
@@ -84,10 +88,10 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
/>
<CommandList>
{filtered.length === 0 && !showCreateOption && (
<CommandEmpty>No categories found.</CommandEmpty>
<CommandEmpty>{t("category.noCategories")}</CommandEmpty>
)}
{filtered.length > 0 && (
<CommandGroup heading="Known categories">
<CommandGroup heading={t("category.knownCategories")}>
{filtered.map((cat) => (
<CommandItem
key={cat}
@@ -104,13 +108,13 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
</CommandGroup>
)}
{showCreateOption && (
<CommandGroup heading="Create new">
<CommandGroup heading={t("category.createNew")}>
<CommandItem
value={inputValue}
onSelect={() => select(inputValue.trim())}
data-testid="item-category-create-new"
>
<span className="text-primary font-medium">+ Create</span>
<span className="text-primary font-medium">{t("category.create")}</span>
<span className="ml-2 text-muted-foreground">&ldquo;{inputValue.trim()}&rdquo;</span>
</CommandItem>
</CommandGroup>
@@ -1,5 +1,6 @@
import { Slider } from "@/components/ui/slider";
import { Maximize2, Minimize2 } from "lucide-react";
import { useTranslation } from "react-i18next";
export type Density = "cozy" | "compact";
@@ -10,8 +11,13 @@ export function DensityToggle({
value: Density;
onValueChange: (d: Density) => void;
}) {
const { t } = useTranslation();
const densityLabel = value === "compact" ? t("common.densityCompact") : t("common.densityComfortable");
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 />
<Slider
className="w-16"
@@ -20,7 +26,7 @@ export function DensityToggle({
step={1}
value={[value === "compact" ? 1 : 0]}
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 />
</div>
@@ -105,7 +105,7 @@ export function FilterPopover({
step={0.5}
value={[minRating ?? 0]}
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">
{minRating != null ? `${minRating.toFixed(1)}+` : t("filter.any")}
@@ -1,4 +1,5 @@
import { ChevronDown, Lightbulb, LightbulbOff, Monitor, Moon, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -11,6 +12,7 @@ import {
import { useTheme, type Theme } from "@/hooks/use-theme";
export function ThemeToggle() {
const { t } = useTranslation();
const { theme, setTheme, resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
@@ -25,8 +27,8 @@ export function ThemeToggle() {
size="icon"
className="h-9 w-9"
onClick={quickToggle}
title={isDark ? "Switch to light theme" : "Switch to dark theme"}
aria-label={isDark ? "Switch to light theme" : "Switch to dark theme"}
title={isDark ? t("theme.switchLight") : t("theme.switchDark")}
aria-label={isDark ? t("theme.switchLight") : t("theme.switchDark")}
data-testid="button-theme-quick-toggle"
>
{isDark ? <Lightbulb className="w-4 h-4" /> : <LightbulbOff className="w-4 h-4" />}
@@ -37,27 +39,27 @@ export function ThemeToggle() {
variant="ghost"
size="icon"
className="h-9 w-9"
aria-label="Choose theme"
title="Choose theme"
aria-label={t("theme.chooseTitle")}
title={t("theme.chooseTitle")}
data-testid="button-theme-menu"
>
<ChevronDown className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={6}>
<DropdownMenuLabel>Theme</DropdownMenuLabel>
<DropdownMenuLabel>{t("theme.title")}</DropdownMenuLabel>
<DropdownMenuRadioGroup value={theme} onValueChange={(v) => setTheme(v as Theme)}>
<DropdownMenuRadioItem value="light">
<Sun className="w-4 h-4 mr-2" />
Light
{t("theme.light")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark">
<Moon className="w-4 h-4 mr-2" />
Dark
{t("theme.dark")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system">
<Monitor className="w-4 h-4 mr-2" />
System
{t("theme.system")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
@@ -1,4 +1,5 @@
import { Link } from "wouter";
import { useTranslation } from "react-i18next";
import type { ToolWithStats } from "@workspace/api-client-react";
import { Badge } from "@/components/ui/badge";
@@ -6,6 +7,7 @@ import { RatingStars } from "@/components/rating-stars";
import { MiniBars } from "@/components/mini-bars";
export function ToolPreviewCard({ tool }: { tool: ToolWithStats }) {
const { t } = useTranslation();
return (
<div className="space-y-3">
<div>
@@ -22,7 +24,7 @@ export function ToolPreviewCard({ tool }: { tool: ToolWithStats }) {
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{tool.ratingCount} review{tool.ratingCount === 1 ? "" : "s"}</span>
<span className="text-muted-foreground">{t("browse.reviewCount", { count: tool.ratingCount })}</span>
<span className="font-semibold tabular-nums">
{tool.avgCombined != null ? tool.avgCombined.toFixed(1) : ""}/5
</span>
@@ -47,8 +49,7 @@ export function ToolPreviewCard({ tool }: { tool: ToolWithStats }) {
href={`/tools/${tool.id}`}
className="block w-full rounded-md border border-primary/40 bg-primary/5 px-3 py-1.5 text-center text-xs font-medium text-primary hover:bg-primary/10"
>
View details
</Link>
</div>
{t("common.viewDetails")}
</Link> </div>
);
}
@@ -17,6 +17,7 @@ import {
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/password-input";
import { GuideHelp } from "@/components/guide-help";
import { useToast } from "@/hooks/use-toast";
import { useChangeMyPassword, useGetPasswordRedirect, getGetPasswordRedirectQueryKey } from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
@@ -164,7 +165,10 @@ export function UserMenu() {
<Dialog open={pwOpen} onOpenChange={setPwOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("auth.changePassword")}</DialogTitle>
<DialogTitle className="flex items-center gap-2">
{t("auth.changePassword")}
<GuideHelp guide="konto" label={t("auth.changePassword")} />
</DialogTitle>
</DialogHeader>
{!user?.isLocal ? (
<div className="space-y-4 py-2">
@@ -193,7 +197,7 @@ export function UserMenu() {
<PasswordInput
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="min. 6 characters"
placeholder={t("auth.passwordMinLength")}
data-testid="input-new-password"
/>
</div>
@@ -1,12 +1,13 @@
import { LayoutGrid, List, Rows3 } from "lucide-react";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useTranslation } from "react-i18next";
export type ViewMode = "grid" | "table" | "rows";
const OPTIONS: { value: ViewMode; label: string; Icon: typeof LayoutGrid }[] = [
{ value: "grid", label: "Grid", Icon: LayoutGrid },
{ value: "table", label: "Table", Icon: List },
{ value: "rows", label: "Rows", Icon: Rows3 },
const OPTIONS: { value: ViewMode; labelKey: string; Icon: typeof LayoutGrid }[] = [
{ value: "grid", labelKey: "common.viewGrid", Icon: LayoutGrid },
{ value: "table", labelKey: "common.viewTable", Icon: List },
{ value: "rows", labelKey: "common.viewRows", Icon: Rows3 },
];
export function ViewToggle({
@@ -16,6 +17,7 @@ export function ViewToggle({
value: ViewMode;
onValueChange: (v: ViewMode) => void;
}) {
const { t } = useTranslation();
return (
<ToggleGroup
type="single"
@@ -25,10 +27,16 @@ export function ViewToggle({
}}
variant="outline"
size="sm"
aria-label="View mode"
aria-label={t("common.viewMode")}
>
{OPTIONS.map(({ value: v, label, Icon }) => (
<ToggleGroupItem key={v} value={v} aria-label={label} title={label} data-testid={`view-${v}`}>
{OPTIONS.map(({ value: v, labelKey, Icon }) => (
<ToggleGroupItem
key={v}
value={v}
aria-label={t(labelKey)}
title={t(labelKey)}
data-testid={`view-${v}`}
>
<Icon className="w-4 h-4" />
</ToggleGroupItem>
))}
+291 -6
View File
@@ -30,6 +30,7 @@
"currentPassword": "Aktuelles Passwort",
"newPassword": "Neues Passwort",
"confirmPassword": "Passwort bestätigen",
"passwordMinLength": "mind. 6 Zeichen",
"pwMismatch": "Passwörter stimmen nicht überein",
"pwTooShort": "Passwort muss mindestens 8 Zeichen haben",
"pwChanged": "Passwort geändert",
@@ -53,7 +54,17 @@
"loading": "Wird geladen…",
"all": "Alle",
"none": "Keine",
"language": "Sprache"
"language": "Sprache",
"viewDetails": "Details ansehen",
"guideTooltip": "Anleitung in der Dokumentation",
"viewMode": "Ansichtsmodus",
"viewGrid": "Raster",
"viewTable": "Tabelle",
"viewRows": "Zeilen",
"listDensity": "Listendichte",
"densityCompact": "Kompakt",
"densityComfortable": "Komfortabel",
"densityTitle": "Dichte: {{density}}"
},
"home": {
"welcome": "Willkommen bei toolr",
@@ -80,6 +91,8 @@
"category": "Kategorie",
"rating": "Bewertung",
"reviews": "Bewertungen",
"reviewCount_one": "{{count}} Bewertung",
"reviewCount_other": "{{count}} Bewertungen",
"noToolsFound": "Keine Tools gefunden",
"noToolsMatch": "Wir konnten keine Tools finden, die zu deinen Filtern passen. Versuche, die Suchkriterien anzupassen, oder füge ein neues Tool hinzu.",
"addTool": "Tool hinzufügen",
@@ -103,7 +116,9 @@
"features": "Funktionen",
"minRating": "Mindestbewertung",
"any": "Beliebig",
"clearFilters": "Filter zurücksetzen"
"clearFilters": "Filter zurücksetzen",
"removeFeature": "Funktion entfernen: {{feature}}",
"removeMinRating": "Mindestbewertung entfernen"
},
"detail": {
"ratingBreakdown": "Bewertungsaufschlüsselung",
@@ -133,7 +148,65 @@
"deleteToTrash": "Dies verschiebt {{name}} in den Papierkorb. Es kann später wiederhergestellt werden.",
"deletePermanent": "Dies entfernt {{name}} dauerhaft inklusive aller Bewertungen. Das kann nicht rückgängig gemacht werden.",
"deleting": "Löschen…",
"deleteAction": "Löschen"
"deleteAction": "Löschen",
"invalidToolId": "Ungültige Tool-ID",
"backToBrowse": "Zurück zur Übersicht",
"keyFeatures": "Wichtige Funktionen",
"toolNotFound": "Tool nicht gefunden.",
"similarTools": "Ähnliche Tools",
"noSimilarTools": "Keine ähnlichen Tools gefunden.",
"linkTool": "Tool verknüpfen",
"linkSimilarTool": "Ähnliches Tool verknüpfen",
"linkSimilarToolSub": "Verknüpfe dieses Tool manuell mit einem anderen Tool.",
"toolId": "Tool-ID",
"toolIdPlaceholder": "Ziel-Tool-ID eingeben",
"relationType": "Beziehungstyp",
"relationSimilar": "Ähnlich",
"relationReplaces": "Ersetzt",
"relationSupersededBy": "Ersetzt durch",
"notesOptional": "Notizen (optional)",
"notesPlaceholder": "Warum sind diese Tools verwandt?",
"createLink": "Verknüpfung erstellen",
"noCostInfo": "Noch keine Kosteninformationen hinzugefügt.",
"costDialogSub": "Verwalte die Lizenzkosteninformationen für dieses Tool.",
"licenseType": "Lizenztyp",
"licenseFree": "Kostenlos",
"licenseSubscription": "Abonnement",
"licenseOneTime": "Einmalig",
"licenseUsageBased": "Nutzungsbasiert",
"billingPeriod": "Abrechnungszeitraum",
"billingMonthly": "Monatlich",
"billingQuarterly": "Quartalsweise",
"billingYearly": "Jährlich",
"costLabel": "Kosten",
"currency": "Währung",
"costNotes": "Notizen",
"costNotesPlaceholder": "Abrechnungsdetails, Vertragsinformationen…",
"commentOptional": "Kommentar (optional)",
"commentLabel": "Kommentar",
"commentPlaceholder": "Was denkst du über dieses Tool?",
"nameOptional": "Name (optional)",
"nameLabel": "Name",
"anonymousPlaceholder": "Anonym",
"anonymousEngineer": "Anonymer Ingenieur",
"beFirstToReview": "Sei der Erste, der seine Gedanken zu diesem Tool teilt.",
"shareExperience": "Teile deine Erfahrung mit {{name}}",
"score": "Punktestand",
"toastRelationCreated": "Beziehung erstellt",
"toastRelationFailed": "Beziehung konnte nicht erstellt werden",
"toastRelationDeleted": "Beziehung gelöscht",
"toastRelationDeleteFailed": "Beziehung konnte nicht gelöscht werden",
"toastCostUpdated": "Kosten aktualisiert",
"toastCostAdded": "Kosten hinzugefügt",
"toastCostFailed": "Kosten konnten nicht gespeichert werden",
"toastCostDeleted": "Kosten gelöscht",
"toastCostDeleteFailed": "Kosten konnten nicht gelöscht werden",
"toastRatingSubmitted": "Bewertung abgesendet",
"toastRatingThanks": "Danke für dein Feedback!",
"toastRatingFailed": "Bewertung konnte nicht abgesendet werden",
"toastToolDeleted": "Tool gelöscht",
"toastDeleteFailed": "Löschen fehlgeschlagen",
"unexpectedError": "Ein unerwarteter Fehler ist aufgetreten."
},
"compare": {
"title": "Tools vergleichen",
@@ -176,7 +249,29 @@
"deletePermanently": "Endgültig löschen",
"emptyTrash": "Papierkorb leeren",
"deletedAt": "Gelöscht am",
"deletedBy": "Gelöscht von"
"deletedBy": "Gelöscht von",
"name": "Name",
"category": "Kategorie",
"actions": "Aktionen",
"selectAll": "Alle auswählen",
"selectName": "{{name}} auswählen",
"restoreAction": "Wiederherstellen",
"deleteAction": "Löschen",
"deleteConfirmTitle": "{{count}} Tool(s) endgültig löschen?",
"deleteConfirmSub": "Dies entfernt die ausgewählten Tools dauerhaft inklusive aller Bewertungen, Kosten und Beziehungen. Das kann nicht rückgängig gemacht werden.",
"emptyConfirmTitle": "Papierkorb leeren?",
"emptyConfirmSub": "Dies entfernt alle {{count}} Tool(s) im Papierkorb dauerhaft inklusive ihrer Bewertungen, Kosten und Beziehungen. Das kann nicht rückgängig gemacht werden.",
"toastRestored": "Tools wiederhergestellt",
"toastRestoredSub": "{{count}} Tool(s) wiederhergestellt.",
"toastRestoreFailed": "Wiederherstellen fehlgeschlagen",
"toastDeleted": "Tools gelöscht",
"toastDeletedSub": "{{count}} Tool(s) endgültig entfernt.",
"toastDeleteFailed": "Löschen fehlgeschlagen",
"toastEmptied": "Papierkorb geleert",
"toastEmptiedSub": "{{count}} Tool(s) endgültig entfernt.",
"toastEmptyFailed": "Papierkorb konnte nicht geleert werden",
"searchPlaceholder": "Tools suchen…",
"toolCount": "{{count}} Tool(s)"
},
"notFound": {
"text": "Diese Seite existiert nicht.",
@@ -209,7 +304,10 @@
"parameter": "Parameter",
"noResults": "Keine Treffer",
"fields": "Felder",
"fieldHelpHint": "Hinweis: Formular-Felder verlinken per ?-Icon direkt zu den jeweiligen Zeilen dieser Tabelle."
"fieldHelpHint": "Hinweis: Formular-Felder verlinken per ?-Icon direkt zu den jeweiligen Zeilen dieser Tabelle.",
"name": "Name",
"in": "In",
"requestBody": "Request-Body"
},
"command": {
"navigate": "Navigation",
@@ -217,5 +315,192 @@
"tools": "Tools",
"noResults": "Keine Tools für „{{query}}“ gefunden.",
"startTyping": "Beginne zu tippen, um Tools zu suchen."
},
"admin": {
"accessRequired": "Admin-Zugriff erforderlich",
"accessRequiredSub": "Du benötigst Admin-Rechte, um diese Seite anzusehen.",
"goHome": "Zur Startseite",
"panel": "Admin-Bereich",
"panelSub": "Benutzer verwalten und Systemänderungen einsehen.",
"redundancyDashboard": "Redundanz-Dashboard",
"tabUsers": "Benutzer",
"tabTools": "Tools",
"tabAudit": "Audit-Log",
"tabSystem": "System",
"localUsers": "Lokale Benutzer",
"localUsersSub": "Konten für die lokale Authentifizierung verwalten.",
"addUser": "Benutzer hinzufügen",
"noUsersYet": "Noch keine Benutzer.",
"auditLog": "Audit-Log",
"auditLogSub": "Alle vom System protokollierten Erstellungs-, Aktualisierungs- und Löschvorgänge.",
"by": "von",
"noAuditEntries": "Noch keine Audit-Einträge.",
"system": "System",
"systemSub": "Build-Informationen der aktuell laufenden Bereitstellung.",
"version": "Version",
"commit": "Commit",
"buildDate": "Build-Datum",
"trashRetention": "Papierkorb-Aufbewahrung",
"days": "Tage",
"keepForever": "Für immer behalten",
"createNewUser": "Neuen Benutzer erstellen",
"username": "Benutzername",
"password": "Passwort",
"emailOptional": "E-Mail (optional)",
"role": "Rolle",
"roleUser": "Benutzer",
"roleAdmin": "Admin",
"plan": "Plan",
"planFree": "Kostenlos",
"planPremium": "Premium",
"planEnterprise": "Enterprise",
"creating": "Erstelle…",
"createUser": "Benutzer erstellen",
"editUser": "Benutzer bearbeiten — {{username}}",
"setPassword": "Passwort festlegen",
"resetsPassword": "Setzt das Passwort des Benutzers sofort zurück.",
"idpManaged": "Das Passwort wird vom Identity-Provider (Keycloak) verwaltet. Setze es dort zurück.",
"setting": "Setze…",
"deleteUser": "Benutzer löschen",
"deleteUserConfirm": "Bist du sicher, dass du {{username}} löschen möchtest? Das kann nicht rückgängig gemacht werden.",
"minPasswordPlaceholder": "min. 6 Zeichen",
"usernamePlaceholder": "benutzername",
"emailPlaceholder": "user@example.com",
"toastUserCreated": "Benutzer erstellt",
"toastUserCreatedSub": "{{username}} wurde erstellt.",
"toastUserCreateFailed": "Benutzer konnte nicht erstellt werden",
"toastUserUpdated": "Benutzer aktualisiert",
"toastUserUpdateFailed": "Benutzer konnte nicht aktualisiert werden",
"toastPwTooShort": "Passwort zu kurz",
"toastPwTooShortSub": "Mindestens 6 Zeichen.",
"toastPwUpdated": "Passwort aktualisiert",
"toastPwUpdatedSub": "Passwort für {{username}} wurde festgelegt.",
"toastPwSetFailed": "Passwort konnte nicht festgelegt werden",
"toastUserDeleted": "Benutzer gelöscht",
"toastUserDeletedSub": "{{username}} wurde entfernt.",
"toastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden"
},
"adminTools": {
"accessRequired": "Admin-Zugriff erforderlich.",
"allTools": "Alle Tools",
"toolCount": "{{count}} Tool(s)",
"selected": "{{count}} ausgewählt",
"searchPlaceholder": "Tools suchen…",
"moveToTrash": "In den Papierkorb verschieben ({{count}})",
"noMatch": "Keine Tools entsprechen deiner Suche.",
"noToolsYet": "Noch keine Tools.",
"selectAll": "Alle auswählen",
"selectName": "{{name}} auswählen",
"name": "Name",
"category": "Kategorie",
"rating": "Bewertung",
"createdBy": "Erstellt von",
"createdAt": "Erstellt am",
"actions": "Aktionen",
"confirmTitle": "{{count}} Tool(s) in den Papierkorb verschieben?",
"confirmSub": "Die ausgewählten Tools werden aus allen öffentlichen Ansichten entfernt und in den Papierkorb verschoben, wo sie wiederhergestellt oder endgültig gelöscht werden können.",
"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"
},
"analytics": {
"title": "Plattform-Analysen",
"subtitle": "Makro-Einblicke in die Leistung der Tools und das Engagement der Community.",
"totalToolsIndexed": "Indexierte Tools gesamt",
"totalRatingsCast": "Abgegebene Bewertungen gesamt",
"activeCategories": "Aktive Kategorien",
"avgGlobalScore": "Ø Globaler Punktestand",
"top8Tools": "Top 8 Tools nach kombiniertem Punktestand",
"top8ToolsSub": "Die bestbewerteten Tools der Plattform",
"toolsByCategory": "Tools nach Kategorie",
"toolsByCategorySub": "Verteilung der Tools auf Kategorien",
"globalRatingDistribution": "Globale Bewertungsverteilung",
"globalRatingDistributionSub": "Wie Benutzer über alle Tools abstimmen",
"radarTools": "Tools"
},
"redundancy": {
"title": "Tool-Analyse & Empfehlungen",
"subtitle": "Automatische Redundanz-Erkennung mit Kosten- und Bewertungsvergleich. Admins können manuell bestätigen, welches Tool die bessere Wahl ist.",
"toolsComparisons": "{{tools}} Tools, {{pairs}} Vergleiche",
"totalMonthly": "/Monat gesamt",
"reviews": "Bewertungen",
"features": "Features",
"comparisonsTitle": "Vergleiche & Empfehlungen",
"vs": "gegen",
"noTools": "Keine Tools gefunden.",
"perMonth": "/Monat",
"toastEvalSaved": "Auswertung gespeichert",
"toastEvalFailed": "Auswertung konnte nicht gespeichert werden"
},
"toolForm": {
"backToBrowse": "Zurück zur Übersicht",
"backToTool": "Zurück zum Tool",
"addTitle": "Neues Tool hinzufügen",
"addSubtitle": "Reiche ein Tool ein, das du nutzt, damit die Community es bewerten kann.",
"editTitle": "Tool bearbeiten",
"editSubtitle": "Aktualisiere Tool-Details und Metadaten.",
"signInRequired": "Anmeldung erforderlich",
"signInRequiredSub": "Du musst angemeldet sein, um ein Tool einzureichen.",
"toolDetails": "Tool-Details",
"toolDetailsNewSub": "Gib die grundlegenden Informationen zum Tool an.",
"toolDetailsEditSub": "Ändere die Tool-Informationen unten.",
"name": "Name",
"nameMin": "Name muss mindestens 2 Zeichen haben",
"category": "Kategorie",
"categoryRequired": "Kategorie ist erforderlich",
"websiteUrl": "Website-URL",
"websiteUrlOptional": "Website-URL (optional)",
"iconUrl": "Icon-/Logo-URL",
"iconUrlOptional": "Icon-/Logo-URL (optional)",
"iconPreview": "Icon-Vorschau",
"description": "Beschreibung",
"descriptionMin": "Beschreibung muss mindestens 10 Zeichen haben",
"invalidUrl": "Muss eine gültige URL sein",
"features": "Features",
"featuresNewSub": "Liste die wichtigsten Funktionen auf. Vorhandene Features anderer Tools sind auswählbar.",
"featuresEditSub": "Wichtige Funktionen dieses Tools. Vorhandene Features anderer Tools sind auswählbar.",
"addFeature": "Feature hinzufügen",
"noFeatures": "Keine Features hinzugefügt.",
"featurePlaceholder": "z. B. Echtzeit-Zusammenarbeit",
"tags": "Tags",
"tagsNewSub": "Schlüsselwörter, um dieses Tool zu finden. Vorhandene Tags anderer Tools sind auswählbar.",
"tagsEditSub": "Schlüsselwörter für dieses Tool. Vorhandene Tags anderer Tools sind auswählbar.",
"addTag": "Tag hinzufügen",
"tag": "Tag",
"namePlaceholder": "z. B. React, Next.js, Postgres",
"urlPlaceholder": "https://…",
"logoPlaceholder": "https://example.com/logo.png",
"descriptionPlaceholderNew": "Was macht dieses Tool? Warum nutzen es Menschen?",
"descriptionPlaceholderEdit": "Was macht dieses Tool?",
"addingTool": "Füge Tool hinzu…",
"submitTool": "Tool einreichen",
"saving": "Speichere…",
"saveChanges": "Änderungen speichern",
"toastAdded": "Tool erfolgreich hinzugefügt",
"toastAddedSub": "Dein Tool ist jetzt zur Bewertung verfügbar.",
"toastAddFailed": "Tool konnte nicht hinzugefügt werden",
"toastUpdated": "Tool aktualisiert",
"toastUpdatedSub": "Änderungen erfolgreich gespeichert.",
"toastUpdateFailed": "Tool konnte nicht aktualisiert werden",
"tagsHelp": "Schlüsselwörter, die helfen, dieses Tool zu finden. Vorhandene Tags anderer Tools sind auswählbar.",
"tagPlaceholder": "Tag"
},
"category": {
"placeholder": "Kategorie auswählen oder eingeben…",
"searchPlaceholder": "Kategorie suchen oder neue eingeben…",
"noCategories": "Keine Kategorien gefunden.",
"knownCategories": "Bekannte Kategorien",
"createNew": "Neu erstellen",
"create": "+ Erstellen"
},
"theme": {
"title": "Design",
"light": "Hell",
"dark": "Dunkel",
"system": "System",
"chooseTitle": "Design auswählen",
"switchLight": "Zum hellen Design wechseln",
"switchDark": "Zum dunklen Design wechseln"
}
}
}
+292 -7
View File
@@ -30,6 +30,7 @@
"currentPassword": "Current password",
"newPassword": "New password",
"confirmPassword": "Confirm password",
"passwordMinLength": "min. 6 characters",
"pwMismatch": "Passwords do not match",
"pwTooShort": "Password must be at least 8 characters",
"pwChanged": "Password changed",
@@ -53,7 +54,17 @@
"loading": "Loading…",
"all": "All",
"none": "None",
"language": "Language"
"language": "Language",
"viewDetails": "View details",
"guideTooltip": "Guide in the documentation",
"viewMode": "View mode",
"viewGrid": "Grid",
"viewTable": "Table",
"viewRows": "Rows",
"listDensity": "List density",
"densityCompact": "Compact",
"densityComfortable": "Comfortable",
"densityTitle": "Density: {{density}}"
},
"home": {
"welcome": "Welcome to toolr",
@@ -80,6 +91,8 @@
"category": "Category",
"rating": "Rating",
"reviews": "Reviews",
"reviewCount_one": "{{count}} review",
"reviewCount_other": "{{count}} reviews",
"noToolsFound": "No tools found",
"noToolsMatch": "We couldn't find any tools matching your current filters. Try adjusting your search criteria or add a new tool.",
"addTool": "Add Tool",
@@ -103,7 +116,9 @@
"features": "Features",
"minRating": "Min. rating",
"any": "Any",
"clearFilters": "Clear filters"
"clearFilters": "Clear filters",
"removeFeature": "Remove feature: {{feature}}",
"removeMinRating": "Remove min rating"
},
"detail": {
"ratingBreakdown": "Rating Breakdown",
@@ -133,7 +148,65 @@
"deleteToTrash": "This will move {{name}} to the trash. It can be restored later.",
"deletePermanent": "This will permanently remove {{name}} and all its ratings. This cannot be undone.",
"deleting": "Deleting…",
"deleteAction": "Delete"
"deleteAction": "Delete",
"invalidToolId": "Invalid Tool ID",
"backToBrowse": "Back to browse",
"keyFeatures": "Key Features",
"toolNotFound": "Tool not found.",
"similarTools": "Similar Tools",
"noSimilarTools": "No similar tools found.",
"linkTool": "Link Tool",
"linkSimilarTool": "Link Similar Tool",
"linkSimilarToolSub": "Manually link this tool to another tool.",
"toolId": "Tool ID",
"toolIdPlaceholder": "Enter target tool ID",
"relationType": "Relation Type",
"relationSimilar": "Similar",
"relationReplaces": "Replaces",
"relationSupersededBy": "Superseded By",
"notesOptional": "Notes (Optional)",
"notesPlaceholder": "Why are these tools related?",
"createLink": "Create Link",
"noCostInfo": "No cost information added yet.",
"costDialogSub": "Manage license cost information for this tool.",
"licenseType": "License Type",
"licenseFree": "Free",
"licenseSubscription": "Subscription",
"licenseOneTime": "One-Time",
"licenseUsageBased": "Usage-Based",
"billingPeriod": "Billing Period",
"billingMonthly": "Monthly",
"billingQuarterly": "Quarterly",
"billingYearly": "Yearly",
"costLabel": "Cost",
"currency": "Currency",
"costNotes": "Notes",
"costNotesPlaceholder": "Billing details, contract info...",
"commentOptional": "Comment (Optional)",
"commentLabel": "Comment",
"commentPlaceholder": "What do you think about this tool?",
"nameOptional": "Name (Optional)",
"nameLabel": "Name",
"anonymousPlaceholder": "Anonymous",
"anonymousEngineer": "Anonymous Engineer",
"beFirstToReview": "Be the first to share your thoughts on this tool.",
"shareExperience": "Share your experience with {{name}}",
"score": "Score",
"toastRelationCreated": "Relation created",
"toastRelationFailed": "Failed to create relation",
"toastRelationDeleted": "Relation deleted",
"toastRelationDeleteFailed": "Failed to delete relation",
"toastCostUpdated": "Cost updated",
"toastCostAdded": "Cost added",
"toastCostFailed": "Failed to save cost",
"toastCostDeleted": "Cost deleted",
"toastCostDeleteFailed": "Failed to delete cost",
"toastRatingSubmitted": "Rating submitted",
"toastRatingThanks": "Thank you for your feedback!",
"toastRatingFailed": "Failed to submit rating",
"toastToolDeleted": "Tool deleted",
"toastDeleteFailed": "Failed to delete",
"unexpectedError": "An unexpected error occurred."
},
"compare": {
"title": "Compare Tools",
@@ -176,7 +249,29 @@
"deletePermanently": "Delete permanently",
"emptyTrash": "Empty trash",
"deletedAt": "Deleted at",
"deletedBy": "Deleted by"
"deletedBy": "Deleted by",
"name": "Name",
"category": "Category",
"actions": "Actions",
"selectAll": "Select all",
"selectName": "Select {{name}}",
"restoreAction": "Restore",
"deleteAction": "Delete",
"deleteConfirmTitle": "Delete {{count}} tool(s) permanently?",
"deleteConfirmSub": "This permanently removes the selected tools along with all their ratings, costs and relations. This cannot be undone.",
"emptyConfirmTitle": "Empty the trash?",
"emptyConfirmSub": "This permanently removes all {{count}} tool(s) in the trash along with their ratings, costs and relations. This cannot be undone.",
"toastRestored": "Tools restored",
"toastRestoredSub": "{{count}} tool(s) restored.",
"toastRestoreFailed": "Failed to restore",
"toastDeleted": "Tools deleted",
"toastDeletedSub": "{{count}} tool(s) permanently removed.",
"toastDeleteFailed": "Failed to delete",
"toastEmptied": "Trash emptied",
"toastEmptiedSub": "{{count}} tool(s) permanently removed.",
"toastEmptyFailed": "Failed to empty trash",
"searchPlaceholder": "Search tools…",
"toolCount": "{{count}} tool(s)"
},
"notFound": {
"text": "This page doesn't exist.",
@@ -191,7 +286,7 @@
"latest": "Latest",
"repo": "Repository",
"nav": "Documentation",
"guides": "Guide",
"guides": "Guides",
"endpoints": "Endpoints",
"schemas": "Data models",
"releases": "Release notes",
@@ -209,7 +304,10 @@
"parameter": "Parameter",
"noResults": "No results",
"fields": "Fields",
"fieldHelpHint": "Note: form fields link via the ? icon directly to the respective rows of this table."
"fieldHelpHint": "Note: form fields link via the ? icon directly to the respective rows of this table.",
"name": "Name",
"in": "In",
"requestBody": "Request Body"
},
"command": {
"navigate": "Navigate",
@@ -217,5 +315,192 @@
"tools": "Tools",
"noResults": "No tools found for \"{{query}}\".",
"startTyping": "Start typing to search tools."
},
"admin": {
"accessRequired": "Admin Access Required",
"accessRequiredSub": "You need admin rights to view this page.",
"goHome": "Go Home",
"panel": "Admin Panel",
"panelSub": "Manage users and review system changes.",
"redundancyDashboard": "Redundancy Dashboard",
"tabUsers": "Users",
"tabTools": "Tools",
"tabAudit": "Audit Log",
"tabSystem": "System",
"localUsers": "Local Users",
"localUsersSub": "Manage accounts for local authentication.",
"addUser": "Add User",
"noUsersYet": "No users yet.",
"auditLog": "Audit Log",
"auditLogSub": "All create, update and delete operations tracked by the system.",
"by": "by",
"noAuditEntries": "No audit entries yet.",
"system": "System",
"systemSub": "Build information of the currently live deployment.",
"version": "Version",
"commit": "Commit",
"buildDate": "Build date",
"trashRetention": "Trash retention",
"days": "days",
"keepForever": "Keep forever",
"createNewUser": "Create New User",
"username": "Username",
"password": "Password",
"emailOptional": "Email (Optional)",
"role": "Role",
"roleUser": "User",
"roleAdmin": "Admin",
"plan": "Plan",
"planFree": "Free",
"planPremium": "Premium",
"planEnterprise": "Enterprise",
"creating": "Creating…",
"createUser": "Create User",
"editUser": "Edit User — {{username}}",
"setPassword": "Set Password",
"resetsPassword": "Resets the user's password immediately.",
"idpManaged": "Password is managed by the identity provider (Keycloak). Reset it there.",
"setting": "Setting…",
"deleteUser": "Delete User",
"deleteUserConfirm": "Are you sure you want to delete {{username}}? This cannot be undone.",
"minPasswordPlaceholder": "min. 6 characters",
"usernamePlaceholder": "username",
"emailPlaceholder": "user@example.com",
"toastUserCreated": "User created",
"toastUserCreatedSub": "{{username}} has been created.",
"toastUserCreateFailed": "Failed to create user",
"toastUserUpdated": "User updated",
"toastUserUpdateFailed": "Failed to update user",
"toastPwTooShort": "Password too short",
"toastPwTooShortSub": "Minimum 6 characters.",
"toastPwUpdated": "Password updated",
"toastPwUpdatedSub": "Password for {{username}} has been set.",
"toastPwSetFailed": "Failed to set password",
"toastUserDeleted": "User deleted",
"toastUserDeletedSub": "{{username}} has been removed.",
"toastUserDeleteFailed": "Failed to delete user"
},
"adminTools": {
"accessRequired": "Admin access required.",
"allTools": "All Tools",
"toolCount": "{{count}} tool(s)",
"selected": "{{count}} selected",
"searchPlaceholder": "Search tools…",
"moveToTrash": "Move to trash ({{count}})",
"noMatch": "No tools match your search.",
"noToolsYet": "No tools yet.",
"selectAll": "Select all",
"selectName": "Select {{name}}",
"name": "Name",
"category": "Category",
"rating": "Rating",
"createdBy": "Created by",
"createdAt": "Created at",
"actions": "Actions",
"confirmTitle": "Move {{count}} tool(s) to trash?",
"confirmSub": "The selected tools will be hidden from all public views and moved to the trash, where they can be restored or permanently deleted.",
"moveToTrashAction": "Move to trash",
"toastMoved": "Tools moved to trash",
"toastMovedSub": "{{count}} tool(s) moved to trash.",
"toastMoveFailed": "Failed to move to trash"
},
"analytics": {
"title": "Platform Analytics",
"subtitle": "Macro-level insights into tool performance and community engagement.",
"totalToolsIndexed": "Total Tools Indexed",
"totalRatingsCast": "Total Ratings Cast",
"activeCategories": "Active Categories",
"avgGlobalScore": "Avg Global Score",
"top8Tools": "Top 8 Tools by Combined Score",
"top8ToolsSub": "Highest rated tools across the platform",
"toolsByCategory": "Tools by Category",
"toolsByCategorySub": "Distribution of tools across categories",
"globalRatingDistribution": "Global Rating Distribution",
"globalRatingDistributionSub": "How users are voting across all tools",
"radarTools": "Tools"
},
"redundancy": {
"title": "Tool Analysis & Recommendations",
"subtitle": "Automatic redundancy detection with cost and rating comparison. Admin can manually confirm which tool is the better choice.",
"toolsComparisons": "{{tools}} tools, {{pairs}} comparisons",
"totalMonthly": "/mo total",
"reviews": "reviews",
"features": "features",
"comparisonsTitle": "Comparisons & Recommendations",
"vs": "vs",
"noTools": "No tools found.",
"perMonth": "/mo",
"toastEvalSaved": "Evaluation saved",
"toastEvalFailed": "Failed to save evaluation"
},
"toolForm": {
"backToBrowse": "Back to browse",
"backToTool": "Back to tool",
"addTitle": "Add a New Tool",
"addSubtitle": "Submit a tool you use to let the community rate and review it.",
"editTitle": "Edit Tool",
"editSubtitle": "Update tool details and metadata.",
"signInRequired": "Sign in required",
"signInRequiredSub": "You must be signed in to submit a tool.",
"toolDetails": "Tool Details",
"toolDetailsNewSub": "Provide the basic information about the tool.",
"toolDetailsEditSub": "Modify the tool information below.",
"name": "Name",
"nameMin": "Name must be at least 2 characters",
"category": "Category",
"categoryRequired": "Category is required",
"websiteUrl": "Website URL",
"websiteUrlOptional": "Website URL (Optional)",
"iconUrl": "Icon / Logo URL",
"iconUrlOptional": "Icon / Logo URL (Optional)",
"iconPreview": "icon preview",
"description": "Description",
"descriptionMin": "Description must be at least 10 characters",
"invalidUrl": "Must be a valid URL",
"features": "Features",
"featuresNewSub": "List key capabilities. Existing features from other tools are selectable.",
"featuresEditSub": "Key capabilities of this tool. Existing features from other tools are selectable.",
"addFeature": "Add Feature",
"noFeatures": "No features added.",
"featurePlaceholder": "e.g. Real-time collaboration",
"tags": "Tags",
"tagsNewSub": "Keywords to help find this tool. Existing tags from other tools are selectable.",
"tagsEditSub": "Keywords for this tool. Existing tags from other tools are selectable.",
"addTag": "Add Tag",
"tag": "Tag",
"namePlaceholder": "e.g. React, Next.js, Postgres",
"urlPlaceholder": "https://...",
"logoPlaceholder": "https://example.com/logo.png",
"descriptionPlaceholderNew": "What does this tool do? Why do people use it?",
"descriptionPlaceholderEdit": "What does this tool do?",
"addingTool": "Adding Tool...",
"submitTool": "Submit Tool",
"saving": "Saving…",
"saveChanges": "Save Changes",
"toastAdded": "Tool added successfully",
"toastAddedSub": "Your tool is now available for review.",
"toastAddFailed": "Failed to add tool",
"toastUpdated": "Tool updated",
"toastUpdatedSub": "Changes saved successfully.",
"toastUpdateFailed": "Failed to update tool",
"tagsHelp": "Keywords to help find this tool. Existing tags from other tools are selectable.",
"tagPlaceholder": "Tag"
},
"category": {
"placeholder": "Select or type a category...",
"searchPlaceholder": "Search or enter new category...",
"noCategories": "No categories found.",
"knownCategories": "Known categories",
"createNew": "Create new",
"create": "+ Create"
},
"theme": {
"title": "Theme",
"light": "Light",
"dark": "Dark",
"system": "System",
"chooseTitle": "Choose theme",
"switchLight": "Switch to light theme",
"switchDark": "Switch to dark theme"
}
}
}
+72 -71
View File
@@ -29,8 +29,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Users, ScrollText, ShieldAlert, Plus, Pencil, Trash2, Clock, AlertTriangle, Wrench, Server } from "lucide-react";
import { format } from "date-fns";
import { AdminToolsTab } from "@/components/admin-tools-tab";
import { useTranslation } from "react-i18next";
export default function Admin() {
const { t } = useTranslation();
const [, setLocation] = useLocation();
const { user, isAdmin, isLoading: authLoading } = useAuth();
const { toast } = useToast();
@@ -68,9 +70,9 @@ export default function Admin() {
<Layout>
<div className="flex flex-col items-center justify-center py-20 gap-4">
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
<h2 className="text-2xl font-bold">Admin Access Required</h2>
<p className="text-muted-foreground">You need admin rights to view this page.</p>
<Button variant="outline" onClick={() => setLocation("/")}>Go Home</Button>
<h2 className="text-2xl font-bold">{t("admin.accessRequired")}</h2>
<p className="text-muted-foreground">{t("admin.accessRequiredSub")}</p>
<Button variant="outline" onClick={() => setLocation("/")}>{t("admin.goHome")}</Button>
</div>
</Layout>
);
@@ -82,7 +84,7 @@ export default function Admin() {
{ data: { username: newUsername, password: newPassword, email: newEmail || undefined, role: newRole, tier: newTier } },
{
onSuccess: () => {
toast({ title: "User created", description: `${newUsername} has been created.` });
toast({ title: t("admin.toastUserCreated"), description: t("admin.toastUserCreatedSub", { username: newUsername }) });
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
setCreateOpen(false);
setNewUsername("");
@@ -91,7 +93,7 @@ export default function Admin() {
setNewRole("user");
},
onError: (err) => {
toast({ title: "Failed to create user", description: err.data?.error ?? err.message, variant: "destructive" });
toast({ title: t("admin.toastUserCreateFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
},
},
);
@@ -103,12 +105,12 @@ export default function Admin() {
{ id: editUser.id, data: { role: editUser.role as "admin" | "user", tier: editUser.tier as "free" | "premium" | "enterprise" } },
{
onSuccess: () => {
toast({ title: "User updated" });
toast({ title: t("admin.toastUserUpdated") });
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
setEditUser(null);
},
onError: (err) => {
toast({ title: "Failed to update user", description: err.data?.error ?? err.message, variant: "destructive" });
toast({ title: t("admin.toastUserUpdateFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
},
},
);
@@ -117,18 +119,18 @@ export default function Admin() {
const handleSetUserPassword = () => {
if (!editUser || !editPassword) return;
if (editPassword.length < 6) {
toast({ title: "Password too short", description: "Minimum 6 characters.", variant: "destructive" });
toast({ title: t("admin.toastPwTooShort"), description: t("admin.toastPwTooShortSub"), variant: "destructive" });
return;
}
setUserPassword.mutate(
{ id: editUser.id, data: { password: editPassword } },
{
onSuccess: () => {
toast({ title: "Password updated", description: `Password for ${editUser.username} has been set.` });
toast({ title: t("admin.toastPwUpdated"), description: t("admin.toastPwUpdatedSub", { username: editUser.username }) });
setEditPassword("");
},
onError: (err) => {
toast({ title: "Failed to set password", description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" });
toast({ title: t("admin.toastPwSetFailed"), description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" });
},
},
);
@@ -140,12 +142,12 @@ export default function Admin() {
{ id: deleteConfirm.id },
{
onSuccess: () => {
toast({ title: "User deleted", description: `${deleteConfirm.username} has been removed.` });
toast({ title: t("admin.toastUserDeleted"), description: t("admin.toastUserDeletedSub", { username: deleteConfirm.username }) });
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
setDeleteConfirm(null);
},
onError: (err) => {
toast({ title: "Failed to delete user", description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" });
toast({ title: t("admin.toastUserDeleteFailed"), description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" });
},
},
);
@@ -163,11 +165,11 @@ export default function Admin() {
<div>
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Admin Panel</h1>
<p className="text-muted-foreground">Manage users and review system changes.</p>
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("admin.panel")}</h1>
<p className="text-muted-foreground">{t("admin.panelSub")}</p>
</div>
<Button asChild variant="outline" size="sm" className="gap-2">
<Link href="/admin/redundancy"><AlertTriangle className="w-4 h-4" /> Redundancy Dashboard</Link>
<Link href="/admin/redundancy"><AlertTriangle className="w-4 h-4" /> {t("admin.redundancyDashboard")}</Link>
</Button>
</div>
</div>
@@ -175,16 +177,16 @@ export default function Admin() {
<Tabs defaultValue="users">
<TabsList className="mb-4">
<TabsTrigger value="users" className="gap-2">
<Users className="w-4 h-4" /> Users
<Users className="w-4 h-4" /> {t("admin.tabUsers")}
</TabsTrigger>
<TabsTrigger value="tools" className="gap-2">
<Wrench className="w-4 h-4" /> Tools
<Wrench className="w-4 h-4" /> {t("admin.tabTools")}
</TabsTrigger>
<TabsTrigger value="audit" className="gap-2">
<ScrollText className="w-4 h-4" /> Audit Log
<ScrollText className="w-4 h-4" /> {t("admin.tabAudit")}
</TabsTrigger>
<TabsTrigger value="system" className="gap-2">
<Server className="w-4 h-4" /> System
<Server className="w-4 h-4" /> {t("admin.tabSystem")}
</TabsTrigger>
</TabsList>
@@ -192,11 +194,11 @@ export default function Admin() {
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Local Users</CardTitle>
<CardDescription>Manage accounts for local authentication.</CardDescription>
<CardTitle>{t("admin.localUsers")}</CardTitle>
<CardDescription>{t("admin.localUsersSub")}</CardDescription>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="w-4 h-4 mr-2" /> Add User
<Plus className="w-4 h-4 mr-2" /> {t("admin.addUser")}
</Button>
</CardHeader>
<CardContent>
@@ -248,7 +250,7 @@ export default function Admin() {
</div>
))}
{(!users || users.length === 0) && (
<p className="text-sm text-muted-foreground py-4 text-center">No users yet.</p>
<p className="text-sm text-muted-foreground py-4 text-center">{t("admin.noUsersYet")}</p>
)}
</div>
)}
@@ -263,8 +265,8 @@ export default function Admin() {
<TabsContent value="audit">
<Card>
<CardHeader>
<CardTitle>Audit Log</CardTitle>
<CardDescription>All create, update and delete operations tracked by the system.</CardDescription>
<CardTitle>{t("admin.auditLog")}</CardTitle>
<CardDescription>{t("admin.auditLogSub")}</CardDescription>
</CardHeader>
<CardContent>
{loadingLogs ? (
@@ -289,8 +291,7 @@ export default function Admin() {
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>by <span className="font-medium text-foreground">{log.username}</span></span>
{log.changes && (
<span>{t("admin.by")} <span className="font-medium text-foreground">{log.username}</span></span> {log.changes && (
<span className="truncate max-w-[400px] font-mono bg-muted px-1.5 py-0.5 rounded text-[11px]">
{log.changes.length > 120 ? log.changes.slice(0, 120) + "…" : log.changes}
</span>
@@ -299,7 +300,7 @@ export default function Admin() {
</div>
))}
{(!auditLogs || auditLogs.length === 0) && (
<p className="text-sm text-muted-foreground py-4 text-center">No audit entries yet.</p>
<p className="text-sm text-muted-foreground py-4 text-center">{t("admin.noAuditEntries")}</p>
)}
</div>
)}
@@ -310,17 +311,17 @@ export default function Admin() {
<TabsContent value="system">
<Card>
<CardHeader>
<CardTitle>System</CardTitle>
<CardDescription>Build information of the currently live deployment.</CardDescription>
<CardTitle>{t("admin.system")}</CardTitle>
<CardDescription>{t("admin.systemSub")}</CardDescription>
</CardHeader>
<CardContent>
<div className="divide-y">
<div className="flex items-center justify-between py-3">
<span className="text-sm text-muted-foreground">Version</span>
<span className="text-sm text-muted-foreground">{t("admin.version")}</span>
<span className="text-sm font-medium">{versionInfo?.version || "dev"}</span>
</div>
<div className="flex items-center justify-between py-3">
<span className="text-sm text-muted-foreground">Commit</span>
<span className="text-sm text-muted-foreground">{t("admin.commit")}</span>
{versionInfo?.commitSha ? (
<a
href={`https://git.kubebase.de/admin/tool-evaluator/commit/${versionInfo.commitSha}`}
@@ -336,17 +337,17 @@ export default function Admin() {
)}
</div>
<div className="flex items-center justify-between py-3">
<span className="text-sm text-muted-foreground">Build date</span>
<span className="text-sm text-muted-foreground">{t("admin.buildDate")}</span>
<span className="text-sm">
{versionInfo?.buildDate ? format(new Date(versionInfo.buildDate), "dd.MM.yyyy HH:mm") : "—"}
</span>
</div>
<div className="flex items-center justify-between py-3">
<span className="text-sm text-muted-foreground">Trash retention</span>
<span className="text-sm text-muted-foreground">{t("admin.trashRetention")}</span>
<span className="text-sm">
{(versionInfo?.trashRetentionDays ?? 0) > 0
? `${versionInfo?.trashRetentionDays} days`
: "Keep forever"}
? `${versionInfo?.trashRetentionDays} ${t("admin.days")}`
: t("admin.keepForever")}
</span>
</div>
</div>
@@ -359,51 +360,51 @@ export default function Admin() {
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create New User</DialogTitle>
<DialogTitle>{t("admin.createNewUser")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Username</Label>
<Input value={newUsername} onChange={(e) => setNewUsername(e.target.value)} placeholder="username" />
<Label>{t("admin.username")}</Label>
<Input value={newUsername} onChange={(e) => setNewUsername(e.target.value)} placeholder={t("admin.usernamePlaceholder")} />
</div>
<div className="space-y-2">
<Label>Password</Label>
<PasswordInput value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="min. 6 characters" />
<Label>{t("admin.password")}</Label>
<PasswordInput value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder={t("admin.minPasswordPlaceholder")} />
</div>
<div className="space-y-2">
<Label>Email (Optional)</Label>
<Input type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} placeholder="user@example.com" />
<Label>{t("admin.emailOptional")}</Label>
<Input type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} placeholder={t("admin.emailPlaceholder")} />
</div>
<div className="space-y-2">
<Label>Role</Label>
<Label>{t("admin.role")}</Label>
<Select value={newRole} onValueChange={(v) => setNewRole(v as "admin" | "user")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="user">{t("admin.roleUser")}</SelectItem>
<SelectItem value="admin">{t("admin.roleAdmin")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Plan</Label>
<Label>{t("admin.plan")}</Label>
<Select value={newTier} onValueChange={(v) => setNewTier(v as "free" | "premium" | "enterprise")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="premium">Premium</SelectItem>
<SelectItem value="enterprise">Enterprise</SelectItem>
<SelectItem value="free">{t("admin.planFree")}</SelectItem>
<SelectItem value="premium">{t("admin.planPremium")}</SelectItem>
<SelectItem value="enterprise">{t("admin.planEnterprise")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button variant="outline" onClick={() => setCreateOpen(false)}>{t("common.cancel")}</Button>
<Button onClick={handleCreateUser} disabled={createUser.isPending || !newUsername || !newPassword}>
{createUser.isPending ? "Creating" : "Create User"}
{createUser.isPending ? t("admin.creating") : t("admin.createUser")}
</Button>
</DialogFooter>
</DialogContent>
@@ -412,11 +413,11 @@ export default function Admin() {
<Dialog open={!!editUser} onOpenChange={(open) => { if (!open) { setEditUser(null); setEditPassword(""); } }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Edit User {editUser?.username}</DialogTitle>
<DialogTitle>{t("admin.editUser", { username: editUser?.username })}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Role</Label>
<Label>{t("admin.role")}</Label>
<Select
value={editUser?.role || "user"}
onValueChange={(v) => setEditUser(editUser ? { ...editUser, role: v } : null)}
@@ -425,13 +426,13 @@ export default function Admin() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="user">{t("admin.roleUser")}</SelectItem>
<SelectItem value="admin">{t("admin.roleAdmin")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Plan</Label>
<Label>{t("admin.plan")}</Label>
<Select
value={editUser?.tier || "free"}
onValueChange={(v) => setEditUser(editUser ? { ...editUser, tier: v } : null)}
@@ -440,36 +441,36 @@ export default function Admin() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="premium">Premium</SelectItem>
<SelectItem value="enterprise">Enterprise</SelectItem>
<SelectItem value="free">{t("admin.planFree")}</SelectItem>
<SelectItem value="premium">{t("admin.planPremium")}</SelectItem>
<SelectItem value="enterprise">{t("admin.planEnterprise")}</SelectItem>
</SelectContent>
</Select>
</div>
{editUser?.authProvider !== "oidc" ? (
<div className="space-y-2 border-t pt-4">
<Label>Set Password</Label>
<Label>{t("admin.setPassword")}</Label>
<PasswordInput
value={editPassword}
onChange={(e) => setEditPassword(e.target.value)}
placeholder="min. 6 characters"
placeholder={t("admin.minPasswordPlaceholder")}
data-testid="input-set-password"
/>
<p className="text-xs text-muted-foreground">Resets the user's password immediately.</p>
<p className="text-xs text-muted-foreground">{t("admin.resetsPassword")}</p>
</div>
) : (
<div className="space-y-2 border-t pt-4 text-sm text-muted-foreground">
Password is managed by the identity provider (Keycloak). Reset it there.
{t("admin.idpManaged")}
</div>
)}
</div>
<DialogFooter className="flex-col sm:flex-row sm:justify-end gap-2">
<Button variant="outline" onClick={() => { setEditUser(null); setEditPassword(""); }}>Cancel</Button>
<Button variant="outline" onClick={() => { setEditUser(null); setEditPassword(""); }}>{t("common.cancel")}</Button>
<Button
onClick={handleUpdateUser}
disabled={updateUser.isPending}
>
Save
{t("common.save")}
</Button>
{editUser?.authProvider !== "oidc" && (
<Button
@@ -477,7 +478,7 @@ export default function Admin() {
onClick={handleSetUserPassword}
disabled={setUserPassword.isPending || !editPassword}
>
{setUserPassword.isPending ? "Setting" : "Set Password"}
{setUserPassword.isPending ? t("admin.setting") : t("admin.setPassword")}
</Button>
)}
</DialogFooter>
@@ -487,15 +488,15 @@ export default function Admin() {
<Dialog open={!!deleteConfirm} onOpenChange={(open) => !open && setDeleteConfirm(null)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete User</DialogTitle>
<DialogTitle>{t("admin.deleteUser")}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground py-2">
Are you sure you want to delete <span className="font-medium text-foreground">{deleteConfirm?.username}</span>? This cannot be undone.
{t("admin.deleteUserConfirm", { username: deleteConfirm?.username })}
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>Cancel</Button>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>{t("common.cancel")}</Button>
<Button variant="destructive" onClick={handleDeleteUser} disabled={deleteUser.isPending}>
{deleteUser.isPending ? "Deleting" : "Delete"}
{deleteUser.isPending ? t("detail.deleting") : t("common.delete")}
</Button>
</DialogFooter>
</DialogContent>
+18 -16
View File
@@ -5,6 +5,7 @@ import {
useGetRatingDistribution,
GetTopToolsMetric
} 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";
@@ -17,6 +18,7 @@ import {
import { BarChart3, TrendingUp, Layers, Activity } from "lucide-react";
export default function Analytics() {
const { t } = useTranslation();
const { data: summary, isLoading: loadingSummary } = useGetAnalyticsSummary();
const { data: topTools, isLoading: loadingTopTools } = useGetTopTools({
@@ -50,10 +52,10 @@ export default function Analytics() {
<div className="space-y-6 pb-10">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
Platform Analytics
<GuideHelp guide="analytics" label="Platform Analytics" />
{t("analytics.title")}
<GuideHelp guide="analytics" label={t("analytics.title")} />
</h1>
<p className="text-muted-foreground">Macro-level insights into tool performance and community engagement.</p>
<p className="text-muted-foreground">{t("analytics.subtitle")}</p>
</div>
{/* Top KPI Cards */}
@@ -61,7 +63,7 @@ export default function Analytics() {
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-muted-foreground">Total Tools Indexed</span>
<span className="text-sm font-medium text-muted-foreground">{t("analytics.totalToolsIndexed")}</span>
<Layers className="w-4 h-4 text-muted-foreground" />
</div>
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
@@ -73,7 +75,7 @@ export default function Analytics() {
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-muted-foreground">Total Ratings Cast</span>
<span className="text-sm font-medium text-muted-foreground">{t("analytics.totalRatingsCast")}</span>
<Activity className="w-4 h-4 text-muted-foreground" />
</div>
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
@@ -85,7 +87,7 @@ export default function Analytics() {
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-muted-foreground">Active Categories</span>
<span className="text-sm font-medium text-muted-foreground">{t("analytics.activeCategories")}</span>
<BarChart3 className="w-4 h-4 text-muted-foreground" />
</div>
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
@@ -97,7 +99,7 @@ export default function Analytics() {
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-muted-foreground">Avg Global Score</span>
<span className="text-sm font-medium text-muted-foreground">{t("analytics.avgGlobalScore")}</span>
<TrendingUp className="w-4 h-4 text-muted-foreground" />
</div>
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
@@ -112,8 +114,8 @@ export default function Analytics() {
{/* Top Tools Chart */}
<Card className="col-span-1 lg:col-span-2">
<CardHeader>
<CardTitle>Top 8 Tools by Combined Score</CardTitle>
<CardDescription>Highest rated tools across the platform</CardDescription>
<CardTitle>{t("analytics.top8Tools")}</CardTitle>
<CardDescription>{t("analytics.top8ToolsSub")}</CardDescription>
</CardHeader>
<CardContent>
{loadingTopTools ? (
@@ -140,8 +142,8 @@ export default function Analytics() {
{/* Category Breakdown */}
<Card>
<CardHeader>
<CardTitle>Tools by Category</CardTitle>
<CardDescription>Distribution of tools across categories</CardDescription>
<CardTitle>{t("analytics.toolsByCategory")}</CardTitle>
<CardDescription>{t("analytics.toolsByCategorySub")}</CardDescription>
</CardHeader>
<CardContent>
{loadingCategories ? (
@@ -153,7 +155,7 @@ export default function Analytics() {
<PolarGrid stroke="hsl(var(--border))" />
<PolarAngleAxis dataKey="category" tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} />
<PolarRadiusAxis angle={30} domain={[0, 'auto']} tick={false} axisLine={false} />
<Radar name="Tools" dataKey="tools" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.3} />
<Radar name={t("analytics.radarTools")} dataKey="tools" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.3} />
<RechartsTooltip />
</RadarChart>
</ResponsiveContainer>
@@ -165,8 +167,8 @@ export default function Analytics() {
{/* Rating Distributions */}
<Card>
<CardHeader>
<CardTitle>Global Rating Distribution</CardTitle>
<CardDescription>How users are voting across all tools</CardDescription>
<CardTitle>{t("analytics.globalRatingDistribution")}</CardTitle>
<CardDescription>{t("analytics.globalRatingDistributionSub")}</CardDescription>
</CardHeader>
<CardContent>
{loadingDistribution ? (
@@ -174,7 +176,7 @@ export default function Analytics() {
) : (
<div className="space-y-5">
<div>
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">Usefulness</span>
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">{t("detail.usefulness")}</span>
<div className="h-[110px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={usefulnessData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
@@ -188,7 +190,7 @@ export default function Analytics() {
</div>
</div>
<div>
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">Usability</span>
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">{t("detail.usability")}</span>
<div className="h-[110px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={usabilityData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
+4 -4
View File
@@ -145,7 +145,7 @@ export default function Compare() {
))}
</TableRow>
<TableRow>
<TableCell className="font-medium">Usefulness</TableCell>
<TableCell className="font-medium">{t("detail.usefulness")}</TableCell>
{list.map((t) => (
<TableCell key={t.id} className={cn(isBest(t.avgUsefulness, bestUsefulness) && "bg-primary/5")}>
<span className="tabular-nums">{fmt(t.avgUsefulness)}/5</span>
@@ -153,7 +153,7 @@ export default function Compare() {
))}
</TableRow>
<TableRow>
<TableCell className="font-medium">Usability</TableCell>
<TableCell className="font-medium">{t("detail.usability")}</TableCell>
{list.map((t) => (
<TableCell key={t.id} className={cn(isBest(t.avgUsability, bestUsability) && "bg-primary/5")}>
<span className="tabular-nums">{fmt(t.avgUsability)}/5</span>
@@ -177,7 +177,7 @@ export default function Compare() {
))}
</TableRow>
<TableRow>
<TableCell className="font-medium">Features</TableCell>
<TableCell className="font-medium">{t("filter.features")}</TableCell>
{list.map((t) => (
<TableCell key={t.id} className="align-top">
<div className="flex flex-wrap gap-1">
@@ -190,7 +190,7 @@ export default function Compare() {
))}
</TableRow>
<TableRow>
<TableCell className="font-medium">Tags</TableCell>
<TableCell className="font-medium">{t("filter.tags")}</TableCell>
{list.map((t) => (
<TableCell key={t.id} className="align-top">
<div className="flex flex-wrap gap-1">
+55 -20
View File
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/theme-toggle";
import { LanguageSwitcher } from "@/components/language-switcher";
import {
Select,
SelectContent,
@@ -57,6 +58,20 @@ function docsFile(version: string | null, relPath: string) {
return version === null ? relPath : `versions/${version}/${relPath}`;
}
// Prefer the English file/title variant when the active UI language is English.
function useDocsLocale() {
const { i18n } = useTranslation();
return (i18n.language ?? "en").toLowerCase().startsWith("en") ? "en" : "de";
}
function localizedFile(file: string, fileEn: string | null, locale: "en" | "de"): string {
return locale === "en" && fileEn ? fileEn : file;
}
function localizedTitle(title: string, titleEn: string | null, locale: "en" | "de"): string {
return locale === "en" && titleEn ? titleEn : title;
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -100,13 +115,22 @@ type Reference = { tags: TagGroup[]; schemas: SchemaModel[] };
type ReleaseDoc = {
version: string;
file: string;
fileEn: string | null;
title: string;
titleEn: string | null;
date: string | null;
hasReference: boolean;
hasHandbook: boolean;
};
type HandbookPage = { slug: string; file: string; title: string; order: number };
type HandbookPage = {
slug: string;
file: string;
fileEn: string | null;
title: string;
titleEn: string | null;
order: number;
};
type SearchEntry = { title: string; href: string; kind: string; text: string };
@@ -339,6 +363,7 @@ function DocsNav({
}) {
const [location] = useLocation();
const { t } = useTranslation();
const locale = useDocsLocale();
const navLink = (href: string) => {
const active = location === href || (href !== "/docs" && location.startsWith(href));
@@ -353,7 +378,7 @@ function DocsNav({
icon: BookOpen,
items: handbook.map((p) => ({
href: docsHref(version, `handbook/${p.slug}`),
label: p.title,
label: localizedTitle(p.title, p.titleEn, locale),
active: navLink(docsHref(version, `handbook/${p.slug}`)),
})),
});
@@ -536,7 +561,7 @@ function FieldTable({ fields }: { fields: Field[] }) {
</td>
<td className="px-3 py-2">
{f.required ? (
<Badge className="bg-primary/10 text-primary border-primary/20">required</Badge>
<Badge className="bg-primary/10 text-primary border-primary/20">{t("docs.required")}</Badge>
) : (
<span className="text-muted-foreground"></span>
)}
@@ -611,8 +636,8 @@ function EndpointTagView({ tag }: { tag: TagGroup }) {
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50 text-left text-xs uppercase tracking-wider text-muted-foreground">
<th className="px-3 py-2 font-semibold">Name</th>
<th className="px-3 py-2 font-semibold">In</th>
<th className="px-3 py-2 font-semibold">{t("docs.name")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.in")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.type")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.required")}</th>
<th className="px-3 py-2 font-semibold">{t("docs.description")}</th>
@@ -625,7 +650,7 @@ function EndpointTagView({ tag }: { tag: TagGroup }) {
<td className="px-3 py-1.5 text-muted-foreground">{p.in}</td>
<td className="px-3 py-1.5"><FieldTypeChip type={p.type} /></td>
<td className="px-3 py-1.5">
{p.required ? <Badge className="bg-primary/10 text-primary border-primary/20">req</Badge> : ""}
{p.required ? <Badge className="bg-primary/10 text-primary border-primary/20">{t("docs.required")}</Badge> : ""}
</td>
<td className="px-3 py-1.5 text-muted-foreground">
{p.description}
@@ -642,7 +667,7 @@ function EndpointTagView({ tag }: { tag: TagGroup }) {
{ep.requestBody && (
<div className="mb-3 rounded-lg border bg-muted/30 p-3">
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Request Body {ep.requestBody.required && <Badge className="ml-1">required</Badge>}
{t("docs.requestBody")} {ep.requestBody.required && <Badge className="ml-1">{t("docs.required")}</Badge>}
</p>
<FieldTypeChip type={ep.requestBody.schema} />
</div>
@@ -679,6 +704,7 @@ function EndpointTagView({ tag }: { tag: TagGroup }) {
function ReleasesView({ versions }: { versions: ReleaseDoc[] }) {
const [, setLocation] = useLocation();
const { t } = useTranslation();
const locale = useDocsLocale();
return (
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex-1 min-w-0 max-w-3xl space-y-4">
@@ -697,7 +723,7 @@ function ReleasesView({ versions }: { versions: ReleaseDoc[] }) {
<FileText className="h-4 w-4 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold">{v.title}</span>
<span className="font-semibold">{localizedTitle(v.title, v.titleEn, locale)}</span>
<Badge variant="secondary">{v.version}</Badge>
</div>
{v.date && (
@@ -707,7 +733,7 @@ function ReleasesView({ versions }: { versions: ReleaseDoc[] }) {
</span>
)}
</div>
{v.hasReference && <Badge className="bg-primary/10 text-primary">API-Referenz</Badge>}
{v.hasReference && <Badge className="bg-primary/10 text-primary">{t("docs.reference")}</Badge>}
</button>
))}
</div>
@@ -717,7 +743,9 @@ function ReleasesView({ versions }: { versions: ReleaseDoc[] }) {
);
}
function ReleaseNoteView({ version }: { version: string }) {
function ReleaseNoteView({ version, doc }: { version: string; doc: ReleaseDoc | null }) {
const locale = useDocsLocale();
const file = doc ? `releases/${localizedFile(doc.file, doc.fileEn, locale)}` : `releases/${version}.md`;
return (
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex-1 min-w-0 max-w-3xl">
@@ -733,16 +761,20 @@ function ReleaseNoteView({ version }: { version: string }) {
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
<MarkdownView file={`releases/${version}.md`} />
<MarkdownView file={file} />
</div>
</div>
);
}
function HandbookView({ slug }: { slug: string }) {
function HandbookView({ slug, pages }: { slug: string; pages: HandbookPage[] | null }) {
const version = useDocsVersion();
const handbookFile = `${slug}.md`;
return <MarkdownView file={docsFile(version, `handbook/${handbookFile}`)} />;
const locale = useDocsLocale();
const page = pages?.find((p) => p.slug === slug);
const file = page
? docsFile(version, `handbook/${localizedFile(page.file, page.fileEn, locale)}`)
: docsFile(version, `handbook/${slug}.md`);
return <MarkdownView file={file} />;
}
// ---------------------------------------------------------------------------
@@ -750,7 +782,9 @@ function HandbookView({ slug }: { slug: string }) {
// ---------------------------------------------------------------------------
function useDocsSearch(query: string) {
const { data, error } = useJson<SearchEntry[]>(query ? `${DOCS_BASE}/search.json` : null);
const locale = useDocsLocale();
const indexFile = locale === "en" ? "search.en.json" : "search.json";
const { data, error } = useJson<SearchEntry[]>(query ? `${DOCS_BASE}/${indexFile}` : null);
const results = useMemo(() => {
if (!query.trim() || !data) return [];
const q = query.trim().toLowerCase();
@@ -879,21 +913,21 @@ export default function Docs() {
if (version !== null && path.length === 0) {
content =
handbook && handbook.length > 0 ? (
<HandbookView slug={handbook[0].slug} />
<HandbookView slug={handbook[0].slug} pages={handbook} />
) : (
<ReleaseNoteView version={version} />
<ReleaseNoteView version={version} doc={activeRelease} />
);
} else if (section === "home") {
content =
handbook && handbook.length > 0 ? (
<HandbookView slug={handbook[0].slug} />
<HandbookView slug={handbook[0].slug} pages={handbook} />
) : releases && releases.length > 0 ? (
<ReleasesView versions={releases} />
) : (
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
);
} else if (section === "handbook" && param) {
content = <HandbookView slug={param} />;
content = <HandbookView slug={param} pages={handbook} />;
} else if (section === "reference" && param === "endpoints" && path[2]) {
const tag = reference?.tags.find(
(tg) => tg.name.toLowerCase() === path[2].toLowerCase(),
@@ -963,7 +997,7 @@ export default function Docs() {
</div>
);
} else if (section === "releases" && param) {
content = <ReleaseNoteView version={param} />;
content = <ReleaseNoteView version={param} doc={releases?.find((r) => r.version === param) ?? null} />;
} else if (section === "releases") {
content = releases ? <ReleasesView versions={releases} /> : <Skeleton className="h-64 w-full" />;
} else {
@@ -1014,6 +1048,7 @@ export default function Docs() {
</Link>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<LanguageSwitcher />
<ThemeToggle />
</div>
</div>
+5 -1
View File
@@ -7,6 +7,7 @@ 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";
@@ -24,7 +25,10 @@ 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">{t("home.welcome")}</h1>
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
{t("home.welcome")}
<GuideHelp guide="getting-started" label={t("home.welcome")} />
</h1>
<p className="text-muted-foreground">{t("home.tagline")}</p>
</div>
<Button asChild>
+20 -14
View File
@@ -1,5 +1,7 @@
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";
@@ -11,6 +13,7 @@ import { customFetch } from "@workspace/api-client-react";
import { useToast } from "@/hooks/use-toast";
export default function RedundancyPage() {
const { t } = useTranslation();
const [data, setData] = useState<any[] | null>(null);
const [loading, setLoading] = useState(true);
const { toast } = useToast();
@@ -28,10 +31,10 @@ export default function RedundancyPage() {
method: "POST",
body: JSON.stringify({ toolId, relatedToolId, betterToolId }),
});
toast({ title: "Evaluation saved" });
toast({ title: t("redundancy.toastEvalSaved") });
setData(await customFetch<any[]>("/api/admin/redundancy"));
} catch {
toast({ title: "Failed to save evaluation", variant: "destructive" });
toast({ title: t("redundancy.toastEvalFailed"), variant: "destructive" });
}
}
@@ -44,10 +47,13 @@ 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">Tool Analysis & Recommendations</h1>
<h1 className="text-3xl font-bold flex items-center gap-2">
{t("redundancy.title")}
<GuideHelp guide="redundanz" label={t("redundancy.title")} />
</h1>
</div>
<p className="text-muted-foreground">
Automatic redundancy detection with cost and rating comparison. Admin can manually confirm which tool is the better choice.
{t("redundancy.subtitle")}
</p>
{loading ? (
@@ -61,12 +67,12 @@ export default function RedundancyPage() {
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-semibold capitalize">{group.category}</h2>
<p className="text-xs text-muted-foreground mt-0.5">{group.tools.length} tools, {group.pairs.length} comparisons</p>
<p className="text-xs text-muted-foreground mt-0.5">{t("redundancy.toolsComparisons", { tools: group.tools.length, pairs: group.pairs.length })}</p>
</div>
{group.totalMonthlyCost > 0 && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<DollarSign className="w-3 h-3" />
{group.totalMonthlyCost.toFixed(2)}/mo total
{group.totalMonthlyCost.toFixed(2)}{t("redundancy.totalMonthly")}
</span>
)}
</div>
@@ -82,12 +88,12 @@ export default function RedundancyPage() {
<span className="font-medium">{tool.name}</span>
{tool.costs?.length > 0 && tool.totalMonthly > 0 && (
<Badge variant="outline" className="text-[10px] px-1 py-0 shrink-0">
{tool.totalMonthly.toFixed(2)}/mo
{tool.totalMonthly.toFixed(2)}{t("redundancy.perMonth")}
</Badge>
)}
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground flex-wrap">
<span>{tool.ratingCount} reviews</span>
<span>{tool.ratingCount} {t("redundancy.reviews")}</span>
{tool.avgCombined != null && (
<>
<span>·</span>
@@ -104,7 +110,7 @@ export default function RedundancyPage() {
))}
</div>
</div>
<Badge variant="outline" className="shrink-0">{tool.features.length} features</Badge>
<Badge variant="outline" className="shrink-0">{tool.features.length} {t("redundancy.features")}</Badge>
</div>
</CardContent>
</Card>
@@ -114,7 +120,7 @@ export default function RedundancyPage() {
{group.pairs.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground">Comparisons & Recommendations</h3>
<h3 className="text-sm font-medium text-muted-foreground">{t("redundancy.comparisonsTitle")}</h3>
{group.pairs.map((pair: any, i: number) => (
<Card key={i} className={pair.recommendation.certainty === "high" ? "border-green-300" : pair.recommendation.certainty === "medium" ? "border-amber-200" : ""}>
<CardContent className="p-4">
@@ -126,11 +132,11 @@ export default function RedundancyPage() {
</span>
<span className="text-[10px] text-muted-foreground">
{pair.a.avgCombined != null ? pair.a.avgCombined.toFixed(1) : "N/A"}
{pair.a.totalMonthly > 0 ? ` · ${pair.a.totalMonthly.toFixed(2)}/mo` : ""}
{pair.a.totalMonthly > 0 ? ` · ${pair.a.totalMonthly.toFixed(2)}${t("redundancy.perMonth")}` : ""}
</span>
</div>
<div className="text-center shrink-0">
<div className="text-xs text-muted-foreground font-medium">vs</div>
<div className="text-xs text-muted-foreground font-medium">{t("redundancy.vs")}</div>
<div className="flex items-center gap-1 justify-center mt-0.5">
<Progress value={pair.overlap} className="w-12 h-1.5" />
<span className="text-[10px] text-muted-foreground">{pair.overlap}%</span>
@@ -142,7 +148,7 @@ export default function RedundancyPage() {
</span>
<span className="text-[10px] text-muted-foreground">
{pair.b.avgCombined != null ? pair.b.avgCombined.toFixed(1) : "N/A"}
{pair.b.totalMonthly > 0 ? ` · ${pair.b.totalMonthly.toFixed(2)}/mo` : ""}
{pair.b.totalMonthly > 0 ? ` · ${pair.b.totalMonthly.toFixed(2)}${t("redundancy.perMonth")}` : ""}
</span>
</div>
</div>
@@ -188,7 +194,7 @@ export default function RedundancyPage() {
))}
</div>
) : (
<p className="text-muted-foreground py-8 text-center">No tools found.</p>
<p className="text-muted-foreground py-8 text-center">{t("redundancy.noTools")}</p>
)}
</div>
</Layout>
+66 -66
View File
@@ -121,24 +121,24 @@ export default function ToolDetail() {
method: "POST",
body: JSON.stringify({ relatedToolId: Number(linkToolId), relationType: linkType, notes: linkNotes || undefined }),
});
toast({ title: "Relation created" });
toast({ title: t("detail.toastRelationCreated") });
setLinkDialogOpen(false);
setLinkToolId("");
setLinkNotes("");
setLinkType("similar");
setSimilarData(await customFetch<any>(`/api/tools/${id}/similar`));
} catch (err: any) {
toast({ title: "Failed to create relation", description: err.data?.error ?? err.message, variant: "destructive" });
toast({ title: t("detail.toastRelationFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
}
}
async function handleDeleteRelation(relationId: number) {
try {
await customFetch(`/api/tools/relations/${relationId}`, { method: "DELETE" });
toast({ title: "Relation deleted" });
toast({ title: t("detail.toastRelationDeleted") });
setSimilarData(await customFetch<any>(`/api/tools/${id}/similar`));
} catch {
toast({ title: "Failed to delete relation", variant: "destructive" });
toast({ title: t("detail.toastRelationDeleteFailed"), variant: "destructive" });
}
}
@@ -163,12 +163,12 @@ export default function ToolDetail() {
if (costAmount) body.cost = costAmount;
try {
await customFetch(url, { method, body: JSON.stringify(body) });
toast({ title: editCost ? "Cost updated" : "Cost added" });
toast({ title: editCost ? t("detail.toastCostUpdated") : t("detail.toastCostAdded") });
setCostDialogOpen(false);
resetCostForm();
setCosts(await customFetch<any[]>(`/api/tools/${id}/costs`));
} catch (err: any) {
toast({ title: "Failed to save cost", description: err.data?.error ?? err.message, variant: "destructive" });
toast({ title: t("detail.toastCostFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
}
}
@@ -194,10 +194,10 @@ export default function ToolDetail() {
async function handleDeleteCost(costId: number) {
try {
await customFetch(`/api/costs/${costId}`, { method: "DELETE" });
toast({ title: "Cost deleted" });
toast({ title: t("detail.toastCostDeleted") });
setCosts(await customFetch<any[]>(`/api/tools/${id}/costs`));
} catch {
toast({ title: "Failed to delete cost", variant: "destructive" });
toast({ title: t("detail.toastCostDeleteFailed"), variant: "destructive" });
}
}
@@ -237,8 +237,8 @@ export default function ToolDetail() {
createRating.mutate({ id, data }, {
onSuccess: () => {
toast({
title: "Rating submitted",
description: "Thank you for your feedback!",
title: t("detail.toastRatingSubmitted"),
description: t("detail.toastRatingThanks"),
});
setIsReviewFormOpen(false);
form.reset();
@@ -252,8 +252,8 @@ export default function ToolDetail() {
},
onError: (error) => {
toast({
title: "Failed to submit rating",
description: error.data?.error || error.message || "An unexpected error occurred.",
title: t("detail.toastRatingFailed"),
description: error.data?.error || error.message || t("detail.unexpectedError"),
variant: "destructive"
});
}
@@ -264,9 +264,9 @@ export default function ToolDetail() {
return (
<Layout>
<div className="flex flex-col items-center justify-center py-20">
<h2 className="text-2xl font-bold">Invalid Tool ID</h2>
<h2 className="text-2xl font-bold">{t("detail.invalidToolId")}</h2>
<Button variant="link" asChild className="mt-4">
<Link href="/tools"><ArrowLeft className="w-4 h-4 mr-2" /> Back to tools</Link>
<Link href="/tools"><ArrowLeft className="w-4 h-4 mr-2" /> {t("detail.backToBrowse")}</Link>
</Button>
</div>
</Layout>
@@ -288,7 +288,7 @@ export default function ToolDetail() {
{ id },
{
onSuccess: () => {
toast({ title: "Tool deleted" });
toast({ title: t("detail.toastToolDeleted") });
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
@@ -297,7 +297,7 @@ export default function ToolDetail() {
setLocation("/tools");
},
onError: (err) => {
toast({ title: "Failed to delete", description: err.data?.error || err.message, variant: "destructive" });
toast({ title: t("detail.toastDeleteFailed"), description: err.data?.error || err.message, variant: "destructive" });
setDeleteOpen(false);
},
},
@@ -309,7 +309,7 @@ export default function ToolDetail() {
<Layout>
<div className="space-y-6 max-w-5xl mx-auto pb-10">
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
<Link href="/tools"><ArrowLeft className="w-4 h-4 mr-2" /> Back to browse</Link>
<Link href="/tools"><ArrowLeft className="w-4 h-4 mr-2" /> {t("detail.backToBrowse")}</Link>
</Button>
{/* Header Section */}
@@ -408,7 +408,7 @@ export default function ToolDetail() {
{tool.features && tool.features.length > 0 && (
<div className="pt-6 border-t">
<h3 className="text-lg font-semibold mb-3">Key Features</h3>
<h3 className="text-lg font-semibold mb-3">{t("detail.keyFeatures")}</h3>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{tool.features.map((feature, i) => (
<li key={i} className="flex items-start gap-2">
@@ -423,17 +423,17 @@ export default function ToolDetail() {
)}
</div>
) : (
<div className="text-center py-10">Tool not found.</div>
<div className="text-center py-10">{t("detail.toolNotFound")}</div>
)}
{/* Similar Tools Section */}
{tool && (
<div className="space-y-4 mt-8">
<div className="flex items-center justify-between">
<h3 className="text-xl font-bold">Similar Tools</h3>
<h3 className="text-xl font-bold">{t("detail.similarTools")}</h3>
{isAdmin && (
<Button variant="outline" size="sm" onClick={() => setLinkDialogOpen(true)} className="gap-2">
<LinkIcon className="w-4 h-4" /> Link Tool
<LinkIcon className="w-4 h-4" /> {t("detail.linkTool")}
</Button>
)}
</div>
@@ -501,7 +501,7 @@ export default function ToolDetail() {
</>
)}
<span>·</span>
<span>Score: {item.score}</span>
<span>{t("detail.score")}: {item.score}</span>
</div>
</div>
</CardContent>
@@ -510,7 +510,7 @@ export default function ToolDetail() {
))}
</div>
) : similarData && similarData.manual.length === 0 && similarData.auto.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">No similar tools found.</p>
<p className="text-sm text-muted-foreground py-4">{t("detail.noSimilarTools")}</p>
) : null}
</div>
)}
@@ -519,44 +519,44 @@ export default function ToolDetail() {
<Dialog open={linkDialogOpen} onOpenChange={setLinkDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Link Similar Tool</DialogTitle>
<DialogDescription>Manually link this tool to another tool.</DialogDescription>
<DialogTitle>{t("detail.linkSimilarTool")}</DialogTitle>
<DialogDescription>{t("detail.linkSimilarToolSub")}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium">Tool ID</label>
<label className="text-sm font-medium">{t("detail.toolId")}</label>
<Input
type="number"
placeholder="Enter target tool ID"
placeholder={t("detail.toolIdPlaceholder")}
value={linkToolId}
onChange={(e) => setLinkToolId(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Relation Type</label>
<label className="text-sm font-medium">{t("detail.relationType")}</label>
<Select value={linkType} onValueChange={setLinkType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="similar">Similar</SelectItem>
<SelectItem value="replaces">Replaces</SelectItem>
<SelectItem value="superseded_by">Superseded By</SelectItem>
<SelectItem value="similar">{t("detail.relationSimilar")}</SelectItem>
<SelectItem value="replaces">{t("detail.relationReplaces")}</SelectItem>
<SelectItem value="superseded_by">{t("detail.relationSupersededBy")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Notes (Optional)</label>
<label className="text-sm font-medium">{t("detail.notesOptional")}</label>
<Textarea
placeholder="Why are these tools related?"
placeholder={t("detail.notesPlaceholder")}
value={linkNotes}
onChange={(e) => setLinkNotes(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setLinkDialogOpen(false)}>Cancel</Button>
<Button onClick={handleCreateRelation} disabled={!linkToolId}>Create Link</Button>
<Button variant="outline" onClick={() => setLinkDialogOpen(false)}>{t("common.cancel")}</Button>
<Button onClick={handleCreateRelation} disabled={!linkToolId}>{t("detail.createLink")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -587,7 +587,7 @@ export default function ToolDetail() {
{c.billingPeriod && <span className="text-[10px] text-muted-foreground uppercase">{c.billingPeriod}</span>}
</div>
<div className="text-lg font-bold">
{c.cost != null ? `${c.cost} ${c.currency ?? ""}` : "Free"}
{c.cost != null ? `${c.cost} ${c.currency ?? ""}` : t("detail.licenseFree")}
</div>
{c.notes && <p className="text-xs text-muted-foreground mt-1 italic">{c.notes}</p>}
</div>
@@ -607,7 +607,7 @@ export default function ToolDetail() {
))}
</div>
) : (
<p className="text-sm text-muted-foreground py-4">No cost information added yet.</p>
<p className="text-sm text-muted-foreground py-4">{t("detail.noCostInfo")}</p>
)}
</div>
)}
@@ -620,41 +620,41 @@ export default function ToolDetail() {
{editCost ? t("detail.edit") + " " + t("detail.costs") : t("detail.addCost")}
<GuideHelp guide="kosten" label={t("detail.costs")} />
</DialogTitle>
<DialogDescription>Manage license cost information for this tool.</DialogDescription>
<DialogDescription>{t("detail.costDialogSub")}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium">License Type</label>
<label className="text-sm font-medium">{t("detail.licenseType")}</label>
<Select value={costLicenseType} onValueChange={setCostLicenseType}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="subscription">Subscription</SelectItem>
<SelectItem value="one_time">One-Time</SelectItem>
<SelectItem value="usage_based">Usage-Based</SelectItem>
<SelectItem value="free">{t("detail.licenseFree")}</SelectItem>
<SelectItem value="subscription">{t("detail.licenseSubscription")}</SelectItem>
<SelectItem value="one_time">{t("detail.licenseOneTime")}</SelectItem>
<SelectItem value="usage_based">{t("detail.licenseUsageBased")}</SelectItem>
</SelectContent>
</Select>
</div>
{costLicenseType === "subscription" && (
<div className="space-y-2">
<label className="text-sm font-medium">Billing Period</label>
<label className="text-sm font-medium">{t("detail.billingPeriod")}</label>
<Select value={costBillingPeriod} onValueChange={setCostBillingPeriod}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="quarterly">Quarterly</SelectItem>
<SelectItem value="yearly">Yearly</SelectItem>
<SelectItem value="monthly">{t("detail.billingMonthly")}</SelectItem>
<SelectItem value="quarterly">{t("detail.billingQuarterly")}</SelectItem>
<SelectItem value="yearly">{t("detail.billingYearly")}</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Cost</label>
<label className="text-sm font-medium">{t("detail.costLabel")}</label>
<Input type="number" step="0.01" placeholder="0.00" value={costAmount} onChange={(e) => setCostAmount(e.target.value)} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Currency</label>
<label className="text-sm font-medium">{t("detail.currency")}</label>
<Select value={costCurrency} onValueChange={setCostCurrency}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
@@ -667,13 +667,13 @@ export default function ToolDetail() {
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Notes</label>
<Textarea placeholder="Billing details, contract info..." value={costNotes} onChange={(e) => setCostNotes(e.target.value)} />
<label className="text-sm font-medium">{t("detail.costNotes")}</label>
<Textarea placeholder={t("detail.costNotesPlaceholder")} value={costNotes} onChange={(e) => setCostNotes(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCostDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSaveCost}>Save</Button>
<Button variant="outline" onClick={() => setCostDialogOpen(false)}>{t("common.cancel")}</Button>
<Button onClick={handleSaveCost}>{t("common.save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -744,9 +744,9 @@ export default function ToolDetail() {
<YAxis domain={[0, 5]} tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} axisLine={false} tickLine={false} />
<Tooltip labelFormatter={(_, payload) => (payload?.[0] ? format(new Date(payload[0].payload.date), "dd.MM.yyyy HH:mm") : "")} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line type="monotone" dataKey="combined" name="Combined" stroke="hsl(var(--primary))" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="usefulness" name="Usefulness" stroke="hsl(var(--success))" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
<Line type="monotone" dataKey="usability" name="Usability" stroke="hsl(var(--warning))" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
<Line type="monotone" dataKey="combined" name={t("detail.score")} stroke="hsl(var(--primary))" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="usefulness" name={t("detail.usefulness")} stroke="hsl(var(--success))" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
<Line type="monotone" dataKey="usability" name={t("detail.usability")} stroke="hsl(var(--warning))" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
</LineChart>
</ResponsiveContainer>
</div>
@@ -773,7 +773,7 @@ export default function ToolDetail() {
{t("detail.addReview")}
<GuideHelp guide="bewerten" label={t("detail.addReview")} />
</CardTitle>
<CardDescription>Share your experience with {tool.name}</CardDescription>
<CardDescription>{t("detail.shareExperience", { name: tool.name })}</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
@@ -829,12 +829,12 @@ export default function ToolDetail() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Comment (Optional)
<FieldHelp schema="RatingInput" field="comment">Comment</FieldHelp>
{t("detail.commentOptional")}
<FieldHelp schema="RatingInput" field="comment">{t("detail.commentLabel")}</FieldHelp>
</FormLabel>
<FormControl>
<Textarea
placeholder="What do you think about this tool?"
placeholder={t("detail.commentPlaceholder")}
className="resize-none min-h-[100px]"
{...field}
/>
@@ -850,11 +850,11 @@ export default function ToolDetail() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Name (Optional)
<FieldHelp schema="RatingInput" field="reviewerName">Name</FieldHelp>
{t("detail.nameOptional")}
<FieldHelp schema="RatingInput" field="reviewerName">{t("detail.nameLabel")}</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder="Anonymous" {...field} />
<Input placeholder={t("detail.anonymousPlaceholder")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -868,7 +868,7 @@ export default function ToolDetail() {
onClick={() => setIsReviewFormOpen(false)}
disabled={createRating.isPending}
>
Cancel
{t("common.cancel")}
</Button>
<Button type="submit" disabled={createRating.isPending}>
{createRating.isPending ? t("common.loading") : t("detail.submit")}
@@ -891,7 +891,7 @@ export default function ToolDetail() {
<CardContent className="p-6">
<div className="flex justify-between items-start mb-4">
<div>
<span className="font-semibold">{rating.reviewerName || "Anonymous Engineer"}</span>
<span className="font-semibold">{rating.reviewerName || t("detail.anonymousEngineer")}</span>
<span className="text-muted-foreground text-sm ml-2">
{format(new Date(rating.createdAt), "MMM d, yyyy")}
</span>
@@ -921,7 +921,7 @@ export default function ToolDetail() {
<div className="text-center py-12 bg-muted/30 border border-dashed rounded-xl">
<Star className="w-12 h-12 text-muted-foreground/30 mx-auto mb-3" />
<h4 className="text-lg font-medium">{t("detail.noReviews")}</h4>
<p className="text-muted-foreground mt-1">Be the first to share your thoughts on this tool.</p>
<p className="text-muted-foreground mt-1">{t("detail.beFirstToReview")}</p>
</div>
)}
</div>
+54 -48
View File
@@ -32,28 +32,34 @@ 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";
const toolSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
description: z.string().min(10, "Description must be at least 10 characters"),
category: z.string().min(2, "Category is required"),
websiteUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
iconUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
features: z.array(z.object({ value: z.string() })).optional(),
tags: z.array(z.object({ value: z.string() })).optional(),
});
type ToolFormValues = z.infer<ReturnType<typeof buildToolSchema>>;
type ToolFormValues = z.infer<typeof toolSchema>;
function buildToolSchema(t: (key: string) => string) {
return z.object({
name: z.string().min(2, t("toolForm.nameMin")),
description: z.string().min(10, t("toolForm.descriptionMin")),
category: z.string().min(2, t("toolForm.categoryRequired")),
websiteUrl: z.string().url(t("toolForm.invalidUrl")).optional().or(z.literal("")),
iconUrl: z.string().url(t("toolForm.invalidUrl")).optional().or(z.literal("")),
features: z.array(z.object({ value: z.string() })).optional(),
tags: z.array(z.object({ value: z.string() })).optional(),
});
}
export default function ToolEdit() {
const [match, params] = useRoute("/tools/:id/edit");
const [, setLocation] = useLocation();
const id = parseInt(params?.id || "0", 10);
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const updateTool = useUpdateTool();
const { isAuthenticated, isAdmin } = useAuth();
const toolSchema = buildToolSchema(t);
const { data: tool, isLoading } = useGetTool(id, {
query: { enabled: !!id, queryKey: getGetToolQueryKey(id) },
});
@@ -108,7 +114,7 @@ export default function ToolEdit() {
{ id, data: payload },
{
onSuccess: () => {
toast({ title: "Tool updated", description: "Changes saved successfully." });
toast({ title: t("toolForm.toastUpdated"), description: t("toolForm.toastUpdatedSub") });
queryClient.invalidateQueries({ queryKey: getGetToolQueryKey(id) });
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
@@ -120,8 +126,8 @@ export default function ToolEdit() {
},
onError: (err) => {
toast({
title: "Failed to update tool",
description: err.data?.error || err.message || "An unexpected error occurred.",
title: t("toolForm.toastUpdateFailed"),
description: err.data?.error || err.message || t("detail.unexpectedError"),
variant: "destructive",
});
},
@@ -138,13 +144,13 @@ export default function ToolEdit() {
<div className="max-w-3xl mx-auto space-y-6 pb-10">
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
<Link href={`/tools/${id}`}>
<ArrowLeft className="w-4 h-4 mr-2" /> Back to tool
<ArrowLeft className="w-4 h-4 mr-2" /> {t("toolForm.backToTool")}
</Link>
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Edit Tool</h1>
<p className="text-muted-foreground">Update tool details and metadata.</p>
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("toolForm.editTitle")}</h1>
<p className="text-muted-foreground">{t("toolForm.editSubtitle")}</p>
</div>
{isLoading ? (
@@ -160,10 +166,10 @@ export default function ToolEdit() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Pencil className="w-5 h-5 text-primary" />
Tool Details
<GuideHelp guide="tool-bearbeiten" label="Tool Details" />
{t("toolForm.toolDetails")}
<GuideHelp guide="tool-bearbeiten" label={t("toolForm.toolDetails")} />
</CardTitle>
<CardDescription>Modify the tool information below.</CardDescription>
<CardDescription>{t("toolForm.toolDetailsEditSub")}</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
@@ -175,11 +181,11 @@ export default function ToolEdit() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Name
<FieldHelp schema="ToolInput" field="name">Name</FieldHelp>
{t("toolForm.name")}
<FieldHelp schema="ToolInput" field="name">{t("toolForm.name")}</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder="Tool name" {...field} />
<Input placeholder={t("toolForm.name")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -191,8 +197,8 @@ export default function ToolEdit() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Category
<FieldHelp schema="ToolInput" field="category">Category</FieldHelp>
{t("toolForm.category")}
<FieldHelp schema="ToolInput" field="category">{t("toolForm.category")}</FieldHelp>
</FormLabel>
<FormControl>
<CategoryCombobox value={field.value} onChange={field.onChange} />
@@ -209,11 +215,11 @@ export default function ToolEdit() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Website URL (Optional)
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
{t("toolForm.websiteUrlOptional")}
<FieldHelp schema="ToolInput" field="websiteUrl">{t("toolForm.websiteUrl")}</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder="https://..." type="url" {...field} />
<Input placeholder={t("toolForm.urlPlaceholder")} type="url" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -226,8 +232,8 @@ export default function ToolEdit() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Icon / Logo URL (Optional)
<FieldHelp schema="ToolInput" field="iconUrl">Icon / Logo URL</FieldHelp>
{t("toolForm.iconUrlOptional")}
<FieldHelp schema="ToolInput" field="iconUrl">{t("toolForm.iconUrl")}</FieldHelp>
</FormLabel>
<FormControl>
<div className="flex items-center gap-3">
@@ -235,16 +241,16 @@ export default function ToolEdit() {
{field.value ? (
<img
src={field.value}
alt="icon preview"
alt={t("toolForm.iconPreview")}
className="w-full h-full object-contain p-0.5"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<span className="text-xs text-muted-foreground">img</span>
<span className="text-xs text-muted-foreground">{t("toolForm.name").charAt(0)}</span>
)}
</div>
<Input
placeholder="https://example.com/logo.png"
placeholder={t("toolForm.logoPlaceholder")}
type="url"
{...field}
className="flex-1"
@@ -262,12 +268,12 @@ export default function ToolEdit() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Description
<FieldHelp schema="ToolInput" field="description">Description</FieldHelp>
{t("toolForm.description")}
<FieldHelp schema="ToolInput" field="description">{t("toolForm.description")}</FieldHelp>
</FormLabel>
<FormControl>
<Textarea
placeholder="What does this tool do?"
placeholder={t("toolForm.descriptionPlaceholderEdit")}
className="min-h-[120px] resize-none"
{...field}
/>
@@ -281,13 +287,13 @@ export default function ToolEdit() {
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
Features
<FieldHelp schema="ToolInput" field="features">Features</FieldHelp>
{t("toolForm.features")}
<FieldHelp schema="ToolInput" field="features">{t("toolForm.features")}</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">Key capabilities of this tool. Existing features from other tools are selectable.</p>
<p className="text-sm text-muted-foreground">{t("toolForm.featuresEditSub")}</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => appendFeature({ value: "" })}>
<Plus className="w-4 h-4 mr-2" /> Add Feature
<Plus className="w-4 h-4 mr-2" /> {t("toolForm.addFeature")}
</Button>
</div>
<div className="space-y-3">
@@ -302,7 +308,7 @@ export default function ToolEdit() {
<FeatureInput
value={field.value ?? ""}
onChange={field.onChange}
placeholder="e.g. Real-time collaboration"
placeholder={t("toolForm.featurePlaceholder")}
/>
</FormControl>
<Button
@@ -319,7 +325,7 @@ export default function ToolEdit() {
/>
))}
{featureFields.length === 0 && (
<p className="text-sm text-muted-foreground italic">No features added.</p>
<p className="text-sm text-muted-foreground italic">{t("toolForm.noFeatures")}</p>
)}
</div>
</div>
@@ -328,13 +334,13 @@ export default function ToolEdit() {
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
Tags
<FieldHelp schema="ToolInput" field="tags">Tags</FieldHelp>
{t("toolForm.tags")}
<FieldHelp schema="ToolInput" field="tags">{t("toolForm.tags")}</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">Keywords for this tool. Existing tags from other tools are selectable.</p>
<p className="text-sm text-muted-foreground">{t("toolForm.tagsEditSub")}</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => appendTag({ value: "" })}>
<Plus className="w-4 h-4 mr-2" /> Add Tag
<Plus className="w-4 h-4 mr-2" /> {t("toolForm.addTag")}
</Button>
</div>
<div className="flex flex-wrap gap-2">
@@ -347,7 +353,7 @@ export default function ToolEdit() {
<FormItem className="flex items-center space-y-0 relative w-[200px]">
<FormControl>
<TagInput
placeholder="Tag"
placeholder={t("toolForm.tag")}
className="pr-8 h-9 text-sm"
onChange={field.onChange}
value={field.value ?? ""}
@@ -371,10 +377,10 @@ export default function ToolEdit() {
<div className="pt-6 border-t flex justify-end gap-3">
<Button type="button" variant="outline" asChild>
<Link href={`/tools/${id}`}>Cancel</Link>
<Link href={`/tools/${id}`}>{t("common.cancel")}</Link>
</Button>
<Button type="submit" disabled={updateTool.isPending || !isAuthenticated}>
{updateTool.isPending ? "Saving" : "Save Changes"}
{updateTool.isPending ? t("toolForm.saving") : t("toolForm.saveChanges")}
</Button>
</div>
</form>
+55 -49
View File
@@ -20,26 +20,32 @@ 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";
const toolSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
description: z.string().min(10, "Description must be at least 10 characters"),
category: z.string().min(2, "Category is required"),
websiteUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
iconUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
features: z.array(z.object({ value: z.string() })).optional(),
tags: z.array(z.object({ value: z.string() })).optional(),
});
type ToolFormValues = z.infer<ReturnType<typeof buildToolSchema>>;
type ToolFormValues = z.infer<typeof toolSchema>;
function buildToolSchema(t: (key: string) => string) {
return z.object({
name: z.string().min(2, t("toolForm.nameMin")),
description: z.string().min(10, t("toolForm.descriptionMin")),
category: z.string().min(2, t("toolForm.categoryRequired")),
websiteUrl: z.string().url(t("toolForm.invalidUrl")).optional().or(z.literal("")),
iconUrl: z.string().url(t("toolForm.invalidUrl")).optional().or(z.literal("")),
features: z.array(z.object({ value: z.string() })).optional(),
tags: z.array(z.object({ value: z.string() })).optional(),
});
}
export default function ToolNew() {
const [location, setLocation] = useLocation();
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const createTool = useCreateTool();
const { isAuthenticated, isLoading: authLoading, login } = useAuth();
const toolSchema = buildToolSchema(t);
const form = useForm<ToolFormValues>({
resolver: zodResolver(toolSchema),
defaultValues: {
@@ -76,7 +82,7 @@ export default function ToolNew() {
{ data: payload },
{
onSuccess: (newTool) => {
toast({ title: "Tool added successfully", description: "Your tool is now available for review." });
toast({ title: t("toolForm.toastAdded"), description: t("toolForm.toastAddedSub") });
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
@@ -86,7 +92,7 @@ export default function ToolNew() {
setLocation(`/tools/${newTool.id}`);
},
onError: () => {
toast({ title: "Failed to add tool", description: "An unexpected error occurred.", variant: "destructive" });
toast({ title: t("toolForm.toastAddFailed"), description: t("detail.unexpectedError"), variant: "destructive" });
},
},
);
@@ -97,24 +103,24 @@ export default function ToolNew() {
<div className="max-w-3xl mx-auto space-y-6 pb-10">
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
<Link href="/tools">
<ArrowLeft className="w-4 h-4 mr-2" /> Back to browse
<ArrowLeft className="w-4 h-4 mr-2" /> {t("toolForm.backToBrowse")}
</Link>
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Add a New Tool</h1>
<p className="text-muted-foreground">Submit a tool you use to let the community rate and review it.</p>
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("toolForm.addTitle")}</h1>
<p className="text-muted-foreground">{t("toolForm.addSubtitle")}</p>
</div>
{!authLoading && !isAuthenticated && (
<div className="flex items-center gap-4 rounded-md border border-primary/20 bg-primary/5 px-4 py-3">
<LogIn className="w-5 h-5 text-primary shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Sign in required</p>
<p className="text-xs text-muted-foreground">You must be signed in to submit a tool.</p>
<p className="text-sm font-medium">{t("toolForm.signInRequired")}</p>
<p className="text-xs text-muted-foreground">{t("toolForm.signInRequiredSub")}</p>
</div>
<Button size="sm" onClick={() => login(location)} data-testid="button-login-prompt">
Sign in
{t("auth.signIn")}
</Button>
</div>
)}
@@ -123,10 +129,10 @@ export default function ToolNew() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Wrench className="w-5 h-5 text-primary" />
Tool Details
<GuideHelp guide="tool-anlegen" label="Tool Details" />
{t("toolForm.toolDetails")}
<GuideHelp guide="tool-anlegen" label={t("toolForm.toolDetails")} />
</CardTitle>
<CardDescription>Provide the basic information about the tool.</CardDescription>
<CardDescription>{t("toolForm.toolDetailsNewSub")}</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
@@ -138,11 +144,11 @@ export default function ToolNew() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Name
<FieldHelp schema="ToolInput" field="name">Name</FieldHelp>
{t("toolForm.name")}
<FieldHelp schema="ToolInput" field="name">{t("toolForm.name")}</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder="e.g. React, Next.js, Postgres" {...field} data-testid="input-tool-name" />
<Input placeholder={t("toolForm.namePlaceholder")} {...field} data-testid="input-tool-name" />
</FormControl>
<FormMessage />
</FormItem>
@@ -155,8 +161,8 @@ export default function ToolNew() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Category
<FieldHelp schema="ToolInput" field="category">Category</FieldHelp>
{t("toolForm.category")}
<FieldHelp schema="ToolInput" field="category">{t("toolForm.category")}</FieldHelp>
</FormLabel>
<FormControl>
<CategoryCombobox
@@ -176,11 +182,11 @@ export default function ToolNew() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Website URL (Optional)
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
{t("toolForm.websiteUrlOptional")}
<FieldHelp schema="ToolInput" field="websiteUrl">{t("toolForm.websiteUrl")}</FieldHelp>
</FormLabel>
<FormControl>
<Input placeholder="https://..." type="url" {...field} data-testid="input-tool-url" />
<Input placeholder={t("toolForm.urlPlaceholder")} type="url" {...field} data-testid="input-tool-url" />
</FormControl>
<FormMessage />
</FormItem>
@@ -193,8 +199,8 @@ export default function ToolNew() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Icon / Logo URL (Optional)
<FieldHelp schema="ToolInput" field="iconUrl">Icon / Logo URL</FieldHelp>
{t("toolForm.iconUrlOptional")}
<FieldHelp schema="ToolInput" field="iconUrl">{t("toolForm.iconUrl")}</FieldHelp>
</FormLabel>
<FormControl>
<div className="flex items-center gap-3">
@@ -202,16 +208,16 @@ export default function ToolNew() {
{field.value ? (
<img
src={field.value}
alt="icon preview"
alt={t("toolForm.iconPreview")}
className="w-full h-full object-contain p-0.5"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<span className="text-xs text-muted-foreground">img</span>
<span className="text-xs text-muted-foreground">{t("toolForm.name").charAt(0)}</span>
)}
</div>
<Input
placeholder="https://example.com/logo.png"
placeholder={t("toolForm.logoPlaceholder")}
type="url"
{...field}
data-testid="input-tool-icon-url"
@@ -230,12 +236,12 @@ export default function ToolNew() {
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center gap-1.5">
Description
<FieldHelp schema="ToolInput" field="description">Description</FieldHelp>
{t("toolForm.description")}
<FieldHelp schema="ToolInput" field="description">{t("toolForm.description")}</FieldHelp>
</FormLabel>
<FormControl>
<Textarea
placeholder="What does this tool do? Why do people use it?"
placeholder={t("toolForm.descriptionPlaceholderNew")}
className="min-h-[120px] resize-none"
{...field}
data-testid="input-tool-description"
@@ -250,10 +256,10 @@ export default function ToolNew() {
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
Features
<FieldHelp schema="ToolInput" field="features">Features</FieldHelp>
{t("toolForm.features")}
<FieldHelp schema="ToolInput" field="features">{t("toolForm.features")}</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">List key capabilities. Existing features from other tools are selectable.</p>
<p className="text-sm text-muted-foreground">{t("toolForm.featuresNewSub")}</p>
</div>
<Button
type="button"
@@ -262,7 +268,7 @@ export default function ToolNew() {
onClick={() => appendFeature({ value: "" })}
data-testid="button-add-feature"
>
<Plus className="w-4 h-4 mr-2" /> Add Feature
<Plus className="w-4 h-4 mr-2" /> {t("toolForm.addFeature")}
</Button>
</div>
@@ -278,7 +284,7 @@ export default function ToolNew() {
<FeatureInput
value={field.value ?? ""}
onChange={field.onChange}
placeholder="e.g. Real-time collaboration"
placeholder={t("toolForm.featurePlaceholder")}
data-testid={`input-feature-${index}`}
/>
</FormControl>
@@ -297,7 +303,7 @@ export default function ToolNew() {
/>
))}
{featureFields.length === 0 && (
<p className="text-sm text-muted-foreground italic">No features added.</p>
<p className="text-sm text-muted-foreground italic">{t("toolForm.noFeatures")}</p>
)}
</div>
</div>
@@ -306,10 +312,10 @@ export default function ToolNew() {
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
Tags
<FieldHelp schema="ToolInput" field="tags">Tags</FieldHelp>
{t("toolForm.tags")}
<FieldHelp schema="ToolInput" field="tags">{t("toolForm.tags")}</FieldHelp>
</h3>
<p className="text-sm text-muted-foreground">Keywords to help find this tool. Existing tags from other tools are selectable.</p>
<p className="text-sm text-muted-foreground">{t("toolForm.tagsHelp")}</p>
</div>
<Button
type="button"
@@ -318,7 +324,7 @@ export default function ToolNew() {
onClick={() => appendTag({ value: "" })}
data-testid="button-add-tag"
>
<Plus className="w-4 h-4 mr-2" /> Add Tag
<Plus className="w-4 h-4 mr-2" /> {t("toolForm.addTag")}
</Button>
</div>
@@ -332,7 +338,7 @@ export default function ToolNew() {
<FormItem className="flex items-center space-y-0 relative w-[200px]">
<FormControl>
<TagInput
placeholder="Tag"
placeholder={t("toolForm.tagPlaceholder")}
className="pr-8 h-9 text-sm"
onChange={field.onChange}
value={field.value ?? ""}
@@ -363,7 +369,7 @@ export default function ToolNew() {
className="w-full sm:w-auto"
data-testid="button-submit-tool"
>
{createTool.isPending ? "Adding Tool..." : "Submit Tool"}
{createTool.isPending ? t("toolForm.addingTool") : t("toolForm.submitTool")}
</Button>
</div>
</form>
+15 -8
View File
@@ -6,6 +6,7 @@ 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";
@@ -230,7 +231,10 @@ 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">{t("nav.browseTools")}</h1>
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
{t("nav.browseTools")}
<GuideHelp guide="tools-finden" label={t("browse.subtitle")} />
</h1>
<p className="text-muted-foreground">{t("browse.subtitle")}</p>
</div>
<Button asChild>
@@ -362,7 +366,7 @@ export default function ToolsBrowse() {
<button
onClick={() => setFeatures(features.filter((x) => x !== f))}
className="rounded-sm hover:bg-muted p-0.5"
aria-label={`Remove feature ${f}`}
aria-label={t("filter.removeFeature", { feature: f })}
>
<X className="w-3.5 h-3.5" />
</button>
@@ -374,14 +378,14 @@ export default function ToolsBrowse() {
<button
onClick={() => setMinRating(null)}
className="rounded-sm hover:bg-muted p-0.5"
aria-label="Remove min rating"
aria-label={t("filter.removeMinRating")}
>
<X className="w-3.5 h-3.5" />
</button>
</Badge>
)}
<Button variant="link" size="sm" className="px-1 text-muted-foreground" onClick={clearFilters}>
Clear all
{t("common.clearAll")}
</Button>
</div>
)}
@@ -521,15 +525,18 @@ export default function ToolsBrowse() {
<AlertDialog open={compareUpsellOpen} onOpenChange={setCompareUpsellOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Compare is a Premium feature</AlertDialogTitle>
<AlertDialogTitle className="flex items-center gap-2">
{t("browse.comparePremiumTitle")}
<GuideHelp guide="plaene" label={t("browse.comparePremiumTitle")} />
</AlertDialogTitle>
<AlertDialogDescription>
Comparing tools side-by-side is available to Premium and Enterprise users. Upgrade your plan to unlock it.
{t("browse.comparePremiumSub")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Not now</AlertDialogCancel>
<AlertDialogCancel>{t("browse.notNow")}</AlertDialogCancel>
<AlertDialogAction asChild>
<a href="/admin?tab=plan">Upgrade to Premium</a>
<a href="/admin?tab=plan">{t("browse.upgradePremium")}</a>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+34 -31
View File
@@ -11,11 +11,11 @@ import {
getListAllTagsQueryKey,
getGetTopToolsQueryKey,
getGetAnalyticsSummaryQueryKey,
type Tool,
} from "@workspace/api-client-react";
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";
@@ -91,12 +91,12 @@ export default function Trash() {
{ data: { ids } },
{
onSuccess: (res) => {
toast({ title: "Tools restored", description: `${res.restored ?? ids.length} tool(s) restored.` });
toast({ title: t("trash.toastRestored"), description: t("trash.toastRestoredSub", { count: res.restored ?? ids.length }) });
setSelected(new Set());
invalidate();
},
onError: (err) => {
toast({ title: "Failed to restore", description: err.data?.error ?? err.message, variant: "destructive" });
toast({ title: t("trash.toastRestoreFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
},
},
);
@@ -107,13 +107,13 @@ export default function Trash() {
{ data: { ids } },
{
onSuccess: () => {
toast({ title: "Tools deleted", description: `${ids.length} tool(s) permanently removed.` });
toast({ title: t("trash.toastDeleted"), description: t("trash.toastDeletedSub", { count: ids.length }) });
setSelected(new Set());
setConfirmDelete(false);
invalidate();
},
onError: (err) => {
toast({ title: "Failed to delete", description: err.data?.error ?? err.message, variant: "destructive" });
toast({ title: t("trash.toastDeleteFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
},
},
);
@@ -124,13 +124,13 @@ export default function Trash() {
undefined,
{
onSuccess: (res) => {
toast({ title: "Trash emptied", description: `${res.deleted ?? 0} tool(s) permanently removed.` });
toast({ title: t("trash.toastEmptied"), description: t("trash.toastEmptiedSub", { count: res.deleted ?? 0 }) });
setSelected(new Set());
setConfirmEmpty(false);
invalidate();
},
onError: (err) => {
toast({ title: "Failed to empty trash", description: err.data?.error ?? err.message, variant: "destructive" });
toast({ title: t("trash.toastEmptyFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
},
},
);
@@ -157,7 +157,10 @@ export default function Trash() {
<Layout>
<div className="space-y-6 pb-10">
<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 flex items-center gap-2">
{t("trash.title")}
<GuideHelp guide="papierkorb" label={t("trash.title")} />
</h1>
<p className="text-muted-foreground">{t("trash.subtitle")}</p>
</div>
@@ -227,48 +230,48 @@ export default function Trash() {
<Checkbox
checked={selected.size === trashed.length && trashed.length > 0}
onCheckedChange={toggleAll}
aria-label="Select all"
aria-label={t("trash.selectAll")}
/>
</TableHead>
<TableHead>Name</TableHead>
<TableHead>Category</TableHead>
<TableHead>{t("trash.name")}</TableHead>
<TableHead>{t("trash.category")}</TableHead>
<TableHead>{t("trash.deletedAt")}</TableHead>
<TableHead>{t("trash.deletedBy")}</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead className="text-right">{t("trash.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{trashed.map((t: Tool) => (
<TableRow key={t.id} className={selected.has(t.id) ? "bg-muted/40" : undefined}>
{trashed.map((tool) => (
<TableRow key={tool.id} className={selected.has(tool.id) ? "bg-muted/40" : undefined}>
<TableCell>
<Checkbox
checked={selected.has(t.id)}
onCheckedChange={() => toggle(t.id)}
aria-label={`Select ${t.name}`}
checked={selected.has(tool.id)}
onCheckedChange={() => toggle(tool.id)}
aria-label={t("trash.selectName", { name: tool.name })}
/>
</TableCell>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell className="font-medium">{tool.name}</TableCell>
<TableCell>
<Badge variant="outline">{t.category}</Badge>
<Badge variant="outline">{tool.category}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{t.deletedAt ? format(new Date(t.deletedAt), "dd.MM.yyyy HH:mm") : "—"}
{tool.deletedAt ? format(new Date(tool.deletedAt), "dd.MM.yyyy HH:mm") : "—"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{t.deletedBy ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{tool.deletedBy ?? "—"}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => handleRestore([t.id])} disabled={restore.isPending}>
<RotateCcw className="w-3.5 h-3.5" /> Restore
<Button variant="ghost" size="sm" onClick={() => handleRestore([tool.id])} disabled={restore.isPending}>
<RotateCcw className="w-3.5 h-3.5" /> {t("trash.restoreAction")}
</Button>
{isAdmin && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => handleDeletePermanent([t.id])}
onClick={() => handleDeletePermanent([tool.id])}
disabled={deletePermanent.isPending}
>
<TrashIcon className="w-3.5 h-3.5" /> Delete
<TrashIcon className="w-3.5 h-3.5" /> {t("trash.deleteAction")}
</Button>
)}
</div>
@@ -285,13 +288,13 @@ export default function Trash() {
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {selectedIds.length} tool(s) permanently?</AlertDialogTitle>
<AlertDialogTitle>{t("trash.deleteConfirmTitle", { count: selectedIds.length })}</AlertDialogTitle>
<AlertDialogDescription>
This permanently removes the selected tools along with all their ratings, costs and relations. This cannot be undone.
{t("trash.deleteConfirmSub")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => handleDeletePermanent(selectedIds)}
@@ -305,13 +308,13 @@ export default function Trash() {
<AlertDialog open={confirmEmpty} onOpenChange={setConfirmEmpty}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Empty the trash?</AlertDialogTitle>
<AlertDialogTitle>{t("trash.emptyConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
This permanently removes all {trashed.length} tool(s) in the trash along with their ratings, costs and relations. This cannot be undone.
{t("trash.emptyConfirmSub", { count: trashed.length })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleEmpty}
+1 -1
View File
@@ -62,7 +62,7 @@ export default function Watchlist() {
{t("watchlist.emptySub")}
</p>
<Button variant="outline" asChild>
<a href="/tools">Browse tools</a>
<a href="/tools">{t("common.browseTools")}</a>
</Button>
</div>
) : (
+65
View File
@@ -0,0 +1,65 @@
---
title: Administration
order: 13
---
# Administration
The **Admin** area (`/admin`) is exclusively accessible to admins.
Without the admin role, access is denied.
> At the top right, the **Redundancy dashboard** button leads to the automatic
> duplicate detection (see [Redundancy](/docs/handbook/redundanz)).
## "Users" tab
Management of local accounts.
- **Add user:** username (required), password (at least 6 characters),
email (optional), **role** (User/Admin), **plan** (Free/Premium/Enterprise).
- **Edit user:** set role, plan and (for local accounts) a new password.
For OIDC accounts, password management is offered in the identity provider
(e.g. Keycloak).
- **Delete user:** permanently removes the account (not for your own account).
API reference:
[`POST /users`](/docs/reference/endpoints/users#createUser),
[`PATCH /users/{id}`](/docs/reference/endpoints/users#updateUser),
[`DELETE /users/{id}`](/docs/reference/endpoints/users#deleteUser).
## "Tools" tab
Central access to the tool catalog.
- **Search** for tools.
- View, edit or move individual tools 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
and can be restored or permanently deleted).
## "Audit log" tab
Chronological log of all creation, change and deletion operations
(max. 100 entries): action, entity + ID, timestamp, executing person and
changed fields.
API reference: [`GET /audit-logs`](/docs/reference/endpoints/audit#listAuditLogs).
## "System" tab
Version information of the running instance:
- **Version** (e.g. `v0.8.1`),
- **Commit** (7-digit SHA, linked to the repository),
- **Build date**,
- **Trash retention** ("N days" or "Forever").
## Tool links (Admin)
On the detail page of a tool you can manage **links** as an admin
(own/"manual" as well as automatically detected ones):
- **Link tool:** dialog with tool ID, **relationship type**
(Similar / Replaces / Superseded by) and optional notes.
- Relationship types are displayed as badges on the detail page.
- Manual links can be removed again via the trash icon.
+33
View File
@@ -0,0 +1,33 @@
---
title: Analytics
order: 10
---
# Analytics
The **Analytics** area (`/analytics`) is a public dashboard with
metrics and charts based on all tools and ratings.
## Metrics (KPI cards)
- **Number of tools** — how many tools are recorded in the catalog.
- **Number of ratings** — how many ratings were submitted in total.
- **Active categories** — how many categories exist.
- **Average rating** — global combined value.
## Charts
| Chart | Content |
| --- | --- |
| **Top 8 tools** | Bar chart of the tools with the highest combined score (05) |
| **Tools per category** | Radar chart of the number of tools per category |
| **Score distribution** | Two horizontal bar charts (usefulness & usability) per star |
The charts are interactive (tooltips on hover).
## API
- [`GET /analytics/summary`](/docs/reference/endpoints/analytics#getAnalyticsSummary)
- [`GET /analytics/top-tools`](/docs/reference/endpoints/analytics#getTopTools)
- [`GET /analytics/by-category`](/docs/reference/endpoints/analytics#getAnalyticsByCategory)
- [`GET /analytics/rating-distribution`](/docs/reference/endpoints/analytics#getRatingDistribution)
+43
View File
@@ -0,0 +1,43 @@
---
title: Rating
order: 7
---
# Rating
On the detail page of a tool you can share your experience. Click on
**Submit a rating** (requires an account).
## Form fields
| Field | Required | Notes |
| --- | --- | --- |
| **Usefulness** | Yes | 15 stars |
| **Usability** | Yes | 15 stars |
| **Comment** | No | Free text |
| **Name** | No | Defaults to "Anonymous" |
Next to the fields, the **? icon** links directly to the associated field
description in the [data model reference](/docs/reference/schemas/ratinginput).
## What happens after submitting?
- Your rating is saved immediately and appears in the **rating list** of the
detail page.
- The **averages** (usefulness, usability, combined) and the **score
distribution** are updated.
- The **statistics** in the [Analytics](/docs/handbook/analytics) section are
recalculated.
## Statistic sections on the detail page
- **Rating overview:** usefulness & usability as an average with progress
bars.
- **Score distribution:** number of ratings per star (1★–5★).
- **History:** line chart of combined/individual values over time
(only visible once there are several ratings).
## API
- [`POST /tools/{id}/ratings`](/docs/reference/endpoints/ratings#createRating) — Submit a rating
- [`GET /tools/{id}/ratings`](/docs/reference/endpoints/ratings#listToolRatings) — Ratings of a tool
+74
View File
@@ -0,0 +1,74 @@
---
title: Data model
order: 17
---
# Data model
This chapter explains the central data objects of toolr at the application
level. The complete, automatically generated reference of all fields,
types and constraints can be found in the
[API reference](/docs/reference/schemas/tool).
## Tool
The heart of it all: a tool recorded in the catalog.
| Property | Description |
| --- | --- |
| `id` | Unique identifier |
| `name` | Display name |
| `description` | Description (what does the tool do?) |
| `category` | Category assignment |
| `websiteUrl` | Official website (optional) |
| `iconUrl` | Logo/icon URL (optional) |
| `features` | List of capabilities |
| `tags` | List of keywords |
| `createdAt` / `updatedAt` | Timestamps |
| `createdBy` | Person who created it |
| `deletedAt` / `deletedBy` | Soft deletion (trash) |
Input forms use the derived schemas
[`ToolInput`](/docs/reference/schemas/toolinput) and
[`ToolUpdate`](/docs/reference/schemas/toolupdate).
Aggregated views are provided by [`ToolWithStats`](/docs/reference/schemas/toolwithstats)
(e.g. with average rating).
## Rating (Bewertung)
A single rating for a tool:
- `usefulness` (Nützlichkeit, 15) and `usability` (Bedienbarkeit, 15)
- optional `comment` and a display name (`reviewerName`)
- timestamp
Input schema: [`RatingInput`](/docs/reference/schemas/ratinginput).
## User & Auth
- [`User`](/docs/reference/schemas/user) — user account with role (User/Admin)
and plan (Free/Premium/Enterprise).
- [`AuthUser`](/docs/reference/schemas/authuser) — the logged-in profile
including `entitlements` (available features).
- [`UserPreferences`](/docs/reference/schemas/userpreferences) — view and
density preferences as well as the `watchlist` (list of tool IDs).
## Analytics
The statistics endpoints provide aggregated data:
- [`AnalyticsSummary`](/docs/reference/schemas/analyticssummary) — global
metrics (number of tools/ratings, categories, average).
- [`TopToolEntry`](/docs/reference/schemas/TopToolEntry) — an entry of the
top tools.- [`CategoryStats`](/docs/reference/schemas/categorystats) — number of tools per
category.
- [`RatingDistribution`](/docs/reference/schemas/ratingdistribution) —
score distribution (usefulness & usability).
- [`ScoreBucket`](/docs/reference/schemas/scorebucket) — a value bucket.
## Additional
- [`VersionInfo`](/docs/reference/schemas/versioninfo) — version, commit SHA,
build date and trash retention of the running instance.
- [`AuditLog`](/docs/reference/schemas/auditlog) — a log entry
(action, entity, timestamp, actor, changes).
+57
View File
@@ -0,0 +1,57 @@
---
title: Getting Started
order: 2
---
# Getting Started
This page guides you through the most important workflows in toolr — from your
first visit to creating and rating a tool.
## 1. Sign in
Most actions (create a tool, rate, watchlist, compare) require
an account. Click **Sign in** in the bottom left corner. Depending on the
instance configuration you have two options:
- **Local accounts:** username + password. Access is created by an admin
(see [Administration](/docs/handbook/administration)).
- **OIDC (SSO):** sign in with the configured identity provider (e.g.
Keycloak).
Which mode is active is shown by the
[`GET /auth/mode`](/docs/reference/endpoints/auth#getAuthMode) endpoint. Details
can be found in the [Sign in & account](/docs/handbook/konto) section.
## 2. Find tools
Open the **Browse tools** section:
- **Search** — full-text search across name & description (shortcut `/`).
- **Filter** — by category, tags, features and minimum rating
(`minRating`).
- **Sort** — by newest, top-rated, most rated, name
(ascending/descending) or last update.
All options in detail: [Find & browse tools](/docs/handbook/tools-finden).
## 3. Create a tool
Go to **Add tool** and fill in the form. Details for each field can be found
in the [Create a tool](/docs/handbook/tool-anlegen) section and in the
[field reference](/docs/reference/schemas/toolinput).
## 4. Rate
On the detail page of a tool you can submit **usefulness** and **usability**
(15 each) and optionally leave a comment. Your
rating is immediately reflected in the statistics.
See [Rating](/docs/handbook/bewerten).
## 5. Further reading
- [Compare tools](/docs/handbook/vergleichen)
- [Watchlist](/docs/handbook/watchlist)
- [Analytics](/docs/handbook/analytics)
- [Plans & permissions](/docs/handbook/plaene)
- [Administration](/docs/handbook/administration)
+2 -2
View File
@@ -10,7 +10,7 @@ Besuch bis zum Anlegen und Bewerten eines Tools.
## 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
Instanz hast du zwei Möglichkeiten:
@@ -51,7 +51,7 @@ Siehe [Bewerten](/docs/handbook/bewerten).
## 5. Weiterführend
- [Tools vergleichen](/docs/handbook/vergleichen)
- [Watchlist](/docs/handbook/watchlist)
- [Merkliste](/docs/handbook/watchlist)
- [Analytics](/docs/handbook/analytics)
- [Pläne & Berechtigungen](/docs/handbook/plaene)
- [Administration](/docs/handbook/administration)
+55
View File
@@ -0,0 +1,55 @@
---
title: Overview
order: 1
---
# Welcome to toolr
toolr is a platform for **discovering, rating and comparing development
tools**. Users maintain a shared catalog of tools, submit ratings
(usefulness & usability) and use statistics to make the right choice.
## What can you do with toolr?
| Function | Description | Visibility |
| --- | --- | --- |
| **Browse tools** | Filter, sort and search the catalog | Everyone |
| **Create a tool** | Add a new tool with description, category, features & tags | Logged in |
| **Edit/delete tools** | Maintain your own tools (creator or admin) | Logged in |
| **Rate** | Submit usefulness & usability (15) plus a comment | Logged in |
| **Watchlist** | Save tools as favorites | Premium |
| **Compare** | View tools side by side | Premium |
| **Record costs** | Enter license and cost models per tool | Premium |
| **Analytics** | Statistics, top tools, distributions | Everyone |
| **Trash** | Restore soft-deleted tools or delete them permanently | Premium |
| **Admin** | User management, audit log, system information | Admin |
| **Redundancy detection** | Automatic duplicate detection | Admin |
## How this documentation is organized
- **User Guide** (these pages): step-by-step instructions for all
functions — from the [Getting Started](/docs/handbook/getting-started) to
[Administration](/docs/handbook/administration).
- **API Reference**: automatically generated from the OpenAPI specification —
all [endpoints](/docs/reference/endpoints/tools) and
[data fields](/docs/reference/schemas/toolinput) of the current version.
- **Release Notes**: what is new in each [version](/docs/releases/v0.8.1).
## Getting started
The fastest way:
1. **Sign in** — without an account you can only browse
(see [Getting Started](/docs/handbook/getting-started#1-anmelden)).
2. **Find tools** — search, filters and sorting in the
[Browse tools](/docs/handbook/tools-finden) section.
3. **Create a tool** — via "Add tool"
([guide](/docs/handbook/tool-anlegen)).
4. **Rate** — on the detail page of a tool
([guide](/docs/handbook/bewerten)).
## Contact & source code
The source code is available at
[git.kubebase.de/admin/tool-evaluator](https://git.kubebase.de/admin/tool-evaluator) —
you can reach it at any time via the repository icon in the top right corner.
+1 -1
View File
@@ -18,7 +18,7 @@ die richtige Wahl zu treffen.
| **Tool anlegen** | Neues Tool mit Beschreibung, Kategorie, Features & Tags eintragen | Angemeldet |
| **Tool bearbeiten/löschen** | Eigene Tools pflegen (Ersteller:in oder Admin) | Angemeldet |
| **Bewerten** | Nützlichkeit & Bedienbarkeit (15) plus Kommentar vergeben | Angemeldet |
| **Watchlist** | Tools als Favoriten speichern | Premium |
| **Merkliste** | Tools als Favoriten speichern | Premium |
| **Vergleichen** | Tools nebeneinander gegenüberstellen | Premium |
| **Kosten erfassen** | Lizenz- und Kostenmodelle je Tool eintragen | Premium |
| **Analytics** | Statistiken, Top-Tools, Verteilungen | Alle |
+56
View File
@@ -0,0 +1,56 @@
---
title: Login & Account
order: 3
---
# Login & Account
## Logging in
Click **Login** at the bottom left of the sidebar. Depending on the
configuration of the instance:
- **Local accounts:** enter username and password. The accounts are created
by an admin (see [Administration](/docs/handbook/administration)).
- **OIDC (SSO):** you are redirected to the configured identity provider and
log in there.
The active mode is available at the endpoint
[`GET /auth/mode`](/docs/reference/endpoints/auth#getAuthMode).
> You can reach the login page directly at `/login`. After a successful
> login you are redirected back to the page you originally requested.
## User profile
You can see your profile (avatar, name, email, plan) at the bottom left in
the user menu. There you have the following actions available:
- **Watchlist** — your saved tools (only with the corresponding plan).
- **Trash** — restorable, deleted tools (Premium/Enterprise).
- **Change password** — directly in toolr for local accounts; for OIDC
accounts, password management is offered in the identity provider.
- **Logout** — ends your session.
## Changing your password (local account)
1. Open the user menu at the bottom left.
2. Select **Change password**.
3. Enter the **current** and a **new** password (min. 6 characters) and
confirm it.
4. Save — the password takes effect immediately.
API reference: [`POST /auth/me/password`](/docs/reference/endpoints/auth#changeMyPassword).
## Display settings
Using the buttons at the top right you can:
- switch the **language** (German / English),
- toggle the **theme** (Light / Dark / System),
- adjust the **list view** and **density** in the Browse tools section
(see [Finding & browsing tools](/docs/handbook/tools-finden)).
Your preferences (incl. watchlist) are saved at the endpoint
[`GET /auth/me/preferences`](/docs/reference/endpoints/auth#getMePreferences)
and updated via [`PUT /auth/me/preferences`](/docs/reference/endpoints/auth#updateMePreferences).
+2 -2
View File
@@ -26,7 +26,7 @@ Der aktive Modus steht im Endpunkt
Dein Profil (Avatar, Name, E-Mail, Tarif) siehst du unten links im
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).
- **Passwort ändern** — für lokale Konten direkt in toolr; für OIDC-Konten wird
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
(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)
gespeichert und über [`PUT /auth/me/preferences`](/docs/reference/endpoints/auth#updateMePreferences)
aktualisiert.
+39
View File
@@ -0,0 +1,39 @@
---
title: Recording Costs
order: 12
---
# Recording Costs
On the detail page of a tool you can enter cost and license models so that
the total costs per tool become transparent.
> Costs is a **premium feature** (`costs`, Premium/Enterprise). Admins always
> have access.
## Adding costs
Click **Add costs** in the costs section of the detail page and fill out the
form:
| Field | Notes |
| --- | --- |
| **License type** | Free / Subscription / One-Time / Usage-Based |
| **Billing period** | Only for "Subscription": Monthly / Quarterly / Annually |
| **Costs** | Amount as a number |
| **Currency** | EUR / USD / GBP / CHF |
| **Notes** | Optional free text |
Saving creates the entry. Each cost entry is displayed as a card with
license badge, billing period, amount (`Amount Currency` or "Free") and
notes.
## Editing & deleting costs
Hovering over a cost card shows the **Edit** (pencil) and **Delete** (trash)
actions.
## API
The cost data is managed via the tool endpoints
(see [API reference](/docs/reference/endpoints/tools)).
+44
View File
@@ -0,0 +1,44 @@
---
title: Trash
order: 15
---
# Trash
The **trash** (`/trash`) contains soft-deleted tools. With trash access
they can be restored; permanent deletion is reserved for admins.
> The trash is a **premium feature** (`trash`, Premium/Enterprise).
> Admins always have access.
## Access
The trash can be reached via the user menu or the sidebar.
Without the `trash` permission, a hint about changing the plan appears.
## Restoring
- Select one or more tools (checkboxes).
- Click on **Restore (N)** — the tools appear again in all
public views.
> Restoring is available to anyone with trash access.
## Permanently delete (admin only)
- **Delete (N)** **permanently** removes the selected tools — including
all ratings, costs and links. This cannot be undone.
- **Empty trash** permanently removes all soft-deleted tools.
## Table
The trash lists: name, category, **Deleted on** (`tt.MM.jjjj HH:mm`),
**Deleted by** as well as actions (Restore; Delete admin only). The search
filters by name.
## API
- [`GET /tools/trash`](/docs/reference/endpoints/tools#listTrashedTools) — list
- [`POST /tools/trash/restore`](/docs/reference/endpoints/tools#restoreTools) — restore
- [`DELETE /tools/trash`](/docs/reference/endpoints/tools#deleteTrashedTools) — permanently delete (admin)
- [`POST /tools/trash/empty`](/docs/reference/endpoints/tools#emptyTrash) — empty trash (admin)
+45
View File
@@ -0,0 +1,45 @@
---
title: Plans & Permissions
order: 11
---
# Plans & Permissions
toolr distinguishes **plans** (tier) and **roles**. Admins bypass all feature
restrictions.
## Plans
| Plan | Description |
| --- | --- |
| **Free** | Basic functions: search, filter, view, analytics |
| **Premium** | Additionally watchlist, compare, trash, costs |
| **Enterprise** | All premium features + extended support |
### Feature permissions
Premium/Enterprise unlock the following features:
| Feature | Function | Learn more |
| --- | --- | --- |
| `compare` | Compare tools | [Compare](/docs/handbook/vergleichen) |
| `watchlist` | Favorites list | [Watchlist](/docs/handbook/watchlist) |
| `trash` | Trash (soft-deleted tools) | [Trash](/docs/handbook/papierkorb) |
| `costs` | Record cost/license models | [Recording costs](/docs/handbook/kosten) |
If you are missing a feature, the app shows an **upgrade notice** with a link
to the plan management.
## Roles
| Role | Permissions |
| --- | --- |
| **User** | Standard account: create/rate tools, edit your own tools |
| **Admin** | All user rights + administration, audit log, redundancy, empty trash, tool links |
Admins pass **all** feature checks — even without a premium plan.
## Plan/role management
The assignment of role and plan is managed by admins in the
[Administration](/docs/handbook/administration) section (tab "Users").
+2 -2
View File
@@ -13,7 +13,7 @@ Feature-Beschränkungen.
| Tarif | Beschreibung |
| --- | --- |
| **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 |
### Feature-Berechtigungen
@@ -23,7 +23,7 @@ Premium/Enterprise schalten folgende Features frei:
| Feature | Funktion | Mehr erfahren |
| --- | --- | --- |
| `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) |
| `costs` | Kosten-/Lizenzmodelle erfassen | [Kosten erfassen](/docs/handbook/kosten) |
+42
View File
@@ -0,0 +1,42 @@
---
title: Redundancy dashboard
order: 14
---
# Redundancy dashboard
The **Redundancy dashboard** (`/admin/redundancy`) is an admin tool for
automatic detection of duplicate or strongly overlapping tools — per
category — including cost and rating comparison.
> Access is reserved exclusively for admins (the API is
> admin-protected).
## Layout
- **Per category** a group is shown: name of the category,
number of tools and comparisons as well as the **total monthly costs**
if applicable (e.g. `€X.XX/mo total`).
- Each tool is displayed as a card: name, monthly costs, number of
ratings, combined rating, license badges and number of features.
## Comparisons & recommendations
For each tool pair the following appears:
- Tool A vs. Tool B, each with rating (`X.X ★`) and monthly costs.
- **Overlap** in percent (progress bar in the middle).
- A **recommendation** with confidence color:
- **high** (green), **medium** (yellow), **low** (gray)
- The recommended, better tool is marked with a "thumbs up" and justified.
## Manual rating
You can rate a pair manually: click on Tool A or Tool B to
record which one is better. The selection is saved and the
display is updated.
## API
- [`GET /api/admin/redundancy`](#) — load data (admin-protected)
- [`POST /api/admin/redundancy/evaluate`](#) — save manual rating
+40
View File
@@ -0,0 +1,40 @@
---
title: Keyboard shortcuts & command palette
order: 16
---
# Keyboard shortcuts & command palette
## Command palette
The command palette is the central quick navigation:
- Open with **`⌘K`** (macOS) or **`Ctrl+K`** (Windows/Linux).
- Alternatively via the search bar at the top right ("Search tools… ⌘K") or the
search icon on mobile devices.
### Empty state
Without input, the palette shows:
- **Recently viewed** — the last 5 tools you visited.
- **Navigation** — browse tools, add tool, analytics as well as
(depending on permissions) watchlist, trash and admin.
### Search
Type to search for tools live (max. 10 results, incl. rating
`X.X★`).
## Overview of keyboard shortcuts
| Shortcut | Action |
| --- | --- |
| `⌘K` / `Ctrl+K` | Open command palette |
| `/` | Focus search in the "Browse tools" area |
## Additional notes
- **Recently viewed** is stored locally in the browser (max. 5 entries).
- The sidebar (left navigation) can be collapsed on desktop; the
breadcrumb at the top shows your current location.
+1 -1
View File
@@ -19,7 +19,7 @@ Ohne Eingabe zeigt die Palette:
- **Zuletzt angesehen** — die letzten 5 Tools, die du besucht hast.
- **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
+50
View File
@@ -0,0 +1,50 @@
---
title: Create a tool
order: 5
---
# Create a tool
To add a new tool to the catalog, click **Add tool**
(`/tools/new`). Creating a tool requires an account — without being signed in
a notice with a login button appears.
## Form fields
| Field | Required | Notes |
| --- | --- | --- |
| **Name** | Yes | At least 2 characters |
| **Category** | Yes | Dropdown; new categories can be created directly |
| **Website URL** | No | Valid URL (e.g. `https://...`) |
| **Icon / Logo URL** | No | Valid URL; preview is shown live |
| **Description** | Yes | At least 10 characters; describe what the tool does |
| **Features** | No | Dynamic list with autocomplete (max. 6) |
| **Tags** | No | Dynamic list with autocomplete |
Next to each field, the **? icon** takes you directly to the corresponding
field description in the [data model reference](/docs/reference/schemas/toolinput).
### Category
- Type to search for existing categories.
- Select **+ Create "..."** to create a new category.
### Features & tags
- **Add feature** / **Add tag** appends a new row.
- The input fields suggest existing features/tags
(autocomplete, max. 6 suggestions).
- Use the **×** button to remove individual rows.
- Features and tags help with filtering and finding tools again.
## Save
Click **Add tool**. After successful creation you will be redirected to the
detail page of the new tool.
## API
- [`POST /tools`](/docs/reference/endpoints/tools#createTool) — create a tool
- [`GET /categories`](/docs/reference/endpoints/tools#listCategories) — categories
- [`GET /features/all`](/docs/reference/endpoints/tools#listAllFeatures) — features
- [`GET /tags/all`](/docs/reference/endpoints/tools#listAllTags) — tags
+36
View File
@@ -0,0 +1,36 @@
---
title: Edit & delete tools
order: 6
---
# Edit & delete tools
## Editing
On the detail page of a tool you will find the **Edit** button
(only for the person who created the tool, as well as for admins).
The edit page (`/tools/:id/edit`) contains the same fields as when
creating (name, category, website/icon URL, description, features, tags) —
already filled with the current values.
- **Save** applies the changes.
- **Cancel** takes you back to the detail page.
API reference: [`PATCH /tools/{id}`](/docs/reference/endpoints/tools#updateTool).
## Deleting
Via **Delete** on the detail page the tool is removed. The behavior
depends on your plan:
- **With trash access** (Premium/Enterprise or Admin): the tool is
**soft deleted** — it disappears from all public views, but can
be restored or permanently deleted in the [trash](/docs/handbook/papierkorb).
- **Without trash access:** the tool is **permanently** deleted and cannot
be restored.
Deletion is only possible for the person who created the tool, as well as
for admins.
API reference: [`DELETE /tools/{id}`](/docs/reference/endpoints/tools#deleteTool).
+70
View File
@@ -0,0 +1,70 @@
---
title: Find & browse tools
order: 4
---
# Find & browse tools
The **Browse tools** section (`/tools`) is your entry point to the catalog.
Here you combine search, filters and sorting to find exactly the tools
you are interested in.
## Search
- The **search bar** searches name and description (full text).
- Shortcut: Press **`/`** to focus the search.
- The input is debounced so that filtering happens immediately with each
keystroke.
## Filter
Via the **Filter** button (with a badge for the number of active filters)
you open the filter popover with:
- **Tags** — selection via checkboxes (scrollable list).
- **Features** — selection via checkboxes.
- **Minimum rating** — slider from 0 to 5 (in steps of 0.5); shows
e.g. "3.0+".
Active filters appear as **removable chips** above the result list.
Use **Reset filters** or **Remove all** to clear them again.
## Sort
The **Sort** dropdown offers the following options:
| Sort | Description |
| --- | --- |
| Newest | New tools first |
| Top rated | By combined rating |
| Most rated | By number of ratings |
| Name (AZ) | Alphabetically ascending |
| Name (ZA) | Alphabetically descending |
| Last updated | By last update |
## View & density
- **Switch view:** grid / table / rows.
- **Density:** comfortable / compact (slider).
Your selection is saved — locally in the browser and, for logged-in users,
additionally on the server in the preferences. View, density, search, filters
and sorting are reflected in the URL so you can share results.
## Table view
In the table view the columns **Tool**, **Rating** and **Number of
ratings** are sortable. Hovering over a row shows a preview
with rating details, tags and mini bars.
## Selecting for comparison & watchlist
- On every card/row you find a **compare icon** that lets you add tools to the
[compare bar](/docs/handbook/vergleichen).
- The **bookmark icon** saves tools to your
[watchlist](/docs/handbook/watchlist) (only with the corresponding plan).
## API
All search, filter and sort parameters correspond to the query parameters of
[`GET /tools`](/docs/reference/endpoints/tools#listTools).
+2 -2
View File
@@ -58,12 +58,12 @@ In der Tabellenansicht sind die Spalten **Tool**, **Bewertung** und **Anzahl
Bewertungen** sortierbar. Beim Überfahren einer Zeile erscheint eine Vorschau
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
[Vergleichsleiste](/docs/handbook/vergleichen) hinzufügst.
- 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
+46
View File
@@ -0,0 +1,46 @@
---
title: Compare
order: 9
---
# Compare
With the compare function you can put several tools **side by side**
ideal for making a well-informed decision.
> Comparing is a **premium feature** (Premium/Enterprise) and is always
> available to admins.
## Selecting tools
1. In the **Browse tools** section, click the **compare icon** (scales) on
each card/row.
2. The **compare bar** appears at the bottom with the selected tools as
chips. You can remove individual tools (×) or clear the selection.
3. Click **Compare (N)** to go to the compare view.
> Without a premium plan the button is locked (lock icon). The dialog takes
> you to the plan switch
> (see [Plans & permissions](/docs/handbook/plaene)).
## The compare view
The view shows a table with one column per tool. Rows:
| Row | Content |
| --- | --- |
| **Rating** | Stars + value (e.g. `4.2/5`) |
| **Usefulness** | Value (X.X/5) |
| **Usability** | Value (X.X/5) |
| **Number of ratings** | Count |
| **Description** | Text |
| **Features** | Badges |
| **Tags** | Badges |
| **Last updated** | Date |
The **best value** per row is highlighted (with trophy icon).
## API
The compare view reads the data via
[`GET /compare`](/docs/reference/endpoints/tools#listCompareTools).
+37
View File
@@ -0,0 +1,37 @@
---
title: Watchlist
order: 8
---
# Watchlist
The **watchlist** is a personal favorites list. You can open and compare the
tools in it at any time with a click.
> The watchlist is a **premium feature** (Premium/Enterprise) and is always
> available to admins.
## Prerequisite
You need a plan with the `watchlist` permission. If it is missing, a note
about switching plans appears at the bookmark
(see [Plans & permissions](/docs/handbook/plaene)).
## Saving a tool
- On every card/row in the **Browse tools** section you will find the
**bookmark icon**.
- A click saves the tool to your watchlist — the icon becomes filled.
- Clicking it again removes it.
## Viewing the watchlist
Open the watchlist via the user menu or the sidebar. It shows all saved tools
as cards. The filled bookmark on a card removes the tool from the list.
## Where is the watchlist stored?
The watchlist is a list of tool IDs in your **user preferences**. This way it
is linked to your account across devices.
API reference: [`GET /auth/me/watchlist`](/docs/reference/endpoints/auth#getMeWatchlist).
+10 -10
View File
@@ -1,14 +1,14 @@
---
title: Watchlist
title: Merkliste
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.
> 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.
## Voraussetzung
@@ -21,18 +21,18 @@ beim Lesezeichen ein Hinweis auf den Tarifwechsel
- Auf jeder Karte/Zeile im Bereich **Tools durchsuchen** findest du das
**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.
## 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
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.
API-Referenz: [`GET /auth/me/watchlist`](/docs/reference/endpoints/auth#getMeWatchlist).
API-Referenz: [`GET /auth/me/watchlist`](/docs/reference/endpoints/auth#getMeMerkliste).
+37
View File
@@ -0,0 +1,37 @@
# v0.6.0 — Release Notes
**Date:** 2026-08-03 · **Tag:** [`v0.6.0`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.6.0)
## New features
- Complete modernization of all dependencies to the current major versions
(TypeScript 7, Vite 8, React 19.2, openid-client 6, zod 4, recharts 3, react-day-picker 10).
## Fixes & improvements
- CI build fixed via `allowBuilds` configuration for pnpm 11
(build scripts for esbuild & Co. are no longer blocked).
- Image tagging simplified: only `latest` and `v*` tags, no more `nightly-*`/`sha-*` tags.
- All dependencies pinned exactly; automatic updates via Renovate prepared
(`renovate.json`, `docs/dependency-policy.md`).
## API changes
- No breaking changes to the API. openid-client internally migrated to v6
(auth flow behaves identically).
## Operations / upgrade
- **Env vars:** unchanged. Node image pinned to `node:24.18.1-alpine`.
- **Migration:** no database migration required.
- **Breaking changes:** none.
## Known limitations
- `typedoc` (indirect orval dependency) shows a peer-dependency warning
(expects TypeScript 5.x/6.x, 7.x is installed) — harmless for build and runtime.
## Links
- Commit: [`2f66fff`](https://git.kubebase.de/admin/tool-evaluator/commit/2f66fff)
- Tag: [`v0.6.0`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.6.0)
+35
View File
@@ -0,0 +1,35 @@
# v0.7.0 — Release Notes
**Date:** 2026-08-03 · **Tag:** [`v0.7.0`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.7.0)
## New features
- Version-bound **release documentation** in the app under `/docs`
(index + detail page per version, Markdown from `docs/releases/`).
- Generated release template (`docs/releases/TEMPLATE.md`) and
sync step for the frontend build.
## Fixes & improvements
- `tsx` bumped to 4.23.4 — last outdated dependency in the workspace
(`pnpm outdated -r` is now empty).
## API changes
- No breaking changes to the API.
## Operations / upgrade
- **Env vars:** unchanged.
- **Migration:** none.
- **Breaking changes:** none.
## Known limitations
- The documentation is currently limited to release notes; a complete
API/field reference will follow in v0.8.0.
## Links
- Commit: [`520f917`](https://git.kubebase.de/admin/tool-evaluator/commit/520f917)
- Tag: [`v0.7.0`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.7.0)
+46
View File
@@ -0,0 +1,46 @@
# v0.8.0 — Release Notes
**Date:** 2026-08-03 · **Tag:** [`v0.8.0`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.0)
## New features
- **Complete documentation site** in mkdocs look under `/docs`:
- **Manual** with understandable explanations of all features
(Getting started, creating a tool, evaluations, comparing, watchlist,
analytics, administration, data model).
- **Automatically generated reference** from `lib/api-spec/openapi.yaml`:
all endpoints and data fields (type, required status, constraints) —
this guarantees that *every* feature is documented.
- **Search** across the manual, endpoints, and fields.
- **Version dropdown**: older releases keep their full field/endpoint
reference as a snapshot.
- **Repo link** to the source in the top right.
- **Help buttons (?) in forms** (NetBox style): next to each field,
an icon jumps directly to the field description in the docs.
## Fixes & improvements
- Docs generator `scripts/src/generate-docs.mjs` replaces the previous
`sync-release-docs.mjs` (OpenAPI parsing, manual, search index, snapshots).
- Documentation for v0.7.0 backfilled.
## API changes
- No breaking changes to the API.
## Operations / upgrade
- **Env vars:** unchanged.
- **Migration:** none.
- **Breaking changes:** none.
## Known limitations
- Manual & reference apply to the current version; older versions
show their release notes and a reference snapshot, if generated at
release time (`node scripts/src/generate-docs.mjs --snapshot vX.Y.Z`).
## Links
- Commit: [`6c92b63`](https://git.kubebase.de/admin/tool-evaluator/commit/6c92b63)
- Tag: [`v0.8.0`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.0)
+1 -1
View File
@@ -6,7 +6,7 @@
- **Vollständige Dokumentations-Site** in mkdocs-Optik unter `/docs`:
- **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).
- **Automatisch generierte Referenz** aus `lib/api-spec/openapi.yaml`:
alle Endpunkte und Datenfelder (Typ, Pflichtstatus, Constraints) —
+47
View File
@@ -0,0 +1,47 @@
# v0.8.1 — Release Notes
**Date:** 2026-08-03 · **Tag:** [`v0.8.1`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.1)
## New features
- **Standalone docs page**: The documentation is now available as its own,
mkdocs-like page at `toolr.kubebase.de/docs` — without the app shell
(own header with repo link, version dropdown, search, theme toggle
and link back to the app).
- **Navigation renamed**: The sidebar entry is now called **"Help"**
and leads to the standalone docs.
- **User guide completely revised**: 17 manual pages with
step-by-step instructions for all features (finding tools, creating a
tool, evaluating, watchlist, comparing, costs, analytics, plans,
administration, redundancy, trash, keyboard shortcuts, data model).
## Fixes & improvements
- Reference links are now case-insensitive
(schema/endpoint slugs such as `toolinput` and `ToolInput` both work).
- Manual links to endpoint anchors corrected (PascalCase operation IDs).
- Outdated, broken manual links (`vergleichen`, `watchlist` …) replaced.
- Documentation table headers and notice texts in the docs page
internationalized via i18n (de/en).
## API changes
- No changes to the API.
## Operations / upgrade
- **Env vars:** unchanged.
- **Migration:** none.
- **Breaking changes:** none. The docs page is reachable under `/docs` as
before; only the presentation is now standalone.
## Known limitations
- Manual & reference apply to the current version; older versions show
their release notes and a reference snapshot, if generated at release time
(`node scripts/src/generate-docs.mjs --snapshot vX.Y.Z`).
## Links
- Commit: [`fdf2d74`](https://git.kubebase.de/admin/tool-evaluator/commit/fdf2d74)
- Tag: [`v0.8.1`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.1)
+1 -1
View File
@@ -12,7 +12,7 @@
und führt zur Standalone-Doku.
- **User Guide komplett überarbeitet**: 17 Handbuch-Seiten mit
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).
## Fixes & Verbesserungen
@@ -0,0 +1,61 @@
# Administration
The **Admin** area (`/admin`) is exclusively accessible to admins.
Without the admin role, access is denied.
> At the top right, the **Redundancy dashboard** button leads to the automatic
> duplicate detection (see [Redundancy](/docs/handbook/redundanz)).
## "Users" tab
Management of local accounts.
- **Add user:** username (required), password (at least 6 characters),
email (optional), **role** (User/Admin), **plan** (Free/Premium/Enterprise).
- **Edit user:** set role, plan and (for local accounts) a new password.
For OIDC accounts, password management is offered in the identity provider
(e.g. Keycloak).
- **Delete user:** permanently removes the account (not for your own account).
API reference:
[`POST /users`](/docs/reference/endpoints/users#createUser),
[`PATCH /users/{id}`](/docs/reference/endpoints/users#updateUser),
[`DELETE /users/{id}`](/docs/reference/endpoints/users#deleteUser).
## "Tools" tab
Central access to the tool catalog.
- **Search** for tools.
- View, edit or move individual tools 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
and can be restored or permanently deleted).
## "Audit log" tab
Chronological log of all creation, change and deletion operations
(max. 100 entries): action, entity + ID, timestamp, executing person and
changed fields.
API reference: [`GET /audit-logs`](/docs/reference/endpoints/audit#listAuditLogs).
## "System" tab
Version information of the running instance:
- **Version** (e.g. `v0.8.1`),
- **Commit** (7-digit SHA, linked to the repository),
- **Build date**,
- **Trash retention** ("N days" or "Forever").
## Tool links (Admin)
On the detail page of a tool you can manage **links** as an admin
(own/"manual" as well as automatically detected ones):
- **Link tool:** dialog with tool ID, **relationship type**
(Similar / Replaces / Superseded by) and optional notes.
- Relationship types are displayed as badges on the detail page.
- Manual links can be removed again via the trash icon.
@@ -0,0 +1,29 @@
# Analytics
The **Analytics** area (`/analytics`) is a public dashboard with
metrics and charts based on all tools and ratings.
## Metrics (KPI cards)
- **Number of tools** — how many tools are recorded in the catalog.
- **Number of ratings** — how many ratings were submitted in total.
- **Active categories** — how many categories exist.
- **Average rating** — global combined value.
## Charts
| Chart | Content |
| --- | --- |
| **Top 8 tools** | Bar chart of the tools with the highest combined score (05) |
| **Tools per category** | Radar chart of the number of tools per category |
| **Score distribution** | Two horizontal bar charts (usefulness & usability) per star |
The charts are interactive (tooltips on hover).
## API
- [`GET /analytics/summary`](/docs/reference/endpoints/analytics#getAnalyticsSummary)
- [`GET /analytics/top-tools`](/docs/reference/endpoints/analytics#getTopTools)
- [`GET /analytics/by-category`](/docs/reference/endpoints/analytics#getAnalyticsByCategory)
- [`GET /analytics/rating-distribution`](/docs/reference/endpoints/analytics#getRatingDistribution)
@@ -0,0 +1,39 @@
# Rating
On the detail page of a tool you can share your experience. Click on
**Submit a rating** (requires an account).
## Form fields
| Field | Required | Notes |
| --- | --- | --- |
| **Usefulness** | Yes | 15 stars |
| **Usability** | Yes | 15 stars |
| **Comment** | No | Free text |
| **Name** | No | Defaults to "Anonymous" |
Next to the fields, the **? icon** links directly to the associated field
description in the [data model reference](/docs/reference/schemas/ratinginput).
## What happens after submitting?
- Your rating is saved immediately and appears in the **rating list** of the
detail page.
- The **averages** (usefulness, usability, combined) and the **score
distribution** are updated.
- The **statistics** in the [Analytics](/docs/handbook/analytics) section are
recalculated.
## Statistic sections on the detail page
- **Rating overview:** usefulness & usability as an average with progress
bars.
- **Score distribution:** number of ratings per star (1★–5★).
- **History:** line chart of combined/individual values over time
(only visible once there are several ratings).
## API
- [`POST /tools/{id}/ratings`](/docs/reference/endpoints/ratings#createRating) — Submit a rating
- [`GET /tools/{id}/ratings`](/docs/reference/endpoints/ratings#listToolRatings) — Ratings of a tool
@@ -0,0 +1,70 @@
# Data model
This chapter explains the central data objects of toolr at the application
level. The complete, automatically generated reference of all fields,
types and constraints can be found in the
[API reference](/docs/reference/schemas/tool).
## Tool
The heart of it all: a tool recorded in the catalog.
| Property | Description |
| --- | --- |
| `id` | Unique identifier |
| `name` | Display name |
| `description` | Description (what does the tool do?) |
| `category` | Category assignment |
| `websiteUrl` | Official website (optional) |
| `iconUrl` | Logo/icon URL (optional) |
| `features` | List of capabilities |
| `tags` | List of keywords |
| `createdAt` / `updatedAt` | Timestamps |
| `createdBy` | Person who created it |
| `deletedAt` / `deletedBy` | Soft deletion (trash) |
Input forms use the derived schemas
[`ToolInput`](/docs/reference/schemas/toolinput) and
[`ToolUpdate`](/docs/reference/schemas/toolupdate).
Aggregated views are provided by [`ToolWithStats`](/docs/reference/schemas/toolwithstats)
(e.g. with average rating).
## Rating (Bewertung)
A single rating for a tool:
- `usefulness` (Nützlichkeit, 15) and `usability` (Bedienbarkeit, 15)
- optional `comment` and a display name (`reviewerName`)
- timestamp
Input schema: [`RatingInput`](/docs/reference/schemas/ratinginput).
## User & Auth
- [`User`](/docs/reference/schemas/user) — user account with role (User/Admin)
and plan (Free/Premium/Enterprise).
- [`AuthUser`](/docs/reference/schemas/authuser) — the logged-in profile
including `entitlements` (available features).
- [`UserPreferences`](/docs/reference/schemas/userpreferences) — view and
density preferences as well as the `watchlist` (list of tool IDs).
## Analytics
The statistics endpoints provide aggregated data:
- [`AnalyticsSummary`](/docs/reference/schemas/analyticssummary) — global
metrics (number of tools/ratings, categories, average).
- [`TopToolEntry`](/docs/reference/schemas/TopToolEntry) — an entry of the
top tools.- [`CategoryStats`](/docs/reference/schemas/categorystats) — number of tools per
category.
- [`RatingDistribution`](/docs/reference/schemas/ratingdistribution) —
score distribution (usefulness & usability).
- [`ScoreBucket`](/docs/reference/schemas/scorebucket) — a value bucket.
## Additional
- [`VersionInfo`](/docs/reference/schemas/versioninfo) — version, commit SHA,
build date and trash retention of the running instance.
- [`AuditLog`](/docs/reference/schemas/auditlog) — a log entry
(action, entity, timestamp, actor, changes).
@@ -0,0 +1,53 @@
# Getting Started
This page guides you through the most important workflows in toolr — from your
first visit to creating and rating a tool.
## 1. Sign in
Most actions (create a tool, rate, watchlist, compare) require
an account. Click **Sign in** in the bottom left corner. Depending on the
instance configuration you have two options:
- **Local accounts:** username + password. Access is created by an admin
(see [Administration](/docs/handbook/administration)).
- **OIDC (SSO):** sign in with the configured identity provider (e.g.
Keycloak).
Which mode is active is shown by the
[`GET /auth/mode`](/docs/reference/endpoints/auth#getAuthMode) endpoint. Details
can be found in the [Sign in & account](/docs/handbook/konto) section.
## 2. Find tools
Open the **Browse tools** section:
- **Search** — full-text search across name & description (shortcut `/`).
- **Filter** — by category, tags, features and minimum rating
(`minRating`).
- **Sort** — by newest, top-rated, most rated, name
(ascending/descending) or last update.
All options in detail: [Find & browse tools](/docs/handbook/tools-finden).
## 3. Create a tool
Go to **Add tool** and fill in the form. Details for each field can be found
in the [Create a tool](/docs/handbook/tool-anlegen) section and in the
[field reference](/docs/reference/schemas/toolinput).
## 4. Rate
On the detail page of a tool you can submit **usefulness** and **usability**
(15 each) and optionally leave a comment. Your
rating is immediately reflected in the statistics.
See [Rating](/docs/handbook/bewerten).
## 5. Further reading
- [Compare tools](/docs/handbook/vergleichen)
- [Watchlist](/docs/handbook/watchlist)
- [Analytics](/docs/handbook/analytics)
- [Plans & permissions](/docs/handbook/plaene)
- [Administration](/docs/handbook/administration)
+51
View File
@@ -0,0 +1,51 @@
# Welcome to toolr
toolr is a platform for **discovering, rating and comparing development
tools**. Users maintain a shared catalog of tools, submit ratings
(usefulness & usability) and use statistics to make the right choice.
## What can you do with toolr?
| Function | Description | Visibility |
| --- | --- | --- |
| **Browse tools** | Filter, sort and search the catalog | Everyone |
| **Create a tool** | Add a new tool with description, category, features & tags | Logged in |
| **Edit/delete tools** | Maintain your own tools (creator or admin) | Logged in |
| **Rate** | Submit usefulness & usability (15) plus a comment | Logged in |
| **Watchlist** | Save tools as favorites | Premium |
| **Compare** | View tools side by side | Premium |
| **Record costs** | Enter license and cost models per tool | Premium |
| **Analytics** | Statistics, top tools, distributions | Everyone |
| **Trash** | Restore soft-deleted tools or delete them permanently | Premium |
| **Admin** | User management, audit log, system information | Admin |
| **Redundancy detection** | Automatic duplicate detection | Admin |
## How this documentation is organized
- **User Guide** (these pages): step-by-step instructions for all
functions — from the [Getting Started](/docs/handbook/getting-started) to
[Administration](/docs/handbook/administration).
- **API Reference**: automatically generated from the OpenAPI specification —
all [endpoints](/docs/reference/endpoints/tools) and
[data fields](/docs/reference/schemas/toolinput) of the current version.
- **Release Notes**: what is new in each [version](/docs/releases/v0.8.1).
## Getting started
The fastest way:
1. **Sign in** — without an account you can only browse
(see [Getting Started](/docs/handbook/getting-started#1-anmelden)).
2. **Find tools** — search, filters and sorting in the
[Browse tools](/docs/handbook/tools-finden) section.
3. **Create a tool** — via "Add tool"
([guide](/docs/handbook/tool-anlegen)).
4. **Rate** — on the detail page of a tool
([guide](/docs/handbook/bewerten)).
## Contact & source code
The source code is available at
[git.kubebase.de/admin/tool-evaluator](https://git.kubebase.de/admin/tool-evaluator) —
you can reach it at any time via the repository icon in the top right corner.
+51 -17
View File
@@ -3,102 +3,136 @@
"slug": "index",
"file": "index.md",
"title": "Überblick",
"order": 1
"order": 1,
"fileEn": "index.en.md",
"titleEn": "Overview"
},
{
"slug": "getting-started",
"file": "getting-started.md",
"title": "Erste Schritte",
"order": 2
"order": 2,
"fileEn": "getting-started.en.md",
"titleEn": "Getting Started"
},
{
"slug": "konto",
"file": "konto.md",
"title": "Anmelden & Konto",
"order": 3
"order": 3,
"fileEn": "konto.en.md",
"titleEn": "Login & Account"
},
{
"slug": "tools-finden",
"file": "tools-finden.md",
"title": "Tools finden & durchsuchen",
"order": 4
"order": 4,
"fileEn": "tools-finden.en.md",
"titleEn": "Find & browse tools"
},
{
"slug": "tool-anlegen",
"file": "tool-anlegen.md",
"title": "Tool anlegen",
"order": 5
"order": 5,
"fileEn": "tool-anlegen.en.md",
"titleEn": "Create a tool"
},
{
"slug": "tool-bearbeiten",
"file": "tool-bearbeiten.md",
"title": "Tool bearbeiten & löschen",
"order": 6
"order": 6,
"fileEn": "tool-bearbeiten.en.md",
"titleEn": "Edit & delete tools"
},
{
"slug": "bewerten",
"file": "bewerten.md",
"title": "Bewerten",
"order": 7
"order": 7,
"fileEn": "bewerten.en.md",
"titleEn": "Rating"
},
{
"slug": "watchlist",
"file": "watchlist.md",
"title": "Watchlist",
"order": 8
"order": 8,
"fileEn": "watchlist.en.md",
"titleEn": "Watchlist"
},
{
"slug": "vergleichen",
"file": "vergleichen.md",
"title": "Vergleichen",
"order": 9
"order": 9,
"fileEn": "vergleichen.en.md",
"titleEn": "Compare"
},
{
"slug": "analytics",
"file": "analytics.md",
"title": "Analytics",
"order": 10
"order": 10,
"fileEn": "analytics.en.md",
"titleEn": "Analytics"
},
{
"slug": "plaene",
"file": "plaene.md",
"title": "Pläne & Berechtigungen",
"order": 11
"order": 11,
"fileEn": "plaene.en.md",
"titleEn": "Plans & Permissions"
},
{
"slug": "kosten",
"file": "kosten.md",
"title": "Kosten erfassen",
"order": 12
"order": 12,
"fileEn": "kosten.en.md",
"titleEn": "Recording Costs"
},
{
"slug": "administration",
"file": "administration.md",
"title": "Administration",
"order": 13
"order": 13,
"fileEn": "administration.en.md",
"titleEn": "Administration"
},
{
"slug": "redundanz",
"file": "redundanz.md",
"title": "Redundanz-Dashboard",
"order": 14
"order": 14,
"fileEn": "redundanz.en.md",
"titleEn": "Redundancy dashboard"
},
{
"slug": "papierkorb",
"file": "papierkorb.md",
"title": "Papierkorb",
"order": 15
"order": 15,
"fileEn": "papierkorb.en.md",
"titleEn": "Trash"
},
{
"slug": "tastatur",
"file": "tastatur.md",
"title": "Tastenkürzel & Kommandopalette",
"order": 16
"order": 16,
"fileEn": "tastatur.en.md",
"titleEn": "Keyboard shortcuts & command palette"
},
{
"slug": "datenmodell",
"file": "datenmodell.md",
"title": "Datenmodell",
"order": 17
"order": 17,
"fileEn": "datenmodell.en.md",
"titleEn": "Data model"
}
]
+52
View File
@@ -0,0 +1,52 @@
# Login & Account
## Logging in
Click **Login** at the bottom left of the sidebar. Depending on the
configuration of the instance:
- **Local accounts:** enter username and password. The accounts are created
by an admin (see [Administration](/docs/handbook/administration)).
- **OIDC (SSO):** you are redirected to the configured identity provider and
log in there.
The active mode is available at the endpoint
[`GET /auth/mode`](/docs/reference/endpoints/auth#getAuthMode).
> You can reach the login page directly at `/login`. After a successful
> login you are redirected back to the page you originally requested.
## User profile
You can see your profile (avatar, name, email, plan) at the bottom left in
the user menu. There you have the following actions available:
- **Watchlist** — your saved tools (only with the corresponding plan).
- **Trash** — restorable, deleted tools (Premium/Enterprise).
- **Change password** — directly in toolr for local accounts; for OIDC
accounts, password management is offered in the identity provider.
- **Logout** — ends your session.
## Changing your password (local account)
1. Open the user menu at the bottom left.
2. Select **Change password**.
3. Enter the **current** and a **new** password (min. 6 characters) and
confirm it.
4. Save — the password takes effect immediately.
API reference: [`POST /auth/me/password`](/docs/reference/endpoints/auth#changeMyPassword).
## Display settings
Using the buttons at the top right you can:
- switch the **language** (German / English),
- toggle the **theme** (Light / Dark / System),
- adjust the **list view** and **density** in the Browse tools section
(see [Finding & browsing tools](/docs/handbook/tools-finden)).
Your preferences (incl. watchlist) are saved at the endpoint
[`GET /auth/me/preferences`](/docs/reference/endpoints/auth#getMePreferences)
and updated via [`PUT /auth/me/preferences`](/docs/reference/endpoints/auth#updateMePreferences).
@@ -0,0 +1,35 @@
# Recording Costs
On the detail page of a tool you can enter cost and license models so that
the total costs per tool become transparent.
> Costs is a **premium feature** (`costs`, Premium/Enterprise). Admins always
> have access.
## Adding costs
Click **Add costs** in the costs section of the detail page and fill out the
form:
| Field | Notes |
| --- | --- |
| **License type** | Free / Subscription / One-Time / Usage-Based |
| **Billing period** | Only for "Subscription": Monthly / Quarterly / Annually |
| **Costs** | Amount as a number |
| **Currency** | EUR / USD / GBP / CHF |
| **Notes** | Optional free text |
Saving creates the entry. Each cost entry is displayed as a card with
license badge, billing period, amount (`Amount Currency` or "Free") and
notes.
## Editing & deleting costs
Hovering over a cost card shows the **Edit** (pencil) and **Delete** (trash)
actions.
## API
The cost data is managed via the tool endpoints
(see [API reference](/docs/reference/endpoints/tools)).
@@ -0,0 +1,40 @@
# Trash
The **trash** (`/trash`) contains soft-deleted tools. With trash access
they can be restored; permanent deletion is reserved for admins.
> The trash is a **premium feature** (`trash`, Premium/Enterprise).
> Admins always have access.
## Access
The trash can be reached via the user menu or the sidebar.
Without the `trash` permission, a hint about changing the plan appears.
## Restoring
- Select one or more tools (checkboxes).
- Click on **Restore (N)** — the tools appear again in all
public views.
> Restoring is available to anyone with trash access.
## Permanently delete (admin only)
- **Delete (N)** **permanently** removes the selected tools — including
all ratings, costs and links. This cannot be undone.
- **Empty trash** permanently removes all soft-deleted tools.
## Table
The trash lists: name, category, **Deleted on** (`tt.MM.jjjj HH:mm`),
**Deleted by** as well as actions (Restore; Delete admin only). The search
filters by name.
## API
- [`GET /tools/trash`](/docs/reference/endpoints/tools#listTrashedTools) — list
- [`POST /tools/trash/restore`](/docs/reference/endpoints/tools#restoreTools) — restore
- [`DELETE /tools/trash`](/docs/reference/endpoints/tools#deleteTrashedTools) — permanently delete (admin)
- [`POST /tools/trash/empty`](/docs/reference/endpoints/tools#emptyTrash) — empty trash (admin)
@@ -0,0 +1,41 @@
# Plans & Permissions
toolr distinguishes **plans** (tier) and **roles**. Admins bypass all feature
restrictions.
## Plans
| Plan | Description |
| --- | --- |
| **Free** | Basic functions: search, filter, view, analytics |
| **Premium** | Additionally watchlist, compare, trash, costs |
| **Enterprise** | All premium features + extended support |
### Feature permissions
Premium/Enterprise unlock the following features:
| Feature | Function | Learn more |
| --- | --- | --- |
| `compare` | Compare tools | [Compare](/docs/handbook/vergleichen) |
| `watchlist` | Favorites list | [Watchlist](/docs/handbook/watchlist) |
| `trash` | Trash (soft-deleted tools) | [Trash](/docs/handbook/papierkorb) |
| `costs` | Record cost/license models | [Recording costs](/docs/handbook/kosten) |
If you are missing a feature, the app shows an **upgrade notice** with a link
to the plan management.
## Roles
| Role | Permissions |
| --- | --- |
| **User** | Standard account: create/rate tools, edit your own tools |
| **Admin** | All user rights + administration, audit log, redundancy, empty trash, tool links |
Admins pass **all** feature checks — even without a premium plan.
## Plan/role management
The assignment of role and plan is managed by admins in the
[Administration](/docs/handbook/administration) section (tab "Users").
@@ -0,0 +1,38 @@
# Redundancy dashboard
The **Redundancy dashboard** (`/admin/redundancy`) is an admin tool for
automatic detection of duplicate or strongly overlapping tools — per
category — including cost and rating comparison.
> Access is reserved exclusively for admins (the API is
> admin-protected).
## Layout
- **Per category** a group is shown: name of the category,
number of tools and comparisons as well as the **total monthly costs**
if applicable (e.g. `€X.XX/mo total`).
- Each tool is displayed as a card: name, monthly costs, number of
ratings, combined rating, license badges and number of features.
## Comparisons & recommendations
For each tool pair the following appears:
- Tool A vs. Tool B, each with rating (`X.X ★`) and monthly costs.
- **Overlap** in percent (progress bar in the middle).
- A **recommendation** with confidence color:
- **high** (green), **medium** (yellow), **low** (gray)
- The recommended, better tool is marked with a "thumbs up" and justified.
## Manual rating
You can rate a pair manually: click on Tool A or Tool B to
record which one is better. The selection is saved and the
display is updated.
## API
- [`GET /api/admin/redundancy`](#) — load data (admin-protected)
- [`POST /api/admin/redundancy/evaluate`](#) — save manual rating
@@ -0,0 +1,36 @@
# Keyboard shortcuts & command palette
## Command palette
The command palette is the central quick navigation:
- Open with **`⌘K`** (macOS) or **`Ctrl+K`** (Windows/Linux).
- Alternatively via the search bar at the top right ("Search tools… ⌘K") or the
search icon on mobile devices.
### Empty state
Without input, the palette shows:
- **Recently viewed** — the last 5 tools you visited.
- **Navigation** — browse tools, add tool, analytics as well as
(depending on permissions) watchlist, trash and admin.
### Search
Type to search for tools live (max. 10 results, incl. rating
`X.X★`).
## Overview of keyboard shortcuts
| Shortcut | Action |
| --- | --- |
| `⌘K` / `Ctrl+K` | Open command palette |
| `/` | Focus search in the "Browse tools" area |
## Additional notes
- **Recently viewed** is stored locally in the browser (max. 5 entries).
- The sidebar (left navigation) can be collapsed on desktop; the
breadcrumb at the top shows your current location.
@@ -0,0 +1,46 @@
# Create a tool
To add a new tool to the catalog, click **Add tool**
(`/tools/new`). Creating a tool requires an account — without being signed in
a notice with a login button appears.
## Form fields
| Field | Required | Notes |
| --- | --- | --- |
| **Name** | Yes | At least 2 characters |
| **Category** | Yes | Dropdown; new categories can be created directly |
| **Website URL** | No | Valid URL (e.g. `https://...`) |
| **Icon / Logo URL** | No | Valid URL; preview is shown live |
| **Description** | Yes | At least 10 characters; describe what the tool does |
| **Features** | No | Dynamic list with autocomplete (max. 6) |
| **Tags** | No | Dynamic list with autocomplete |
Next to each field, the **? icon** takes you directly to the corresponding
field description in the [data model reference](/docs/reference/schemas/toolinput).
### Category
- Type to search for existing categories.
- Select **+ Create "..."** to create a new category.
### Features & tags
- **Add feature** / **Add tag** appends a new row.
- The input fields suggest existing features/tags
(autocomplete, max. 6 suggestions).
- Use the **×** button to remove individual rows.
- Features and tags help with filtering and finding tools again.
## Save
Click **Add tool**. After successful creation you will be redirected to the
detail page of the new tool.
## API
- [`POST /tools`](/docs/reference/endpoints/tools#createTool) — create a tool
- [`GET /categories`](/docs/reference/endpoints/tools#listCategories) — categories
- [`GET /features/all`](/docs/reference/endpoints/tools#listAllFeatures) — features
- [`GET /tags/all`](/docs/reference/endpoints/tools#listAllTags) — tags
@@ -0,0 +1,32 @@
# Edit & delete tools
## Editing
On the detail page of a tool you will find the **Edit** button
(only for the person who created the tool, as well as for admins).
The edit page (`/tools/:id/edit`) contains the same fields as when
creating (name, category, website/icon URL, description, features, tags) —
already filled with the current values.
- **Save** applies the changes.
- **Cancel** takes you back to the detail page.
API reference: [`PATCH /tools/{id}`](/docs/reference/endpoints/tools#updateTool).
## Deleting
Via **Delete** on the detail page the tool is removed. The behavior
depends on your plan:
- **With trash access** (Premium/Enterprise or Admin): the tool is
**soft deleted** — it disappears from all public views, but can
be restored or permanently deleted in the [trash](/docs/handbook/papierkorb).
- **Without trash access:** the tool is **permanently** deleted and cannot
be restored.
Deletion is only possible for the person who created the tool, as well as
for admins.
API reference: [`DELETE /tools/{id}`](/docs/reference/endpoints/tools#deleteTool).
@@ -0,0 +1,66 @@
# Find & browse tools
The **Browse tools** section (`/tools`) is your entry point to the catalog.
Here you combine search, filters and sorting to find exactly the tools
you are interested in.
## Search
- The **search bar** searches name and description (full text).
- Shortcut: Press **`/`** to focus the search.
- The input is debounced so that filtering happens immediately with each
keystroke.
## Filter
Via the **Filter** button (with a badge for the number of active filters)
you open the filter popover with:
- **Tags** — selection via checkboxes (scrollable list).
- **Features** — selection via checkboxes.
- **Minimum rating** — slider from 0 to 5 (in steps of 0.5); shows
e.g. "3.0+".
Active filters appear as **removable chips** above the result list.
Use **Reset filters** or **Remove all** to clear them again.
## Sort
The **Sort** dropdown offers the following options:
| Sort | Description |
| --- | --- |
| Newest | New tools first |
| Top rated | By combined rating |
| Most rated | By number of ratings |
| Name (AZ) | Alphabetically ascending |
| Name (ZA) | Alphabetically descending |
| Last updated | By last update |
## View & density
- **Switch view:** grid / table / rows.
- **Density:** comfortable / compact (slider).
Your selection is saved — locally in the browser and, for logged-in users,
additionally on the server in the preferences. View, density, search, filters
and sorting are reflected in the URL so you can share results.
## Table view
In the table view the columns **Tool**, **Rating** and **Number of
ratings** are sortable. Hovering over a row shows a preview
with rating details, tags and mini bars.
## Selecting for comparison & watchlist
- On every card/row you find a **compare icon** that lets you add tools to the
[compare bar](/docs/handbook/vergleichen).
- The **bookmark icon** saves tools to your
[watchlist](/docs/handbook/watchlist) (only with the corresponding plan).
## API
All search, filter and sort parameters correspond to the query parameters of
[`GET /tools`](/docs/reference/endpoints/tools#listTools).
@@ -0,0 +1,42 @@
# Compare
With the compare function you can put several tools **side by side**
ideal for making a well-informed decision.
> Comparing is a **premium feature** (Premium/Enterprise) and is always
> available to admins.
## Selecting tools
1. In the **Browse tools** section, click the **compare icon** (scales) on
each card/row.
2. The **compare bar** appears at the bottom with the selected tools as
chips. You can remove individual tools (×) or clear the selection.
3. Click **Compare (N)** to go to the compare view.
> Without a premium plan the button is locked (lock icon). The dialog takes
> you to the plan switch
> (see [Plans & permissions](/docs/handbook/plaene)).
## The compare view
The view shows a table with one column per tool. Rows:
| Row | Content |
| --- | --- |
| **Rating** | Stars + value (e.g. `4.2/5`) |
| **Usefulness** | Value (X.X/5) |
| **Usability** | Value (X.X/5) |
| **Number of ratings** | Count |
| **Description** | Text |
| **Features** | Badges |
| **Tags** | Badges |
| **Last updated** | Date |
The **best value** per row is highlighted (with trophy icon).
## API
The compare view reads the data via
[`GET /compare`](/docs/reference/endpoints/tools#listCompareTools).
@@ -0,0 +1,33 @@
# Watchlist
The **watchlist** is a personal favorites list. You can open and compare the
tools in it at any time with a click.
> The watchlist is a **premium feature** (Premium/Enterprise) and is always
> available to admins.
## Prerequisite
You need a plan with the `watchlist` permission. If it is missing, a note
about switching plans appears at the bookmark
(see [Plans & permissions](/docs/handbook/plaene)).
## Saving a tool
- On every card/row in the **Browse tools** section you will find the
**bookmark icon**.
- A click saves the tool to your watchlist — the icon becomes filled.
- Clicking it again removes it.
## Viewing the watchlist
Open the watchlist via the user menu or the sidebar. It shows all saved tools
as cards. The filled bookmark on a card removes the tool from the list.
## Where is the watchlist stored?
The watchlist is a list of tool IDs in your **user preferences**. This way it
is linked to your account across devices.
API reference: [`GET /auth/me/watchlist`](/docs/reference/endpoints/auth#getMeWatchlist).
+35
View File
@@ -0,0 +1,35 @@
# v0.8.2 — Release Notes
**Date:** 2026-08-04 · **Tag:** [`v0.8.2`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.2)
## Fixes & improvements
- **Mobile navigation in the docs**: The left navigation bar (manual,
endpoints, schemas) was completely hidden below `lg` (1024px).
There is now a menu button in the header that opens a side
navigation drawer with the same content — on small screens
the docs remain fully navigable.
- **Version dropdown shows the selected version**: When switching to a
different release (`/docs/releases/vX.Y.Z`), the dropdown wrongly stayed on
"current". The displayed version is now also derived from the releases route.
## API changes
- No changes to the API.
## Operations / upgrade
- **Env vars:** unchanged.
- **Migration:** none.
- **Breaking changes:** none.
## Known limitations
- Manual & reference apply to the current version; older versions show
their release notes and a reference snapshot, if generated at release time
(`node scripts/src/generate-docs.mjs --snapshot vX.Y.Z`).
## Links
- Commit: [`33cf261`](https://git.kubebase.de/admin/tool-evaluator/commit/33cf261)
- Tag: [`v0.8.2`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.2)
@@ -0,0 +1,61 @@
# Administration
The **Admin** area (`/admin`) is exclusively accessible to admins.
Without the admin role, access is denied.
> At the top right, the **Redundancy dashboard** button leads to the automatic
> duplicate detection (see [Redundancy](/docs/handbook/redundanz)).
## "Users" tab
Management of local accounts.
- **Add user:** username (required), password (at least 6 characters),
email (optional), **role** (User/Admin), **plan** (Free/Premium/Enterprise).
- **Edit user:** set role, plan and (for local accounts) a new password.
For OIDC accounts, password management is offered in the identity provider
(e.g. Keycloak).
- **Delete user:** permanently removes the account (not for your own account).
API reference:
[`POST /users`](/docs/reference/endpoints/users#createUser),
[`PATCH /users/{id}`](/docs/reference/endpoints/users#updateUser),
[`DELETE /users/{id}`](/docs/reference/endpoints/users#deleteUser).
## "Tools" tab
Central access to the tool catalog.
- **Search** for tools.
- View, edit or move individual tools 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
and can be restored or permanently deleted).
## "Audit log" tab
Chronological log of all creation, change and deletion operations
(max. 100 entries): action, entity + ID, timestamp, executing person and
changed fields.
API reference: [`GET /audit-logs`](/docs/reference/endpoints/audit#listAuditLogs).
## "System" tab
Version information of the running instance:
- **Version** (e.g. `v0.8.1`),
- **Commit** (7-digit SHA, linked to the repository),
- **Build date**,
- **Trash retention** ("N days" or "Forever").
## Tool links (Admin)
On the detail page of a tool you can manage **links** as an admin
(own/"manual" as well as automatically detected ones):
- **Link tool:** dialog with tool ID, **relationship type**
(Similar / Replaces / Superseded by) and optional notes.
- Relationship types are displayed as badges on the detail page.
- Manual links can be removed again via the trash icon.
@@ -0,0 +1,29 @@
# Analytics
The **Analytics** area (`/analytics`) is a public dashboard with
metrics and charts based on all tools and ratings.
## Metrics (KPI cards)
- **Number of tools** — how many tools are recorded in the catalog.
- **Number of ratings** — how many ratings were submitted in total.
- **Active categories** — how many categories exist.
- **Average rating** — global combined value.
## Charts
| Chart | Content |
| --- | --- |
| **Top 8 tools** | Bar chart of the tools with the highest combined score (05) |
| **Tools per category** | Radar chart of the number of tools per category |
| **Score distribution** | Two horizontal bar charts (usefulness & usability) per star |
The charts are interactive (tooltips on hover).
## API
- [`GET /analytics/summary`](/docs/reference/endpoints/analytics#getAnalyticsSummary)
- [`GET /analytics/top-tools`](/docs/reference/endpoints/analytics#getTopTools)
- [`GET /analytics/by-category`](/docs/reference/endpoints/analytics#getAnalyticsByCategory)
- [`GET /analytics/rating-distribution`](/docs/reference/endpoints/analytics#getRatingDistribution)
@@ -0,0 +1,39 @@
# Rating
On the detail page of a tool you can share your experience. Click on
**Submit a rating** (requires an account).
## Form fields
| Field | Required | Notes |
| --- | --- | --- |
| **Usefulness** | Yes | 15 stars |
| **Usability** | Yes | 15 stars |
| **Comment** | No | Free text |
| **Name** | No | Defaults to "Anonymous" |
Next to the fields, the **? icon** links directly to the associated field
description in the [data model reference](/docs/reference/schemas/ratinginput).
## What happens after submitting?
- Your rating is saved immediately and appears in the **rating list** of the
detail page.
- The **averages** (usefulness, usability, combined) and the **score
distribution** are updated.
- The **statistics** in the [Analytics](/docs/handbook/analytics) section are
recalculated.
## Statistic sections on the detail page
- **Rating overview:** usefulness & usability as an average with progress
bars.
- **Score distribution:** number of ratings per star (1★–5★).
- **History:** line chart of combined/individual values over time
(only visible once there are several ratings).
## API
- [`POST /tools/{id}/ratings`](/docs/reference/endpoints/ratings#createRating) — Submit a rating
- [`GET /tools/{id}/ratings`](/docs/reference/endpoints/ratings#listToolRatings) — Ratings of a tool
@@ -0,0 +1,70 @@
# Data model
This chapter explains the central data objects of toolr at the application
level. The complete, automatically generated reference of all fields,
types and constraints can be found in the
[API reference](/docs/reference/schemas/tool).
## Tool
The heart of it all: a tool recorded in the catalog.
| Property | Description |
| --- | --- |
| `id` | Unique identifier |
| `name` | Display name |
| `description` | Description (what does the tool do?) |
| `category` | Category assignment |
| `websiteUrl` | Official website (optional) |
| `iconUrl` | Logo/icon URL (optional) |
| `features` | List of capabilities |
| `tags` | List of keywords |
| `createdAt` / `updatedAt` | Timestamps |
| `createdBy` | Person who created it |
| `deletedAt` / `deletedBy` | Soft deletion (trash) |
Input forms use the derived schemas
[`ToolInput`](/docs/reference/schemas/toolinput) and
[`ToolUpdate`](/docs/reference/schemas/toolupdate).
Aggregated views are provided by [`ToolWithStats`](/docs/reference/schemas/toolwithstats)
(e.g. with average rating).
## Rating (Bewertung)
A single rating for a tool:
- `usefulness` (Nützlichkeit, 15) and `usability` (Bedienbarkeit, 15)
- optional `comment` and a display name (`reviewerName`)
- timestamp
Input schema: [`RatingInput`](/docs/reference/schemas/ratinginput).
## User & Auth
- [`User`](/docs/reference/schemas/user) — user account with role (User/Admin)
and plan (Free/Premium/Enterprise).
- [`AuthUser`](/docs/reference/schemas/authuser) — the logged-in profile
including `entitlements` (available features).
- [`UserPreferences`](/docs/reference/schemas/userpreferences) — view and
density preferences as well as the `watchlist` (list of tool IDs).
## Analytics
The statistics endpoints provide aggregated data:
- [`AnalyticsSummary`](/docs/reference/schemas/analyticssummary) — global
metrics (number of tools/ratings, categories, average).
- [`TopToolEntry`](/docs/reference/schemas/TopToolEntry) — an entry of the
top tools.- [`CategoryStats`](/docs/reference/schemas/categorystats) — number of tools per
category.
- [`RatingDistribution`](/docs/reference/schemas/ratingdistribution) —
score distribution (usefulness & usability).
- [`ScoreBucket`](/docs/reference/schemas/scorebucket) — a value bucket.
## Additional
- [`VersionInfo`](/docs/reference/schemas/versioninfo) — version, commit SHA,
build date and trash retention of the running instance.
- [`AuditLog`](/docs/reference/schemas/auditlog) — a log entry
(action, entity, timestamp, actor, changes).
@@ -0,0 +1,53 @@
# Getting Started
This page guides you through the most important workflows in toolr — from your
first visit to creating and rating a tool.
## 1. Sign in
Most actions (create a tool, rate, watchlist, compare) require
an account. Click **Sign in** in the bottom left corner. Depending on the
instance configuration you have two options:
- **Local accounts:** username + password. Access is created by an admin
(see [Administration](/docs/handbook/administration)).
- **OIDC (SSO):** sign in with the configured identity provider (e.g.
Keycloak).
Which mode is active is shown by the
[`GET /auth/mode`](/docs/reference/endpoints/auth#getAuthMode) endpoint. Details
can be found in the [Sign in & account](/docs/handbook/konto) section.
## 2. Find tools
Open the **Browse tools** section:
- **Search** — full-text search across name & description (shortcut `/`).
- **Filter** — by category, tags, features and minimum rating
(`minRating`).
- **Sort** — by newest, top-rated, most rated, name
(ascending/descending) or last update.
All options in detail: [Find & browse tools](/docs/handbook/tools-finden).
## 3. Create a tool
Go to **Add tool** and fill in the form. Details for each field can be found
in the [Create a tool](/docs/handbook/tool-anlegen) section and in the
[field reference](/docs/reference/schemas/toolinput).
## 4. Rate
On the detail page of a tool you can submit **usefulness** and **usability**
(15 each) and optionally leave a comment. Your
rating is immediately reflected in the statistics.
See [Rating](/docs/handbook/bewerten).
## 5. Further reading
- [Compare tools](/docs/handbook/vergleichen)
- [Watchlist](/docs/handbook/watchlist)
- [Analytics](/docs/handbook/analytics)
- [Plans & permissions](/docs/handbook/plaene)
- [Administration](/docs/handbook/administration)
+51
View File
@@ -0,0 +1,51 @@
# Welcome to toolr
toolr is a platform for **discovering, rating and comparing development
tools**. Users maintain a shared catalog of tools, submit ratings
(usefulness & usability) and use statistics to make the right choice.
## What can you do with toolr?
| Function | Description | Visibility |
| --- | --- | --- |
| **Browse tools** | Filter, sort and search the catalog | Everyone |
| **Create a tool** | Add a new tool with description, category, features & tags | Logged in |
| **Edit/delete tools** | Maintain your own tools (creator or admin) | Logged in |
| **Rate** | Submit usefulness & usability (15) plus a comment | Logged in |
| **Watchlist** | Save tools as favorites | Premium |
| **Compare** | View tools side by side | Premium |
| **Record costs** | Enter license and cost models per tool | Premium |
| **Analytics** | Statistics, top tools, distributions | Everyone |
| **Trash** | Restore soft-deleted tools or delete them permanently | Premium |
| **Admin** | User management, audit log, system information | Admin |
| **Redundancy detection** | Automatic duplicate detection | Admin |
## How this documentation is organized
- **User Guide** (these pages): step-by-step instructions for all
functions — from the [Getting Started](/docs/handbook/getting-started) to
[Administration](/docs/handbook/administration).
- **API Reference**: automatically generated from the OpenAPI specification —
all [endpoints](/docs/reference/endpoints/tools) and
[data fields](/docs/reference/schemas/toolinput) of the current version.
- **Release Notes**: what is new in each [version](/docs/releases/v0.8.1).
## Getting started
The fastest way:
1. **Sign in** — without an account you can only browse
(see [Getting Started](/docs/handbook/getting-started#1-anmelden)).
2. **Find tools** — search, filters and sorting in the
[Browse tools](/docs/handbook/tools-finden) section.
3. **Create a tool** — via "Add tool"
([guide](/docs/handbook/tool-anlegen)).
4. **Rate** — on the detail page of a tool
([guide](/docs/handbook/bewerten)).
## Contact & source code
The source code is available at
[git.kubebase.de/admin/tool-evaluator](https://git.kubebase.de/admin/tool-evaluator) —
you can reach it at any time via the repository icon in the top right corner.
+51 -17
View File
@@ -3,102 +3,136 @@
"slug": "index",
"file": "index.md",
"title": "Überblick",
"order": 1
"order": 1,
"fileEn": "index.en.md",
"titleEn": "Overview"
},
{
"slug": "getting-started",
"file": "getting-started.md",
"title": "Erste Schritte",
"order": 2
"order": 2,
"fileEn": "getting-started.en.md",
"titleEn": "Getting Started"
},
{
"slug": "konto",
"file": "konto.md",
"title": "Anmelden & Konto",
"order": 3
"order": 3,
"fileEn": "konto.en.md",
"titleEn": "Login & Account"
},
{
"slug": "tools-finden",
"file": "tools-finden.md",
"title": "Tools finden & durchsuchen",
"order": 4
"order": 4,
"fileEn": "tools-finden.en.md",
"titleEn": "Find & browse tools"
},
{
"slug": "tool-anlegen",
"file": "tool-anlegen.md",
"title": "Tool anlegen",
"order": 5
"order": 5,
"fileEn": "tool-anlegen.en.md",
"titleEn": "Create a tool"
},
{
"slug": "tool-bearbeiten",
"file": "tool-bearbeiten.md",
"title": "Tool bearbeiten & löschen",
"order": 6
"order": 6,
"fileEn": "tool-bearbeiten.en.md",
"titleEn": "Edit & delete tools"
},
{
"slug": "bewerten",
"file": "bewerten.md",
"title": "Bewerten",
"order": 7
"order": 7,
"fileEn": "bewerten.en.md",
"titleEn": "Rating"
},
{
"slug": "watchlist",
"file": "watchlist.md",
"title": "Watchlist",
"order": 8
"order": 8,
"fileEn": "watchlist.en.md",
"titleEn": "Watchlist"
},
{
"slug": "vergleichen",
"file": "vergleichen.md",
"title": "Vergleichen",
"order": 9
"order": 9,
"fileEn": "vergleichen.en.md",
"titleEn": "Compare"
},
{
"slug": "analytics",
"file": "analytics.md",
"title": "Analytics",
"order": 10
"order": 10,
"fileEn": "analytics.en.md",
"titleEn": "Analytics"
},
{
"slug": "plaene",
"file": "plaene.md",
"title": "Pläne & Berechtigungen",
"order": 11
"order": 11,
"fileEn": "plaene.en.md",
"titleEn": "Plans & Permissions"
},
{
"slug": "kosten",
"file": "kosten.md",
"title": "Kosten erfassen",
"order": 12
"order": 12,
"fileEn": "kosten.en.md",
"titleEn": "Recording Costs"
},
{
"slug": "administration",
"file": "administration.md",
"title": "Administration",
"order": 13
"order": 13,
"fileEn": "administration.en.md",
"titleEn": "Administration"
},
{
"slug": "redundanz",
"file": "redundanz.md",
"title": "Redundanz-Dashboard",
"order": 14
"order": 14,
"fileEn": "redundanz.en.md",
"titleEn": "Redundancy dashboard"
},
{
"slug": "papierkorb",
"file": "papierkorb.md",
"title": "Papierkorb",
"order": 15
"order": 15,
"fileEn": "papierkorb.en.md",
"titleEn": "Trash"
},
{
"slug": "tastatur",
"file": "tastatur.md",
"title": "Tastenkürzel & Kommandopalette",
"order": 16
"order": 16,
"fileEn": "tastatur.en.md",
"titleEn": "Keyboard shortcuts & command palette"
},
{
"slug": "datenmodell",
"file": "datenmodell.md",
"title": "Datenmodell",
"order": 17
"order": 17,
"fileEn": "datenmodell.en.md",
"titleEn": "Data model"
}
]
+52
View File
@@ -0,0 +1,52 @@
# Login & Account
## Logging in
Click **Login** at the bottom left of the sidebar. Depending on the
configuration of the instance:
- **Local accounts:** enter username and password. The accounts are created
by an admin (see [Administration](/docs/handbook/administration)).
- **OIDC (SSO):** you are redirected to the configured identity provider and
log in there.
The active mode is available at the endpoint
[`GET /auth/mode`](/docs/reference/endpoints/auth#getAuthMode).
> You can reach the login page directly at `/login`. After a successful
> login you are redirected back to the page you originally requested.
## User profile
You can see your profile (avatar, name, email, plan) at the bottom left in
the user menu. There you have the following actions available:
- **Watchlist** — your saved tools (only with the corresponding plan).
- **Trash** — restorable, deleted tools (Premium/Enterprise).
- **Change password** — directly in toolr for local accounts; for OIDC
accounts, password management is offered in the identity provider.
- **Logout** — ends your session.
## Changing your password (local account)
1. Open the user menu at the bottom left.
2. Select **Change password**.
3. Enter the **current** and a **new** password (min. 6 characters) and
confirm it.
4. Save — the password takes effect immediately.
API reference: [`POST /auth/me/password`](/docs/reference/endpoints/auth#changeMyPassword).
## Display settings
Using the buttons at the top right you can:
- switch the **language** (German / English),
- toggle the **theme** (Light / Dark / System),
- adjust the **list view** and **density** in the Browse tools section
(see [Finding & browsing tools](/docs/handbook/tools-finden)).
Your preferences (incl. watchlist) are saved at the endpoint
[`GET /auth/me/preferences`](/docs/reference/endpoints/auth#getMePreferences)
and updated via [`PUT /auth/me/preferences`](/docs/reference/endpoints/auth#updateMePreferences).
@@ -0,0 +1,35 @@
# Recording Costs
On the detail page of a tool you can enter cost and license models so that
the total costs per tool become transparent.
> Costs is a **premium feature** (`costs`, Premium/Enterprise). Admins always
> have access.
## Adding costs
Click **Add costs** in the costs section of the detail page and fill out the
form:
| Field | Notes |
| --- | --- |
| **License type** | Free / Subscription / One-Time / Usage-Based |
| **Billing period** | Only for "Subscription": Monthly / Quarterly / Annually |
| **Costs** | Amount as a number |
| **Currency** | EUR / USD / GBP / CHF |
| **Notes** | Optional free text |
Saving creates the entry. Each cost entry is displayed as a card with
license badge, billing period, amount (`Amount Currency` or "Free") and
notes.
## Editing & deleting costs
Hovering over a cost card shows the **Edit** (pencil) and **Delete** (trash)
actions.
## API
The cost data is managed via the tool endpoints
(see [API reference](/docs/reference/endpoints/tools)).
@@ -0,0 +1,40 @@
# Trash
The **trash** (`/trash`) contains soft-deleted tools. With trash access
they can be restored; permanent deletion is reserved for admins.
> The trash is a **premium feature** (`trash`, Premium/Enterprise).
> Admins always have access.
## Access
The trash can be reached via the user menu or the sidebar.
Without the `trash` permission, a hint about changing the plan appears.
## Restoring
- Select one or more tools (checkboxes).
- Click on **Restore (N)** — the tools appear again in all
public views.
> Restoring is available to anyone with trash access.
## Permanently delete (admin only)
- **Delete (N)** **permanently** removes the selected tools — including
all ratings, costs and links. This cannot be undone.
- **Empty trash** permanently removes all soft-deleted tools.
## Table
The trash lists: name, category, **Deleted on** (`tt.MM.jjjj HH:mm`),
**Deleted by** as well as actions (Restore; Delete admin only). The search
filters by name.
## API
- [`GET /tools/trash`](/docs/reference/endpoints/tools#listTrashedTools) — list
- [`POST /tools/trash/restore`](/docs/reference/endpoints/tools#restoreTools) — restore
- [`DELETE /tools/trash`](/docs/reference/endpoints/tools#deleteTrashedTools) — permanently delete (admin)
- [`POST /tools/trash/empty`](/docs/reference/endpoints/tools#emptyTrash) — empty trash (admin)
@@ -0,0 +1,41 @@
# Plans & Permissions
toolr distinguishes **plans** (tier) and **roles**. Admins bypass all feature
restrictions.
## Plans
| Plan | Description |
| --- | --- |
| **Free** | Basic functions: search, filter, view, analytics |
| **Premium** | Additionally watchlist, compare, trash, costs |
| **Enterprise** | All premium features + extended support |
### Feature permissions
Premium/Enterprise unlock the following features:
| Feature | Function | Learn more |
| --- | --- | --- |
| `compare` | Compare tools | [Compare](/docs/handbook/vergleichen) |
| `watchlist` | Favorites list | [Watchlist](/docs/handbook/watchlist) |
| `trash` | Trash (soft-deleted tools) | [Trash](/docs/handbook/papierkorb) |
| `costs` | Record cost/license models | [Recording costs](/docs/handbook/kosten) |
If you are missing a feature, the app shows an **upgrade notice** with a link
to the plan management.
## Roles
| Role | Permissions |
| --- | --- |
| **User** | Standard account: create/rate tools, edit your own tools |
| **Admin** | All user rights + administration, audit log, redundancy, empty trash, tool links |
Admins pass **all** feature checks — even without a premium plan.
## Plan/role management
The assignment of role and plan is managed by admins in the
[Administration](/docs/handbook/administration) section (tab "Users").
@@ -0,0 +1,38 @@
# Redundancy dashboard
The **Redundancy dashboard** (`/admin/redundancy`) is an admin tool for
automatic detection of duplicate or strongly overlapping tools — per
category — including cost and rating comparison.
> Access is reserved exclusively for admins (the API is
> admin-protected).
## Layout
- **Per category** a group is shown: name of the category,
number of tools and comparisons as well as the **total monthly costs**
if applicable (e.g. `€X.XX/mo total`).
- Each tool is displayed as a card: name, monthly costs, number of
ratings, combined rating, license badges and number of features.
## Comparisons & recommendations
For each tool pair the following appears:
- Tool A vs. Tool B, each with rating (`X.X ★`) and monthly costs.
- **Overlap** in percent (progress bar in the middle).
- A **recommendation** with confidence color:
- **high** (green), **medium** (yellow), **low** (gray)
- The recommended, better tool is marked with a "thumbs up" and justified.
## Manual rating
You can rate a pair manually: click on Tool A or Tool B to
record which one is better. The selection is saved and the
display is updated.
## API
- [`GET /api/admin/redundancy`](#) — load data (admin-protected)
- [`POST /api/admin/redundancy/evaluate`](#) — save manual rating
@@ -0,0 +1,36 @@
# Keyboard shortcuts & command palette
## Command palette
The command palette is the central quick navigation:
- Open with **`⌘K`** (macOS) or **`Ctrl+K`** (Windows/Linux).
- Alternatively via the search bar at the top right ("Search tools… ⌘K") or the
search icon on mobile devices.
### Empty state
Without input, the palette shows:
- **Recently viewed** — the last 5 tools you visited.
- **Navigation** — browse tools, add tool, analytics as well as
(depending on permissions) watchlist, trash and admin.
### Search
Type to search for tools live (max. 10 results, incl. rating
`X.X★`).
## Overview of keyboard shortcuts
| Shortcut | Action |
| --- | --- |
| `⌘K` / `Ctrl+K` | Open command palette |
| `/` | Focus search in the "Browse tools" area |
## Additional notes
- **Recently viewed** is stored locally in the browser (max. 5 entries).
- The sidebar (left navigation) can be collapsed on desktop; the
breadcrumb at the top shows your current location.
@@ -0,0 +1,46 @@
# Create a tool
To add a new tool to the catalog, click **Add tool**
(`/tools/new`). Creating a tool requires an account — without being signed in
a notice with a login button appears.
## Form fields
| Field | Required | Notes |
| --- | --- | --- |
| **Name** | Yes | At least 2 characters |
| **Category** | Yes | Dropdown; new categories can be created directly |
| **Website URL** | No | Valid URL (e.g. `https://...`) |
| **Icon / Logo URL** | No | Valid URL; preview is shown live |
| **Description** | Yes | At least 10 characters; describe what the tool does |
| **Features** | No | Dynamic list with autocomplete (max. 6) |
| **Tags** | No | Dynamic list with autocomplete |
Next to each field, the **? icon** takes you directly to the corresponding
field description in the [data model reference](/docs/reference/schemas/toolinput).
### Category
- Type to search for existing categories.
- Select **+ Create "..."** to create a new category.
### Features & tags
- **Add feature** / **Add tag** appends a new row.
- The input fields suggest existing features/tags
(autocomplete, max. 6 suggestions).
- Use the **×** button to remove individual rows.
- Features and tags help with filtering and finding tools again.
## Save
Click **Add tool**. After successful creation you will be redirected to the
detail page of the new tool.
## API
- [`POST /tools`](/docs/reference/endpoints/tools#createTool) — create a tool
- [`GET /categories`](/docs/reference/endpoints/tools#listCategories) — categories
- [`GET /features/all`](/docs/reference/endpoints/tools#listAllFeatures) — features
- [`GET /tags/all`](/docs/reference/endpoints/tools#listAllTags) — tags
@@ -0,0 +1,32 @@
# Edit & delete tools
## Editing
On the detail page of a tool you will find the **Edit** button
(only for the person who created the tool, as well as for admins).
The edit page (`/tools/:id/edit`) contains the same fields as when
creating (name, category, website/icon URL, description, features, tags) —
already filled with the current values.
- **Save** applies the changes.
- **Cancel** takes you back to the detail page.
API reference: [`PATCH /tools/{id}`](/docs/reference/endpoints/tools#updateTool).
## Deleting
Via **Delete** on the detail page the tool is removed. The behavior
depends on your plan:
- **With trash access** (Premium/Enterprise or Admin): the tool is
**soft deleted** — it disappears from all public views, but can
be restored or permanently deleted in the [trash](/docs/handbook/papierkorb).
- **Without trash access:** the tool is **permanently** deleted and cannot
be restored.
Deletion is only possible for the person who created the tool, as well as
for admins.
API reference: [`DELETE /tools/{id}`](/docs/reference/endpoints/tools#deleteTool).
@@ -0,0 +1,66 @@
# Find & browse tools
The **Browse tools** section (`/tools`) is your entry point to the catalog.
Here you combine search, filters and sorting to find exactly the tools
you are interested in.
## Search
- The **search bar** searches name and description (full text).
- Shortcut: Press **`/`** to focus the search.
- The input is debounced so that filtering happens immediately with each
keystroke.
## Filter
Via the **Filter** button (with a badge for the number of active filters)
you open the filter popover with:
- **Tags** — selection via checkboxes (scrollable list).
- **Features** — selection via checkboxes.
- **Minimum rating** — slider from 0 to 5 (in steps of 0.5); shows
e.g. "3.0+".
Active filters appear as **removable chips** above the result list.
Use **Reset filters** or **Remove all** to clear them again.
## Sort
The **Sort** dropdown offers the following options:
| Sort | Description |
| --- | --- |
| Newest | New tools first |
| Top rated | By combined rating |
| Most rated | By number of ratings |
| Name (AZ) | Alphabetically ascending |
| Name (ZA) | Alphabetically descending |
| Last updated | By last update |
## View & density
- **Switch view:** grid / table / rows.
- **Density:** comfortable / compact (slider).
Your selection is saved — locally in the browser and, for logged-in users,
additionally on the server in the preferences. View, density, search, filters
and sorting are reflected in the URL so you can share results.
## Table view
In the table view the columns **Tool**, **Rating** and **Number of
ratings** are sortable. Hovering over a row shows a preview
with rating details, tags and mini bars.
## Selecting for comparison & watchlist
- On every card/row you find a **compare icon** that lets you add tools to the
[compare bar](/docs/handbook/vergleichen).
- The **bookmark icon** saves tools to your
[watchlist](/docs/handbook/watchlist) (only with the corresponding plan).
## API
All search, filter and sort parameters correspond to the query parameters of
[`GET /tools`](/docs/reference/endpoints/tools#listTools).
@@ -0,0 +1,42 @@
# Compare
With the compare function you can put several tools **side by side**
ideal for making a well-informed decision.
> Comparing is a **premium feature** (Premium/Enterprise) and is always
> available to admins.
## Selecting tools
1. In the **Browse tools** section, click the **compare icon** (scales) on
each card/row.
2. The **compare bar** appears at the bottom with the selected tools as
chips. You can remove individual tools (×) or clear the selection.
3. Click **Compare (N)** to go to the compare view.
> Without a premium plan the button is locked (lock icon). The dialog takes
> you to the plan switch
> (see [Plans & permissions](/docs/handbook/plaene)).
## The compare view
The view shows a table with one column per tool. Rows:
| Row | Content |
| --- | --- |
| **Rating** | Stars + value (e.g. `4.2/5`) |
| **Usefulness** | Value (X.X/5) |
| **Usability** | Value (X.X/5) |
| **Number of ratings** | Count |
| **Description** | Text |
| **Features** | Badges |
| **Tags** | Badges |
| **Last updated** | Date |
The **best value** per row is highlighted (with trophy icon).
## API
The compare view reads the data via
[`GET /compare`](/docs/reference/endpoints/tools#listCompareTools).
@@ -0,0 +1,33 @@
# Watchlist
The **watchlist** is a personal favorites list. You can open and compare the
tools in it at any time with a click.
> The watchlist is a **premium feature** (Premium/Enterprise) and is always
> available to admins.
## Prerequisite
You need a plan with the `watchlist` permission. If it is missing, a note
about switching plans appears at the bookmark
(see [Plans & permissions](/docs/handbook/plaene)).
## Saving a tool
- On every card/row in the **Browse tools** section you will find the
**bookmark icon**.
- A click saves the tool to your watchlist — the icon becomes filled.
- Clicking it again removes it.
## Viewing the watchlist
Open the watchlist via the user menu or the sidebar. It shows all saved tools
as cards. The filled bookmark on a card removes the tool from the list.
## Where is the watchlist stored?
The watchlist is a list of tool IDs in your **user preferences**. This way it
is linked to your account across devices.
API reference: [`GET /auth/me/watchlist`](/docs/reference/endpoints/auth#getMeWatchlist).
+49
View File
@@ -0,0 +1,49 @@
# v0.8.3 — Release Notes
**Date:** 2026-08-04 · **Tag:** [`v0.8.3`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.3)
## New features
- **Version-based documentation**: Each release now carries a
complete docs snapshot (manual + API reference). Via the
version dropdown you can reach the docs of the respective version
(`/docs/vX.Y.Z/…`) — including the manual and reference as they
applied at release time. The last 7 versions remain available.
- **Help buttons in forms (NetBox style)**: For evaluation, tool
create/edit, costs, comparing, watchlist and analytics there is now
a help icon that leads directly to the matching guide in the manual.
## Fixes & improvements
- **Gitea tag links fixed**: `…/tags/vX.Y.Z` was a 404 page; the
correct URL is `/releases/tag/vX.Y.Z` (in all release notes and the
docs page).
- Version switching now leads to the docs of the selected version instead of
just the release page; old `/docs/vX.Y.Z` redirects are removed.
- Docs generator: `--snapshot` creates manual + reference per version; on
build, the snapshots of the last 7 versions are included.
## API changes
- No changes to the API.
## Operations / upgrade
- **Env vars:** unchanged.
- **Migration:** none.
- **Breaking changes:** none. New release notes must use the correct
tag link (see `docs/releases/TEMPLATE.md`). For versions before
this release, snapshots can be created retroactively:
`node scripts/src/generate-docs.mjs --snapshot vX.Y.Z`.
## Known limitations
- Older releases without a snapshot (e.g. v0.6.0) only show their release notes,
no manual/reference. Backfill with `--snapshot`.
- The docs version corresponds to the state of the docs at the time the
snapshot was created; manual changes apply from the respective next release.
## Links
- Commit: [`3bb4598`](https://git.kubebase.de/admin/tool-evaluator/commit/3bb4598)
- Tag: [`v0.8.3`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.3)
+1 -1
View File
@@ -10,7 +10,7 @@
(`/docs/vX.Y.Z/…`) — inklusive Handbuch und Referenz, wie sie zum
Release-Zeitpunkt galten. Die letzten 7 Versionen bleiben verfügbar.
- **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.
## Fixes & Verbesserungen
+45
View File
@@ -0,0 +1,45 @@
# v0.8.4 — Release Notes
**Date:** 2026-08-04 · **Tag:** [`v0.8.4`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.4)
## New features
- **Full app localization (German/English)**: All pages and components are
now translated — tool detail page, admin area, trash, redundancy dashboard,
compare, watchlist, analytics, create and edit tool, admin tools tab,
category combobox and theme toggle. The language picker in the top right
switches the whole UI.
- **Bilingual documentation**: The manual (all 17 pages) and the release
notes are now available in German and English. The docs page has a
language switcher that switches content, navigation titles and search.
Versioned docs snapshots (v0.8.1, v0.8.2) also contain the English pages.
## Fixes & improvements
- All remaining hardcoded UI strings replaced with `t()` calls; the locales
are in sync (453 keys per language, 0 missing).
- API reference: table headers (Name/In), required badges and request body
labels in the docs are translated.
- Docs search respects the active language (separate index per language).
## API changes
- No changes to the API.
## Operations / upgrade
- **Env vars:** unchanged.
- **Migration:** none.
- **Breaking changes:** none. The docs generator output now includes
additional English files (`*.en.md`) and `search.en.json`; these are
generated automatically during the build.
## Known limitations
- Versioned docs snapshots before v0.8.1 (e.g. v0.6.0) do not contain
English manual pages; the German version is used as fallback there.
## Links
- Commit: [`3de59cc`](https://git.kubebase.de/admin/tool-evaluator/commit/3de59cc)
- Tag: [`v0.8.4`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.4)
+48
View File
@@ -0,0 +1,48 @@
# v0.8.4 — Release Notes
**Datum:** 2026-08-04 · **Tag:** [`v0.8.4`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.4)
## Neue Features
- **Komplette Lokalisierung der App (Deutsch/Englisch)**: Alle Seiten und
Komponenten sind jetzt übersetzt — Tool-Detailseite, Verwaltung, Papierkorb,
Redundanz-Dashboard, Vergleichen, Merkliste, Analytics, Tool anlegen und
bearbeiten, Admin-Bereich inkl. Tools-Tab, Kategorie-Combobox und
Theme-Umschalter. Die Sprachauswahl oben rechts wechselt die gesamte
Oberfläche.
- **Dokumentation zweisprachig**: Handbuch (alle 17 Seiten) und Release-Notes
sind jetzt in Deutsch und Englisch verfügbar. In der Doku gibt es einen
Sprach-Umschalter, der Inhalte, Navigationstitel und Suche umschaltet.
Auch versionierte Doku-Snapshots (v0.8.1, v0.8.2) enthalten die englischen
Seiten.
## Fixes & Verbesserungen
- Alle verbliebenen hartkodierten UI-Strings durch `t()`-Aufrufe ersetzt;
die Locales sind synchron (453 Keys pro Sprache, 0 fehlend).
- API-Referenz: Tabellen-Header (Name/In), Pflicht-Badges und
Request-Body-Label der Doku übersetzt.
- Suche in der Doku berücksichtigt die aktive Sprache (eigener Index pro
Sprache).
## API-Änderungen
- Keine Änderungen an der API.
## Betrieb / Upgrade
- **Env-Vars:** unverändert.
- **Migration:** keine.
- **Breaking Changes:** keine. Die Doku-Generator-Ausgabe umfasst jetzt
zusätzliche englische Dateien (`*.en.md`) und `search.en.json`; beim Build
werden diese automatisch erzeugt.
## Bekannte Einschränkungen
- Versionierte Doku-Snapshots vor v0.8.1 (z. B. v0.6.0) enthalten keine
englischen Handbuch-Seiten; dort greift die deutsche Fassung als Fallback.
## Links
- Commit: [`3de59cc`](https://git.kubebase.de/admin/tool-evaluator/commit/3de59cc)
- Tag: [`v0.8.4`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.4)
+45
View File
@@ -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)
+46
View File
@@ -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)
+73 -15
View File
@@ -26,7 +26,12 @@ const releasesDir = resolve(root, "docs/releases");
const targetDir = resolve(root, "artifacts/toolrate/public/docs");
const isReleaseFile = (name) => /^v\d+\.\d+\.\d+\.md$/.test(name);
const isHandbookFile = (name) => /\.md$/.test(name);
const isEnReleaseFile = (name) => /^v\d+\.\d+\.\d+\.en\.md$/.test(name);
const isHandbookFile = (name) => /\.md$/.test(name) && !name.endsWith(".en.md");
const isEnHandbookFile = (name) => /\.en\.md$/.test(name);
const enName = (file) => (file.endsWith(".md") ? `${file.slice(0, -3)}.en.md` : null);
const deName = (file) => (file.endsWith(".en.md") ? `${file.slice(0, -6)}.md` : null);
// ---------------------------------------------------------------------------
// Version helpers
@@ -196,6 +201,15 @@ function slugify(name) {
return name.replace(/\.md$/, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-");
}
async function readFileOptional(path) {
try {
return await readFile(path, "utf8");
} catch (err) {
if (err.code === "ENOENT") return null;
throw err;
}
}
async function readHandbookPages() {
let files;
try {
@@ -208,12 +222,18 @@ async function readHandbookPages() {
for (const file of files) {
const content = await readFile(join(handbookDir, file), "utf8");
const { frontmatter, body } = parseHandbook(content);
const enFile = enName(file);
const enContent = enFile ? await readFileOptional(join(handbookDir, enFile)) : null;
const en = enContent ? parseHandbook(enContent) : null;
pages.push({
slug: slugify(basename(file)),
file,
title: frontmatter.title ?? extractTitle(body) ?? basename(file),
order: typeof frontmatter.order === "number" ? frontmatter.order : 999,
body,
fileEn: en ? enFile : null,
titleEn: en ? en.frontmatter.title ?? extractTitle(en.body) ?? null : null,
bodyEn: en ? en.body : null,
});
}
pages.sort((a, b) => a.order - b.order || a.title.localeCompare(b.title));
@@ -225,11 +245,12 @@ async function writeHandbookTo(dir) {
await mkdir(dir, { recursive: true });
for (const page of pages) {
await writeFile(join(dir, page.file), page.body);
if (page.bodyEn) await writeFile(join(dir, page.fileEn), page.bodyEn);
}
await writeFile(
join(dir, "index.json"),
JSON.stringify(
pages.map(({ slug, file, title, order }) => ({ slug, file, title, order })),
pages.map(({ slug, file, title, order, fileEn, titleEn }) => ({ slug, file, title, order, fileEn, titleEn })),
null,
2,
),
@@ -256,6 +277,7 @@ function stripMarkdown(md) {
async function buildSearchIndex(reference, handbookPages, releaseVersions) {
const entries = [];
const entriesEn = [];
for (const page of handbookPages) {
const file = join(handbookDir, page.file);
@@ -267,44 +289,58 @@ async function buildSearchIndex(reference, handbookPages, releaseVersions) {
kind: "guide",
text: stripMarkdown(body),
});
if (page.bodyEn) {
entriesEn.push({
title: page.titleEn ?? page.title,
href: `/docs/handbook/${page.slug}`,
kind: "guide",
text: stripMarkdown(page.bodyEn),
});
}
}
for (const tag of reference.tags) {
for (const ep of tag.endpoints) {
entries.push({
const entry = {
title: `${ep.method} ${ep.path}`,
href: `/docs/reference/endpoints/${tag.name}#${ep.operationId}`,
kind: "endpoint",
text: `${ep.summary} ${ep.description} ${ep.parameters
.map((p) => `${p.name} ${p.description}`)
.join(" ")}`.trim(),
});
};
entries.push(entry);
entriesEn.push(entry);
}
}
for (const schema of reference.schemas) {
for (const field of schema.fields) {
entries.push({
const entry = {
title: `${schema.name}.${field.name}`,
href: `/docs/reference/schemas/${schema.name}#${field.name}`,
kind: "field",
text: `${field.description} ${field.constraints} ${field.type.value ?? ""}`.trim(),
});
};
entries.push(entry);
entriesEn.push(entry);
}
}
for (const version of releaseVersions) {
const file = join(releasesDir, `${version}.md`);
const content = await readFile(file, "utf8");
entries.push({
const base = {
title: version,
href: `/docs/releases/${version}`,
kind: "release",
text: stripMarkdown(content),
});
};
entries.push({ ...base, text: stripMarkdown(content) });
const enContent = await readFileOptional(join(releasesDir, `${version}.en.md`));
entriesEn.push({ ...base, text: stripMarkdown(enContent ?? content) });
}
return entries;
return { entries, entriesEn };
}
// ---------------------------------------------------------------------------
@@ -348,7 +384,6 @@ async function main() {
throw err;
}
releaseNames.sort(cmp).reverse();
await rm(targetDir, { recursive: true, force: true });
await mkdir(join(targetDir, "handbook"), { recursive: true });
await mkdir(join(targetDir, "releases"), { recursive: true });
@@ -363,7 +398,18 @@ async function main() {
const handbookPages = await buildHandbookIndex();
await writeFile(
join(targetDir, "handbook/index.json"),
JSON.stringify(handbookPages, null, 2),
JSON.stringify(
handbookPages.map(({ slug, file, title, order, fileEn, titleEn }) => ({
slug,
file,
title,
order,
fileEn,
titleEn,
})),
null,
2,
),
);
// 3. Release notes + versioned docs snapshots (last 7 versions)
@@ -372,6 +418,9 @@ async function main() {
const version = parseVersion(name);
const content = await readFile(join(releasesDir, name), "utf8");
await copyFile(join(releasesDir, name), join(targetDir, "releases", name));
const enName = `${version}.en.md`;
const enContent = await readFileOptional(join(releasesDir, enName));
if (enContent) await copyFile(join(releasesDir, enName), join(targetDir, "releases", enName));
const snapDir = join(releasesDir, version);
const hasReference = await stat(join(snapDir, "reference.json"))
.then(() => true)
@@ -399,12 +448,20 @@ async function main() {
join(snapDir, "handbook", page.file),
join(targetDir, "versions", version, "handbook", page.file),
);
if (page.fileEn) {
await copyFile(
join(snapDir, "handbook", page.fileEn),
join(targetDir, "versions", version, "handbook", page.fileEn),
);
}
}
}
versions.push({
version,
file: `releases/${name}`,
fileEn: enContent ? `releases/${enName}` : null,
title: extractTitle(content) ?? version,
titleEn: enContent ? extractTitle(enContent) ?? version : null,
date: extractDate(content) ?? null,
hasReference,
hasHandbook,
@@ -414,12 +471,13 @@ async function main() {
await writeFile(join(targetDir, "index.json"), JSON.stringify(versions, null, 2));
// 4. Search index
const searchIndex = await buildSearchIndex(reference, handbookPages, versions.map((v) => v.version));
await writeFile(join(targetDir, "search.json"), JSON.stringify(searchIndex, null, 2));
const { entries, entriesEn } = await buildSearchIndex(reference, handbookPages, versions.map((v) => v.version));
await writeFile(join(targetDir, "search.json"), JSON.stringify(entries, null, 2));
await writeFile(join(targetDir, "search.en.json"), JSON.stringify(entriesEn, null, 2));
console.log(
`[generate-docs] synced ${versions.length} release(s), ${handbookPages.length} handbook page(s), ` +
`${reference.schemas.length} schema(s), ${searchIndex.length} search entries`,
`${reference.schemas.length} schema(s), ${entries.length} search entries`,
);
}