Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 63aa14914f | |||
| 3de59ccaaf | |||
| a65e2eb92c | |||
| b886246808 | |||
| 3bb4598d04 |
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import {
|
import {
|
||||||
useListTools,
|
useListTools,
|
||||||
@@ -10,7 +11,6 @@ import {
|
|||||||
getListAllTagsQueryKey,
|
getListAllTagsQueryKey,
|
||||||
getGetTopToolsQueryKey,
|
getGetTopToolsQueryKey,
|
||||||
getGetAnalyticsSummaryQueryKey,
|
getGetAnalyticsSummaryQueryKey,
|
||||||
type ToolWithStats,
|
|
||||||
} from "@workspace/api-client-react";
|
} from "@workspace/api-client-react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
@@ -27,6 +27,7 @@ import { Search, Trash2, ExternalLink, Pencil, Star } from "lucide-react";
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
|
||||||
export function AdminToolsTab() {
|
export function AdminToolsTab() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { isAdmin, isLoading: authLoading } = useAuth();
|
const { isAdmin, isLoading: authLoading } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -82,20 +83,20 @@ export function AdminToolsTab() {
|
|||||||
{ data: { ids } },
|
{ data: { ids } },
|
||||||
{
|
{
|
||||||
onSuccess: (res) => {
|
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());
|
setSelected(new Set());
|
||||||
setConfirmTrash(false);
|
setConfirmTrash(false);
|
||||||
invalidate();
|
invalidate();
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
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) {
|
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];
|
const selectedIds = [...selected];
|
||||||
@@ -104,9 +105,9 @@ export function AdminToolsTab() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-4 flex-wrap">
|
<CardHeader className="flex flex-row items-center justify-between gap-4 flex-wrap">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<CardTitle>All Tools</CardTitle>
|
<CardTitle>{t("adminTools.allTools")}</CardTitle>
|
||||||
<CardDescription>
|
<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>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@@ -114,7 +115,7 @@ export function AdminToolsTab() {
|
|||||||
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
className="pl-8 w-56"
|
className="pl-8 w-56"
|
||||||
placeholder="Search tools…"
|
placeholder={t("adminTools.searchPlaceholder")}
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -125,7 +126,7 @@ export function AdminToolsTab() {
|
|||||||
disabled={selectedIds.length === 0 || trash.isPending}
|
disabled={selectedIds.length === 0 || trash.isPending}
|
||||||
onClick={() => setConfirmTrash(true)}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -135,7 +136,7 @@ export function AdminToolsTab() {
|
|||||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
|
||||||
</div>
|
</div>
|
||||||
) : allTools.length === 0 ? (
|
) : 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>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -144,58 +145,58 @@ export function AdminToolsTab() {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selected.size === allTools.length && allTools.length > 0}
|
checked={selected.size === allTools.length && allTools.length > 0}
|
||||||
onCheckedChange={toggleAll}
|
onCheckedChange={toggleAll}
|
||||||
aria-label="Select all"
|
aria-label={t("adminTools.selectAll")}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>{t("adminTools.name")}</TableHead>
|
||||||
<TableHead>Category</TableHead>
|
<TableHead>{t("adminTools.category")}</TableHead>
|
||||||
<TableHead>Rating</TableHead>
|
<TableHead>{t("adminTools.rating")}</TableHead>
|
||||||
<TableHead>Created by</TableHead>
|
<TableHead>{t("adminTools.createdBy")}</TableHead>
|
||||||
<TableHead>Created at</TableHead>
|
<TableHead>{t("adminTools.createdAt")}</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">{t("adminTools.actions")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{allTools.map((t: ToolWithStats) => (
|
{allTools.map((tool) => (
|
||||||
<TableRow key={t.id} className={selected.has(t.id) ? "bg-muted/40" : undefined}>
|
<TableRow key={tool.id} className={selected.has(tool.id) ? "bg-muted/40" : undefined}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selected.has(t.id)}
|
checked={selected.has(tool.id)}
|
||||||
onCheckedChange={() => toggle(t.id)}
|
onCheckedChange={() => toggle(tool.id)}
|
||||||
aria-label={`Select ${t.name}`}
|
aria-label={t("adminTools.selectName", { name: tool.name })}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium">{t.name}</TableCell>
|
<TableCell className="font-medium">{tool.name}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline">{t.category}</Badge>
|
<Badge variant="outline">{tool.category}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
{t.ratingCount > 0 ? (
|
{tool.ratingCount > 0 ? (
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<Star className="w-3 h-3 fill-amber-500 text-amber-500" />
|
<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>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"—"
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</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">
|
<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>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
|
<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>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
|
<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>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||||
onClick={() => handleTrash([t.id])}
|
onClick={() => handleTrash([tool.id])}
|
||||||
disabled={trash.isPending}
|
disabled={trash.isPending}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
@@ -212,18 +213,18 @@ export function AdminToolsTab() {
|
|||||||
<AlertDialog open={confirmTrash} onOpenChange={setConfirmTrash}>
|
<AlertDialog open={confirmTrash} onOpenChange={setConfirmTrash}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Move {selectedIds.length} tool(s) to trash?</AlertDialogTitle>
|
<AlertDialogTitle>{t("adminTools.confirmTitle", { count: selectedIds.length })}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<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>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
onClick={() => handleTrash(selectedIds)}
|
onClick={() => handleTrash(selectedIds)}
|
||||||
>
|
>
|
||||||
Move to trash
|
{t("adminTools.moveToTrashAction")}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Check, ChevronsUpDown } from "lucide-react";
|
import { Check, ChevronsUpDown } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -23,7 +24,8 @@ interface CategoryComboboxProps {
|
|||||||
placeholder?: string;
|
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 [open, setOpen] = useState(false);
|
||||||
const [inputValue, setInputValue] = useState(value);
|
const [inputValue, setInputValue] = useState(value);
|
||||||
|
|
||||||
@@ -49,6 +51,8 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
|
|||||||
(c) => c.toLowerCase() === inputValue.toLowerCase()
|
(c) => c.toLowerCase() === inputValue.toLowerCase()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const resolvedPlaceholder = placeholder ?? t("category.placeholder");
|
||||||
|
|
||||||
function select(val: string) {
|
function select(val: string) {
|
||||||
onChange(val);
|
onChange(val);
|
||||||
setInputValue(val);
|
setInputValue(val);
|
||||||
@@ -66,7 +70,7 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
|
|||||||
data-testid="button-category-combobox"
|
data-testid="button-category-combobox"
|
||||||
>
|
>
|
||||||
<span className={cn(!value && "text-muted-foreground")}>
|
<span className={cn(!value && "text-muted-foreground")}>
|
||||||
{value || placeholder}
|
{value || resolvedPlaceholder}
|
||||||
</span>
|
</span>
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -74,7 +78,7 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
|
|||||||
<PopoverContent className="w-full p-0" align="start">
|
<PopoverContent className="w-full p-0" align="start">
|
||||||
<Command shouldFilter={false}>
|
<Command shouldFilter={false}>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search or enter new category..."
|
placeholder={t("category.searchPlaceholder")}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
setInputValue(v);
|
setInputValue(v);
|
||||||
@@ -84,10 +88,10 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
|
|||||||
/>
|
/>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
{filtered.length === 0 && !showCreateOption && (
|
{filtered.length === 0 && !showCreateOption && (
|
||||||
<CommandEmpty>No categories found.</CommandEmpty>
|
<CommandEmpty>{t("category.noCategories")}</CommandEmpty>
|
||||||
)}
|
)}
|
||||||
{filtered.length > 0 && (
|
{filtered.length > 0 && (
|
||||||
<CommandGroup heading="Known categories">
|
<CommandGroup heading={t("category.knownCategories")}>
|
||||||
{filtered.map((cat) => (
|
{filtered.map((cat) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={cat}
|
key={cat}
|
||||||
@@ -104,13 +108,13 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
|
|||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
{showCreateOption && (
|
{showCreateOption && (
|
||||||
<CommandGroup heading="Create new">
|
<CommandGroup heading={t("category.createNew")}>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onSelect={() => select(inputValue.trim())}
|
onSelect={() => select(inputValue.trim())}
|
||||||
data-testid="item-category-create-new"
|
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">“{inputValue.trim()}”</span>
|
<span className="ml-2 text-muted-foreground">“{inputValue.trim()}”</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { BookOpen } from "lucide-react";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
export function GuideHelp({
|
||||||
|
guide,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
guide: string;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Link
|
||||||
|
href={`/docs/handbook/${guide}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
aria-label={`Help: ${label}`}
|
||||||
|
data-testid={`guide-${guide}`}
|
||||||
|
className="inline-flex shrink-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<BookOpen className="h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{label} — Anleitung in der Dokumentation</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ChevronDown, Lightbulb, LightbulbOff, Monitor, Moon, Sun } from "lucide-react";
|
import { ChevronDown, Lightbulb, LightbulbOff, Monitor, Moon, Sun } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
import { useTheme, type Theme } from "@/hooks/use-theme";
|
import { useTheme, type Theme } from "@/hooks/use-theme";
|
||||||
|
|
||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||||
const isDark = resolvedTheme === "dark";
|
const isDark = resolvedTheme === "dark";
|
||||||
|
|
||||||
@@ -25,8 +27,8 @@ export function ThemeToggle() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-9 w-9"
|
className="h-9 w-9"
|
||||||
onClick={quickToggle}
|
onClick={quickToggle}
|
||||||
title={isDark ? "Switch to light theme" : "Switch to dark theme"}
|
title={isDark ? t("theme.switchLight") : t("theme.switchDark")}
|
||||||
aria-label={isDark ? "Switch to light theme" : "Switch to dark theme"}
|
aria-label={isDark ? t("theme.switchLight") : t("theme.switchDark")}
|
||||||
data-testid="button-theme-quick-toggle"
|
data-testid="button-theme-quick-toggle"
|
||||||
>
|
>
|
||||||
{isDark ? <Lightbulb className="w-4 h-4" /> : <LightbulbOff className="w-4 h-4" />}
|
{isDark ? <Lightbulb className="w-4 h-4" /> : <LightbulbOff className="w-4 h-4" />}
|
||||||
@@ -37,27 +39,27 @@ export function ThemeToggle() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-9 w-9"
|
className="h-9 w-9"
|
||||||
aria-label="Choose theme"
|
aria-label={t("theme.chooseTitle")}
|
||||||
title="Choose theme"
|
title={t("theme.chooseTitle")}
|
||||||
data-testid="button-theme-menu"
|
data-testid="button-theme-menu"
|
||||||
>
|
>
|
||||||
<ChevronDown className="w-4 h-4" />
|
<ChevronDown className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" sideOffset={6}>
|
<DropdownMenuContent align="end" sideOffset={6}>
|
||||||
<DropdownMenuLabel>Theme</DropdownMenuLabel>
|
<DropdownMenuLabel>{t("theme.title")}</DropdownMenuLabel>
|
||||||
<DropdownMenuRadioGroup value={theme} onValueChange={(v) => setTheme(v as Theme)}>
|
<DropdownMenuRadioGroup value={theme} onValueChange={(v) => setTheme(v as Theme)}>
|
||||||
<DropdownMenuRadioItem value="light">
|
<DropdownMenuRadioItem value="light">
|
||||||
<Sun className="w-4 h-4 mr-2" />
|
<Sun className="w-4 h-4 mr-2" />
|
||||||
Light
|
{t("theme.light")}
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
<DropdownMenuRadioItem value="dark">
|
<DropdownMenuRadioItem value="dark">
|
||||||
<Moon className="w-4 h-4 mr-2" />
|
<Moon className="w-4 h-4 mr-2" />
|
||||||
Dark
|
{t("theme.dark")}
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
<DropdownMenuRadioItem value="system">
|
<DropdownMenuRadioItem value="system">
|
||||||
<Monitor className="w-4 h-4 mr-2" />
|
<Monitor className="w-4 h-4 mr-2" />
|
||||||
System
|
{t("theme.system")}
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
</DropdownMenuRadioGroup>
|
</DropdownMenuRadioGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ToolWithStats } from "@workspace/api-client-react";
|
import type { ToolWithStats } from "@workspace/api-client-react";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -6,6 +7,7 @@ import { RatingStars } from "@/components/rating-stars";
|
|||||||
import { MiniBars } from "@/components/mini-bars";
|
import { MiniBars } from "@/components/mini-bars";
|
||||||
|
|
||||||
export function ToolPreviewCard({ tool }: { tool: ToolWithStats }) {
|
export function ToolPreviewCard({ tool }: { tool: ToolWithStats }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -22,7 +24,7 @@ export function ToolPreviewCard({ tool }: { tool: ToolWithStats }) {
|
|||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center justify-between text-xs">
|
<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">
|
<span className="font-semibold tabular-nums">
|
||||||
{tool.avgCombined != null ? tool.avgCombined.toFixed(1) : "–"}/5
|
{tool.avgCombined != null ? tool.avgCombined.toFixed(1) : "–"}/5
|
||||||
</span>
|
</span>
|
||||||
@@ -47,8 +49,7 @@ export function ToolPreviewCard({ tool }: { tool: ToolWithStats }) {
|
|||||||
href={`/tools/${tool.id}`}
|
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"
|
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
|
{t("common.viewDetails")}
|
||||||
</Link>
|
</Link> </div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,9 @@
|
|||||||
"loading": "Wird geladen…",
|
"loading": "Wird geladen…",
|
||||||
"all": "Alle",
|
"all": "Alle",
|
||||||
"none": "Keine",
|
"none": "Keine",
|
||||||
"language": "Sprache"
|
"language": "Sprache",
|
||||||
|
"viewDetails": "Details ansehen",
|
||||||
|
"guideTooltip": "Anleitung in der Dokumentation"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Willkommen bei toolr",
|
"welcome": "Willkommen bei toolr",
|
||||||
@@ -80,6 +82,8 @@
|
|||||||
"category": "Kategorie",
|
"category": "Kategorie",
|
||||||
"rating": "Bewertung",
|
"rating": "Bewertung",
|
||||||
"reviews": "Bewertungen",
|
"reviews": "Bewertungen",
|
||||||
|
"reviewCount_one": "{{count}} Bewertung",
|
||||||
|
"reviewCount_other": "{{count}} Bewertungen",
|
||||||
"noToolsFound": "Keine Tools gefunden",
|
"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.",
|
"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",
|
"addTool": "Tool hinzufügen",
|
||||||
@@ -103,7 +107,9 @@
|
|||||||
"features": "Funktionen",
|
"features": "Funktionen",
|
||||||
"minRating": "Mindestbewertung",
|
"minRating": "Mindestbewertung",
|
||||||
"any": "Beliebig",
|
"any": "Beliebig",
|
||||||
"clearFilters": "Filter zurücksetzen"
|
"clearFilters": "Filter zurücksetzen",
|
||||||
|
"removeFeature": "Funktion entfernen: {{feature}}",
|
||||||
|
"removeMinRating": "Mindestbewertung entfernen"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"ratingBreakdown": "Bewertungsaufschlüsselung",
|
"ratingBreakdown": "Bewertungsaufschlüsselung",
|
||||||
@@ -133,7 +139,65 @@
|
|||||||
"deleteToTrash": "Dies verschiebt {{name}} in den Papierkorb. Es kann später wiederhergestellt werden.",
|
"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.",
|
"deletePermanent": "Dies entfernt {{name}} dauerhaft inklusive aller Bewertungen. Das kann nicht rückgängig gemacht werden.",
|
||||||
"deleting": "Löschen…",
|
"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": {
|
"compare": {
|
||||||
"title": "Tools vergleichen",
|
"title": "Tools vergleichen",
|
||||||
@@ -176,7 +240,29 @@
|
|||||||
"deletePermanently": "Endgültig löschen",
|
"deletePermanently": "Endgültig löschen",
|
||||||
"emptyTrash": "Papierkorb leeren",
|
"emptyTrash": "Papierkorb leeren",
|
||||||
"deletedAt": "Gelöscht am",
|
"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": {
|
"notFound": {
|
||||||
"text": "Diese Seite existiert nicht.",
|
"text": "Diese Seite existiert nicht.",
|
||||||
@@ -209,7 +295,10 @@
|
|||||||
"parameter": "Parameter",
|
"parameter": "Parameter",
|
||||||
"noResults": "Keine Treffer",
|
"noResults": "Keine Treffer",
|
||||||
"fields": "Felder",
|
"fields": "Felder",
|
||||||
"fieldHelpHint": "Hinweis: Formular-Felder verlinken per ?-Icon direkt zu den jeweiligen Zeilen dieser Tabelle."
|
"fieldHelpHint": "Hinweis: Formular-Felder verlinken per ?-Icon direkt zu den jeweiligen Zeilen dieser Tabelle.",
|
||||||
|
"name": "Name",
|
||||||
|
"in": "In",
|
||||||
|
"requestBody": "Request-Body"
|
||||||
},
|
},
|
||||||
"command": {
|
"command": {
|
||||||
"navigate": "Navigation",
|
"navigate": "Navigation",
|
||||||
@@ -217,5 +306,192 @@
|
|||||||
"tools": "Tools",
|
"tools": "Tools",
|
||||||
"noResults": "Keine Tools für „{{query}}“ gefunden.",
|
"noResults": "Keine Tools für „{{query}}“ gefunden.",
|
||||||
"startTyping": "Beginne zu tippen, um Tools zu suchen."
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,9 @@
|
|||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"language": "Language"
|
"language": "Language",
|
||||||
|
"viewDetails": "View details",
|
||||||
|
"guideTooltip": "Guide in the documentation"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"welcome": "Welcome to toolr",
|
"welcome": "Welcome to toolr",
|
||||||
@@ -80,6 +82,8 @@
|
|||||||
"category": "Category",
|
"category": "Category",
|
||||||
"rating": "Rating",
|
"rating": "Rating",
|
||||||
"reviews": "Reviews",
|
"reviews": "Reviews",
|
||||||
|
"reviewCount_one": "{{count}} review",
|
||||||
|
"reviewCount_other": "{{count}} reviews",
|
||||||
"noToolsFound": "No tools found",
|
"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.",
|
"noToolsMatch": "We couldn't find any tools matching your current filters. Try adjusting your search criteria or add a new tool.",
|
||||||
"addTool": "Add Tool",
|
"addTool": "Add Tool",
|
||||||
@@ -103,7 +107,9 @@
|
|||||||
"features": "Features",
|
"features": "Features",
|
||||||
"minRating": "Min. rating",
|
"minRating": "Min. rating",
|
||||||
"any": "Any",
|
"any": "Any",
|
||||||
"clearFilters": "Clear filters"
|
"clearFilters": "Clear filters",
|
||||||
|
"removeFeature": "Remove feature: {{feature}}",
|
||||||
|
"removeMinRating": "Remove min rating"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"ratingBreakdown": "Rating Breakdown",
|
"ratingBreakdown": "Rating Breakdown",
|
||||||
@@ -133,7 +139,65 @@
|
|||||||
"deleteToTrash": "This will move {{name}} to the trash. It can be restored later.",
|
"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.",
|
"deletePermanent": "This will permanently remove {{name}} and all its ratings. This cannot be undone.",
|
||||||
"deleting": "Deleting…",
|
"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": {
|
"compare": {
|
||||||
"title": "Compare Tools",
|
"title": "Compare Tools",
|
||||||
@@ -176,7 +240,29 @@
|
|||||||
"deletePermanently": "Delete permanently",
|
"deletePermanently": "Delete permanently",
|
||||||
"emptyTrash": "Empty trash",
|
"emptyTrash": "Empty trash",
|
||||||
"deletedAt": "Deleted at",
|
"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": {
|
"notFound": {
|
||||||
"text": "This page doesn't exist.",
|
"text": "This page doesn't exist.",
|
||||||
@@ -191,7 +277,7 @@
|
|||||||
"latest": "Latest",
|
"latest": "Latest",
|
||||||
"repo": "Repository",
|
"repo": "Repository",
|
||||||
"nav": "Documentation",
|
"nav": "Documentation",
|
||||||
"guides": "Guide",
|
"guides": "Guides",
|
||||||
"endpoints": "Endpoints",
|
"endpoints": "Endpoints",
|
||||||
"schemas": "Data models",
|
"schemas": "Data models",
|
||||||
"releases": "Release notes",
|
"releases": "Release notes",
|
||||||
@@ -209,7 +295,10 @@
|
|||||||
"parameter": "Parameter",
|
"parameter": "Parameter",
|
||||||
"noResults": "No results",
|
"noResults": "No results",
|
||||||
"fields": "Fields",
|
"fields": "Fields",
|
||||||
"fieldHelpHint": "Note: form fields link via the ? icon directly to the respective rows of this table."
|
"fieldHelpHint": "Note: form fields link via the ? icon directly to the respective rows of this table.",
|
||||||
|
"name": "Name",
|
||||||
|
"in": "In",
|
||||||
|
"requestBody": "Request Body"
|
||||||
},
|
},
|
||||||
"command": {
|
"command": {
|
||||||
"navigate": "Navigate",
|
"navigate": "Navigate",
|
||||||
@@ -217,5 +306,192 @@
|
|||||||
"tools": "Tools",
|
"tools": "Tools",
|
||||||
"noResults": "No tools found for \"{{query}}\".",
|
"noResults": "No tools found for \"{{query}}\".",
|
||||||
"startTyping": "Start typing to search tools."
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Users, ScrollText, ShieldAlert, Plus, Pencil, Trash2, Clock, AlertTriangle, Wrench, Server } from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { AdminToolsTab } from "@/components/admin-tools-tab";
|
import { AdminToolsTab } from "@/components/admin-tools-tab";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function Admin() {
|
export default function Admin() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const { user, isAdmin, isLoading: authLoading } = useAuth();
|
const { user, isAdmin, isLoading: authLoading } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -68,9 +70,9 @@ export default function Admin() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||||
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
|
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
|
||||||
<h2 className="text-2xl font-bold">Admin Access Required</h2>
|
<h2 className="text-2xl font-bold">{t("admin.accessRequired")}</h2>
|
||||||
<p className="text-muted-foreground">You need admin rights to view this page.</p>
|
<p className="text-muted-foreground">{t("admin.accessRequiredSub")}</p>
|
||||||
<Button variant="outline" onClick={() => setLocation("/")}>Go Home</Button>
|
<Button variant="outline" onClick={() => setLocation("/")}>{t("admin.goHome")}</Button>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
@@ -82,7 +84,7 @@ export default function Admin() {
|
|||||||
{ data: { username: newUsername, password: newPassword, email: newEmail || undefined, role: newRole, tier: newTier } },
|
{ data: { username: newUsername, password: newPassword, email: newEmail || undefined, role: newRole, tier: newTier } },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
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() });
|
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
setNewUsername("");
|
setNewUsername("");
|
||||||
@@ -91,7 +93,7 @@ export default function Admin() {
|
|||||||
setNewRole("user");
|
setNewRole("user");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
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" } },
|
{ id: editUser.id, data: { role: editUser.role as "admin" | "user", tier: editUser.tier as "free" | "premium" | "enterprise" } },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({ title: "User updated" });
|
toast({ title: t("admin.toastUserUpdated") });
|
||||||
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
|
||||||
setEditUser(null);
|
setEditUser(null);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
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 = () => {
|
const handleSetUserPassword = () => {
|
||||||
if (!editUser || !editPassword) return;
|
if (!editUser || !editPassword) return;
|
||||||
if (editPassword.length < 6) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
setUserPassword.mutate(
|
setUserPassword.mutate(
|
||||||
{ id: editUser.id, data: { password: editPassword } },
|
{ id: editUser.id, data: { password: editPassword } },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
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("");
|
setEditPassword("");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
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 },
|
{ id: deleteConfirm.id },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
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() });
|
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
|
||||||
setDeleteConfirm(null);
|
setDeleteConfirm(null);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
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>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Admin Panel</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("admin.panel")}</h1>
|
||||||
<p className="text-muted-foreground">Manage users and review system changes.</p>
|
<p className="text-muted-foreground">{t("admin.panelSub")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild variant="outline" size="sm" className="gap-2">
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,16 +177,16 @@ export default function Admin() {
|
|||||||
<Tabs defaultValue="users">
|
<Tabs defaultValue="users">
|
||||||
<TabsList className="mb-4">
|
<TabsList className="mb-4">
|
||||||
<TabsTrigger value="users" className="gap-2">
|
<TabsTrigger value="users" className="gap-2">
|
||||||
<Users className="w-4 h-4" /> Users
|
<Users className="w-4 h-4" /> {t("admin.tabUsers")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="tools" className="gap-2">
|
<TabsTrigger value="tools" className="gap-2">
|
||||||
<Wrench className="w-4 h-4" /> Tools
|
<Wrench className="w-4 h-4" /> {t("admin.tabTools")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="audit" className="gap-2">
|
<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>
|
||||||
<TabsTrigger value="system" className="gap-2">
|
<TabsTrigger value="system" className="gap-2">
|
||||||
<Server className="w-4 h-4" /> System
|
<Server className="w-4 h-4" /> {t("admin.tabSystem")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -192,11 +194,11 @@ export default function Admin() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Local Users</CardTitle>
|
<CardTitle>{t("admin.localUsers")}</CardTitle>
|
||||||
<CardDescription>Manage accounts for local authentication.</CardDescription>
|
<CardDescription>{t("admin.localUsersSub")}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
<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>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -248,7 +250,7 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{(!users || users.length === 0) && (
|
{(!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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -263,8 +265,8 @@ export default function Admin() {
|
|||||||
<TabsContent value="audit">
|
<TabsContent value="audit">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Audit Log</CardTitle>
|
<CardTitle>{t("admin.auditLog")}</CardTitle>
|
||||||
<CardDescription>All create, update and delete operations tracked by the system.</CardDescription>
|
<CardDescription>{t("admin.auditLogSub")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loadingLogs ? (
|
{loadingLogs ? (
|
||||||
@@ -289,8 +291,7 @@ export default function Admin() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span>by <span className="font-medium text-foreground">{log.username}</span></span>
|
<span>{t("admin.by")} <span className="font-medium text-foreground">{log.username}</span></span> {log.changes && (
|
||||||
{log.changes && (
|
|
||||||
<span className="truncate max-w-[400px] font-mono bg-muted px-1.5 py-0.5 rounded text-[11px]">
|
<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}
|
{log.changes.length > 120 ? log.changes.slice(0, 120) + "…" : log.changes}
|
||||||
</span>
|
</span>
|
||||||
@@ -299,7 +300,7 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{(!auditLogs || auditLogs.length === 0) && (
|
{(!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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -310,17 +311,17 @@ export default function Admin() {
|
|||||||
<TabsContent value="system">
|
<TabsContent value="system">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>System</CardTitle>
|
<CardTitle>{t("admin.system")}</CardTitle>
|
||||||
<CardDescription>Build information of the currently live deployment.</CardDescription>
|
<CardDescription>{t("admin.systemSub")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
<div className="flex items-center justify-between py-3">
|
<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>
|
<span className="text-sm font-medium">{versionInfo?.version || "dev"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-3">
|
<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 ? (
|
{versionInfo?.commitSha ? (
|
||||||
<a
|
<a
|
||||||
href={`https://git.kubebase.de/admin/tool-evaluator/commit/${versionInfo.commitSha}`}
|
href={`https://git.kubebase.de/admin/tool-evaluator/commit/${versionInfo.commitSha}`}
|
||||||
@@ -336,17 +337,17 @@ export default function Admin() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-3">
|
<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">
|
<span className="text-sm">
|
||||||
{versionInfo?.buildDate ? format(new Date(versionInfo.buildDate), "dd.MM.yyyy HH:mm") : "—"}
|
{versionInfo?.buildDate ? format(new Date(versionInfo.buildDate), "dd.MM.yyyy HH:mm") : "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-3">
|
<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">
|
<span className="text-sm">
|
||||||
{(versionInfo?.trashRetentionDays ?? 0) > 0
|
{(versionInfo?.trashRetentionDays ?? 0) > 0
|
||||||
? `${versionInfo?.trashRetentionDays} days`
|
? `${versionInfo?.trashRetentionDays} ${t("admin.days")}`
|
||||||
: "Keep forever"}
|
: t("admin.keepForever")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -359,51 +360,51 @@ export default function Admin() {
|
|||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create New User</DialogTitle>
|
<DialogTitle>{t("admin.createNewUser")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Username</Label>
|
<Label>{t("admin.username")}</Label>
|
||||||
<Input value={newUsername} onChange={(e) => setNewUsername(e.target.value)} placeholder="username" />
|
<Input value={newUsername} onChange={(e) => setNewUsername(e.target.value)} placeholder={t("admin.usernamePlaceholder")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Password</Label>
|
<Label>{t("admin.password")}</Label>
|
||||||
<PasswordInput value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="min. 6 characters" />
|
<PasswordInput value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder={t("admin.minPasswordPlaceholder")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Email (Optional)</Label>
|
<Label>{t("admin.emailOptional")}</Label>
|
||||||
<Input type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} placeholder="user@example.com" />
|
<Input type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} placeholder={t("admin.emailPlaceholder")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Role</Label>
|
<Label>{t("admin.role")}</Label>
|
||||||
<Select value={newRole} onValueChange={(v) => setNewRole(v as "admin" | "user")}>
|
<Select value={newRole} onValueChange={(v) => setNewRole(v as "admin" | "user")}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="user">User</SelectItem>
|
<SelectItem value="user">{t("admin.roleUser")}</SelectItem>
|
||||||
<SelectItem value="admin">Admin</SelectItem>
|
<SelectItem value="admin">{t("admin.roleAdmin")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Plan</Label>
|
<Label>{t("admin.plan")}</Label>
|
||||||
<Select value={newTier} onValueChange={(v) => setNewTier(v as "free" | "premium" | "enterprise")}>
|
<Select value={newTier} onValueChange={(v) => setNewTier(v as "free" | "premium" | "enterprise")}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="free">Free</SelectItem>
|
<SelectItem value="free">{t("admin.planFree")}</SelectItem>
|
||||||
<SelectItem value="premium">Premium</SelectItem>
|
<SelectItem value="premium">{t("admin.planPremium")}</SelectItem>
|
||||||
<SelectItem value="enterprise">Enterprise</SelectItem>
|
<SelectItem value="enterprise">{t("admin.planEnterprise")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<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}>
|
<Button onClick={handleCreateUser} disabled={createUser.isPending || !newUsername || !newPassword}>
|
||||||
{createUser.isPending ? "Creating…" : "Create User"}
|
{createUser.isPending ? t("admin.creating") : t("admin.createUser")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -412,11 +413,11 @@ export default function Admin() {
|
|||||||
<Dialog open={!!editUser} onOpenChange={(open) => { if (!open) { setEditUser(null); setEditPassword(""); } }}>
|
<Dialog open={!!editUser} onOpenChange={(open) => { if (!open) { setEditUser(null); setEditPassword(""); } }}>
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit User — {editUser?.username}</DialogTitle>
|
<DialogTitle>{t("admin.editUser", { username: editUser?.username })}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Role</Label>
|
<Label>{t("admin.role")}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={editUser?.role || "user"}
|
value={editUser?.role || "user"}
|
||||||
onValueChange={(v) => setEditUser(editUser ? { ...editUser, role: v } : null)}
|
onValueChange={(v) => setEditUser(editUser ? { ...editUser, role: v } : null)}
|
||||||
@@ -425,13 +426,13 @@ export default function Admin() {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="user">User</SelectItem>
|
<SelectItem value="user">{t("admin.roleUser")}</SelectItem>
|
||||||
<SelectItem value="admin">Admin</SelectItem>
|
<SelectItem value="admin">{t("admin.roleAdmin")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Plan</Label>
|
<Label>{t("admin.plan")}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={editUser?.tier || "free"}
|
value={editUser?.tier || "free"}
|
||||||
onValueChange={(v) => setEditUser(editUser ? { ...editUser, tier: v } : null)}
|
onValueChange={(v) => setEditUser(editUser ? { ...editUser, tier: v } : null)}
|
||||||
@@ -440,36 +441,36 @@ export default function Admin() {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="free">Free</SelectItem>
|
<SelectItem value="free">{t("admin.planFree")}</SelectItem>
|
||||||
<SelectItem value="premium">Premium</SelectItem>
|
<SelectItem value="premium">{t("admin.planPremium")}</SelectItem>
|
||||||
<SelectItem value="enterprise">Enterprise</SelectItem>
|
<SelectItem value="enterprise">{t("admin.planEnterprise")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{editUser?.authProvider !== "oidc" ? (
|
{editUser?.authProvider !== "oidc" ? (
|
||||||
<div className="space-y-2 border-t pt-4">
|
<div className="space-y-2 border-t pt-4">
|
||||||
<Label>Set Password</Label>
|
<Label>{t("admin.setPassword")}</Label>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
value={editPassword}
|
value={editPassword}
|
||||||
onChange={(e) => setEditPassword(e.target.value)}
|
onChange={(e) => setEditPassword(e.target.value)}
|
||||||
placeholder="min. 6 characters"
|
placeholder={t("admin.minPasswordPlaceholder")}
|
||||||
data-testid="input-set-password"
|
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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 border-t pt-4 text-sm text-muted-foreground">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="flex-col sm:flex-row sm:justify-end gap-2">
|
<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
|
<Button
|
||||||
onClick={handleUpdateUser}
|
onClick={handleUpdateUser}
|
||||||
disabled={updateUser.isPending}
|
disabled={updateUser.isPending}
|
||||||
>
|
>
|
||||||
Save
|
{t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
{editUser?.authProvider !== "oidc" && (
|
{editUser?.authProvider !== "oidc" && (
|
||||||
<Button
|
<Button
|
||||||
@@ -477,7 +478,7 @@ export default function Admin() {
|
|||||||
onClick={handleSetUserPassword}
|
onClick={handleSetUserPassword}
|
||||||
disabled={setUserPassword.isPending || !editPassword}
|
disabled={setUserPassword.isPending || !editPassword}
|
||||||
>
|
>
|
||||||
{setUserPassword.isPending ? "Setting…" : "Set Password"}
|
{setUserPassword.isPending ? t("admin.setting") : t("admin.setPassword")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -487,15 +488,15 @@ export default function Admin() {
|
|||||||
<Dialog open={!!deleteConfirm} onOpenChange={(open) => !open && setDeleteConfirm(null)}>
|
<Dialog open={!!deleteConfirm} onOpenChange={(open) => !open && setDeleteConfirm(null)}>
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete User</DialogTitle>
|
<DialogTitle>{t("admin.deleteUser")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<p className="text-sm text-muted-foreground py-2">
|
<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>
|
</p>
|
||||||
<DialogFooter>
|
<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}>
|
<Button variant="destructive" onClick={handleDeleteUser} disabled={deleteUser.isPending}>
|
||||||
{deleteUser.isPending ? "Deleting…" : "Delete"}
|
{deleteUser.isPending ? t("detail.deleting") : t("common.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
useGetRatingDistribution,
|
useGetRatingDistribution,
|
||||||
GetTopToolsMetric
|
GetTopToolsMetric
|
||||||
} from "@workspace/api-client-react";
|
} from "@workspace/api-client-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Layout } from "@/components/layout";
|
import { Layout } from "@/components/layout";
|
||||||
|
import { GuideHelp } from "@/components/guide-help";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +18,7 @@ import {
|
|||||||
import { BarChart3, TrendingUp, Layers, Activity } from "lucide-react";
|
import { BarChart3, TrendingUp, Layers, Activity } from "lucide-react";
|
||||||
|
|
||||||
export default function Analytics() {
|
export default function Analytics() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: summary, isLoading: loadingSummary } = useGetAnalyticsSummary();
|
const { data: summary, isLoading: loadingSummary } = useGetAnalyticsSummary();
|
||||||
|
|
||||||
const { data: topTools, isLoading: loadingTopTools } = useGetTopTools({
|
const { data: topTools, isLoading: loadingTopTools } = useGetTopTools({
|
||||||
@@ -48,8 +51,11 @@ export default function Analytics() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6 pb-10">
|
<div className="space-y-6 pb-10">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Platform Analytics</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
|
||||||
<p className="text-muted-foreground">Macro-level insights into tool performance and community engagement.</p>
|
{t("analytics.title")}
|
||||||
|
<GuideHelp guide="analytics" label={t("analytics.title")} />
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">{t("analytics.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top KPI Cards */}
|
{/* Top KPI Cards */}
|
||||||
@@ -57,7 +63,7 @@ export default function Analytics() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<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" />
|
<Layers className="w-4 h-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
||||||
@@ -69,7 +75,7 @@ export default function Analytics() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<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" />
|
<Activity className="w-4 h-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
||||||
@@ -81,7 +87,7 @@ export default function Analytics() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<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" />
|
<BarChart3 className="w-4 h-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
||||||
@@ -93,7 +99,7 @@ export default function Analytics() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<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" />
|
<TrendingUp className="w-4 h-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
||||||
@@ -108,8 +114,8 @@ export default function Analytics() {
|
|||||||
{/* Top Tools Chart */}
|
{/* Top Tools Chart */}
|
||||||
<Card className="col-span-1 lg:col-span-2">
|
<Card className="col-span-1 lg:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Top 8 Tools by Combined Score</CardTitle>
|
<CardTitle>{t("analytics.top8Tools")}</CardTitle>
|
||||||
<CardDescription>Highest rated tools across the platform</CardDescription>
|
<CardDescription>{t("analytics.top8ToolsSub")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loadingTopTools ? (
|
{loadingTopTools ? (
|
||||||
@@ -136,8 +142,8 @@ export default function Analytics() {
|
|||||||
{/* Category Breakdown */}
|
{/* Category Breakdown */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Tools by Category</CardTitle>
|
<CardTitle>{t("analytics.toolsByCategory")}</CardTitle>
|
||||||
<CardDescription>Distribution of tools across categories</CardDescription>
|
<CardDescription>{t("analytics.toolsByCategorySub")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loadingCategories ? (
|
{loadingCategories ? (
|
||||||
@@ -149,7 +155,7 @@ export default function Analytics() {
|
|||||||
<PolarGrid stroke="hsl(var(--border))" />
|
<PolarGrid stroke="hsl(var(--border))" />
|
||||||
<PolarAngleAxis dataKey="category" tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} />
|
<PolarAngleAxis dataKey="category" tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} />
|
||||||
<PolarRadiusAxis angle={30} domain={[0, 'auto']} tick={false} axisLine={false} />
|
<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 />
|
<RechartsTooltip />
|
||||||
</RadarChart>
|
</RadarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -161,8 +167,8 @@ export default function Analytics() {
|
|||||||
{/* Rating Distributions */}
|
{/* Rating Distributions */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Global Rating Distribution</CardTitle>
|
<CardTitle>{t("analytics.globalRatingDistribution")}</CardTitle>
|
||||||
<CardDescription>How users are voting across all tools</CardDescription>
|
<CardDescription>{t("analytics.globalRatingDistributionSub")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loadingDistribution ? (
|
{loadingDistribution ? (
|
||||||
@@ -170,7 +176,7 @@ export default function Analytics() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<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">
|
<div className="h-[110px] w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={usefulnessData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
<BarChart data={usefulnessData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
||||||
@@ -184,7 +190,7 @@ export default function Analytics() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div className="h-[110px] w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={usabilityData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
<BarChart data={usabilityData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Link, useSearch } from "wouter";
|
|||||||
import { Layout } from "@/components/layout";
|
import { Layout } from "@/components/layout";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { RatingStars } from "@/components/rating-stars";
|
import { RatingStars } from "@/components/rating-stars";
|
||||||
|
import { GuideHelp } from "@/components/guide-help";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
@@ -101,7 +102,10 @@ export default function Compare() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6 pb-10">
|
<div className="space-y-6 pb-10">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("compare.title")}</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
|
||||||
|
{t("compare.title")}
|
||||||
|
<GuideHelp guide="vergleichen" label={t("compare.title")} />
|
||||||
|
</h1>
|
||||||
<p className="text-muted-foreground">{t("compare.subtitle", { count: list.length })}</p>
|
<p className="text-muted-foreground">{t("compare.subtitle", { count: list.length })}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -141,7 +145,7 @@ export default function Compare() {
|
|||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-medium">Usefulness</TableCell>
|
<TableCell className="font-medium">{t("detail.usefulness")}</TableCell>
|
||||||
{list.map((t) => (
|
{list.map((t) => (
|
||||||
<TableCell key={t.id} className={cn(isBest(t.avgUsefulness, bestUsefulness) && "bg-primary/5")}>
|
<TableCell key={t.id} className={cn(isBest(t.avgUsefulness, bestUsefulness) && "bg-primary/5")}>
|
||||||
<span className="tabular-nums">{fmt(t.avgUsefulness)}/5</span>
|
<span className="tabular-nums">{fmt(t.avgUsefulness)}/5</span>
|
||||||
@@ -149,7 +153,7 @@ export default function Compare() {
|
|||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-medium">Usability</TableCell>
|
<TableCell className="font-medium">{t("detail.usability")}</TableCell>
|
||||||
{list.map((t) => (
|
{list.map((t) => (
|
||||||
<TableCell key={t.id} className={cn(isBest(t.avgUsability, bestUsability) && "bg-primary/5")}>
|
<TableCell key={t.id} className={cn(isBest(t.avgUsability, bestUsability) && "bg-primary/5")}>
|
||||||
<span className="tabular-nums">{fmt(t.avgUsability)}/5</span>
|
<span className="tabular-nums">{fmt(t.avgUsability)}/5</span>
|
||||||
@@ -173,7 +177,7 @@ export default function Compare() {
|
|||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-medium">Features</TableCell>
|
<TableCell className="font-medium">{t("filter.features")}</TableCell>
|
||||||
{list.map((t) => (
|
{list.map((t) => (
|
||||||
<TableCell key={t.id} className="align-top">
|
<TableCell key={t.id} className="align-top">
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
@@ -186,7 +190,7 @@ export default function Compare() {
|
|||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-medium">Tags</TableCell>
|
<TableCell className="font-medium">{t("filter.tags")}</TableCell>
|
||||||
{list.map((t) => (
|
{list.map((t) => (
|
||||||
<TableCell key={t.id} className="align-top">
|
<TableCell key={t.id} className="align-top">
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState, createContext, useContext } from "react";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { Marked } from "marked";
|
import { Marked } from "marked";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -42,6 +43,35 @@ import {
|
|||||||
const DOCS_BASE = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/docs`;
|
const DOCS_BASE = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/docs`;
|
||||||
const REPO_URL = "https://git.kubebase.de/admin/tool-evaluator";
|
const REPO_URL = "https://git.kubebase.de/admin/tool-evaluator";
|
||||||
|
|
||||||
|
// Active docs version (null = current docs). Shared via context so every
|
||||||
|
// sub-view builds versioned links and file paths.
|
||||||
|
const DocsVersionContext = createContext<string | null>(null);
|
||||||
|
const useDocsVersion = () => useContext(DocsVersionContext);
|
||||||
|
|
||||||
|
// Full in-app URL for a docs path, prefixed with the active version.
|
||||||
|
function docsHref(version: string | null, path: string) {
|
||||||
|
return version === null ? `/docs/${path}` : `/docs/${version}/${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static file path under /docs, prefixed with the versioned snapshot dir.
|
||||||
|
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
|
// Types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -85,12 +115,22 @@ type Reference = { tags: TagGroup[]; schemas: SchemaModel[] };
|
|||||||
type ReleaseDoc = {
|
type ReleaseDoc = {
|
||||||
version: string;
|
version: string;
|
||||||
file: string;
|
file: string;
|
||||||
|
fileEn: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
|
titleEn: string | null;
|
||||||
date: string | null;
|
date: string | null;
|
||||||
hasReference: boolean;
|
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 };
|
type SearchEntry = { title: string; href: string; kind: string; text: string };
|
||||||
|
|
||||||
@@ -230,8 +270,11 @@ function isLinkableType(t: FieldType): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveTypeHref(t: FieldType): string | null {
|
function resolveTypeHref(t: FieldType): string | null {
|
||||||
if (t.kind === "ref") return `/docs/reference/schemas/${t.value}`;
|
const version = useDocsVersion();
|
||||||
if (t.kind === "array" && /^[A-Z]/.test(t.value)) return `/docs/reference/schemas/${t.value}`;
|
const href = (v: string | null) =>
|
||||||
|
v === null ? `/docs/reference/schemas/${t.value}` : `/docs/${v}/reference/schemas/${t.value}`;
|
||||||
|
if (t.kind === "ref") return href(version);
|
||||||
|
if (t.kind === "array" && /^[A-Z]/.test(t.value)) return href(version);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,6 +363,7 @@ function DocsNav({
|
|||||||
}) {
|
}) {
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const locale = useDocsLocale();
|
||||||
|
|
||||||
const navLink = (href: string) => {
|
const navLink = (href: string) => {
|
||||||
const active = location === href || (href !== "/docs" && location.startsWith(href));
|
const active = location === href || (href !== "/docs" && location.startsWith(href));
|
||||||
@@ -328,14 +372,14 @@ function DocsNav({
|
|||||||
|
|
||||||
const groups: { label: string; icon: LucideIcon; items: { href: string; label: string; active: boolean }[] }[] = [];
|
const groups: { label: string; icon: LucideIcon; items: { href: string; label: string; active: boolean }[] }[] = [];
|
||||||
|
|
||||||
if (handbook && handbook.length > 0 && version === null) {
|
if (handbook && handbook.length > 0) {
|
||||||
groups.push({
|
groups.push({
|
||||||
label: t("docs.guides"),
|
label: t("docs.guides"),
|
||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
items: handbook.map((p) => ({
|
items: handbook.map((p) => ({
|
||||||
href: `/docs/handbook/${p.slug}`,
|
href: docsHref(version, `handbook/${p.slug}`),
|
||||||
label: p.title,
|
label: localizedTitle(p.title, p.titleEn, locale),
|
||||||
active: navLink(`/docs/handbook/${p.slug}`),
|
active: navLink(docsHref(version, `handbook/${p.slug}`)),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -345,18 +389,18 @@ function DocsNav({
|
|||||||
label: t("docs.endpoints"),
|
label: t("docs.endpoints"),
|
||||||
icon: Server,
|
icon: Server,
|
||||||
items: reference.tags.map((tag) => ({
|
items: reference.tags.map((tag) => ({
|
||||||
href: `/docs/reference/endpoints/${tag.name}`,
|
href: docsHref(version, `reference/endpoints/${tag.name}`),
|
||||||
label: tag.name,
|
label: tag.name,
|
||||||
active: navLink(`/docs/reference/endpoints/${tag.name}`),
|
active: navLink(docsHref(version, `reference/endpoints/${tag.name}`)),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
groups.push({
|
groups.push({
|
||||||
label: t("docs.schemas"),
|
label: t("docs.schemas"),
|
||||||
icon: Library,
|
icon: Library,
|
||||||
items: reference.schemas.map((s) => ({
|
items: reference.schemas.map((s) => ({
|
||||||
href: `/docs/reference/schemas/${s.name}`,
|
href: docsHref(version, `reference/schemas/${s.name}`),
|
||||||
label: s.name,
|
label: s.name,
|
||||||
active: navLink(`/docs/reference/schemas/${s.name}`),
|
active: navLink(docsHref(version, `reference/schemas/${s.name}`)),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -365,9 +409,7 @@ function DocsNav({
|
|||||||
label: t("docs.releases"),
|
label: t("docs.releases"),
|
||||||
icon: Tag,
|
icon: Tag,
|
||||||
items: versions.map((v) => ({
|
items: versions.map((v) => ({
|
||||||
href: v.version === (version ?? versions[0]?.version) && version !== null
|
href: `/docs/releases/${v.version}`,
|
||||||
? `/docs/releases/${v.version}`
|
|
||||||
: `/docs/releases/${v.version}`,
|
|
||||||
label: v.version,
|
label: v.version,
|
||||||
active: navLink(`/docs/releases/${v.version}`),
|
active: navLink(`/docs/releases/${v.version}`),
|
||||||
})),
|
})),
|
||||||
@@ -519,7 +561,7 @@ function FieldTable({ fields }: { fields: Field[] }) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{f.required ? (
|
{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>
|
<span className="text-muted-foreground">–</span>
|
||||||
)}
|
)}
|
||||||
@@ -594,8 +636,8 @@ function EndpointTagView({ tag }: { tag: TagGroup }) {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50 text-left text-xs uppercase tracking-wider text-muted-foreground">
|
<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">{t("docs.name")}</th>
|
||||||
<th className="px-3 py-2 font-semibold">In</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.type")}</th>
|
||||||
<th className="px-3 py-2 font-semibold">{t("docs.required")}</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>
|
<th className="px-3 py-2 font-semibold">{t("docs.description")}</th>
|
||||||
@@ -608,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 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"><FieldTypeChip type={p.type} /></td>
|
||||||
<td className="px-3 py-1.5">
|
<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>
|
||||||
<td className="px-3 py-1.5 text-muted-foreground">
|
<td className="px-3 py-1.5 text-muted-foreground">
|
||||||
{p.description}
|
{p.description}
|
||||||
@@ -625,7 +667,7 @@ function EndpointTagView({ tag }: { tag: TagGroup }) {
|
|||||||
{ep.requestBody && (
|
{ep.requestBody && (
|
||||||
<div className="mb-3 rounded-lg border bg-muted/30 p-3">
|
<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">
|
<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>
|
</p>
|
||||||
<FieldTypeChip type={ep.requestBody.schema} />
|
<FieldTypeChip type={ep.requestBody.schema} />
|
||||||
</div>
|
</div>
|
||||||
@@ -662,6 +704,7 @@ function EndpointTagView({ tag }: { tag: TagGroup }) {
|
|||||||
function ReleasesView({ versions }: { versions: ReleaseDoc[] }) {
|
function ReleasesView({ versions }: { versions: ReleaseDoc[] }) {
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const locale = useDocsLocale();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col lg:flex-row gap-8">
|
<div className="flex flex-col lg:flex-row gap-8">
|
||||||
<div className="flex-1 min-w-0 max-w-3xl space-y-4">
|
<div className="flex-1 min-w-0 max-w-3xl space-y-4">
|
||||||
@@ -680,7 +723,7 @@ function ReleasesView({ versions }: { versions: ReleaseDoc[] }) {
|
|||||||
<FileText className="h-4 w-4 shrink-0 text-primary" />
|
<FileText className="h-4 w-4 shrink-0 text-primary" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<Badge variant="secondary">{v.version}</Badge>
|
||||||
</div>
|
</div>
|
||||||
{v.date && (
|
{v.date && (
|
||||||
@@ -690,7 +733,7 @@ function ReleasesView({ versions }: { versions: ReleaseDoc[] }) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -700,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 (
|
return (
|
||||||
<div className="flex flex-col lg:flex-row gap-8">
|
<div className="flex flex-col lg:flex-row gap-8">
|
||||||
<div className="flex-1 min-w-0 max-w-3xl">
|
<div className="flex-1 min-w-0 max-w-3xl">
|
||||||
@@ -708,7 +753,7 @@ function ReleaseNoteView({ version }: { version: string }) {
|
|||||||
<Tag className="h-4 w-4" />
|
<Tag className="h-4 w-4" />
|
||||||
<span className="font-mono">{version}</span>
|
<span className="font-mono">{version}</span>
|
||||||
<a
|
<a
|
||||||
href={`${REPO_URL}/tags/${version}`}
|
href={`${REPO_URL}/releases/tag/${version}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="inline-flex items-center gap-1 hover:text-foreground"
|
className="inline-flex items-center gap-1 hover:text-foreground"
|
||||||
@@ -716,15 +761,20 @@ function ReleaseNoteView({ version }: { version: string }) {
|
|||||||
<ExternalLink className="h-3.5 w-3.5" />
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<MarkdownView file={`releases/${version}.md`} />
|
<MarkdownView file={file} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HandbookView({ slug }: { slug: string }) {
|
function HandbookView({ slug, pages }: { slug: string; pages: HandbookPage[] | null }) {
|
||||||
const handbookFile = `${slug}.md`;
|
const version = useDocsVersion();
|
||||||
return <MarkdownView file={`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} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -732,7 +782,9 @@ function HandbookView({ slug }: { slug: string }) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function useDocsSearch(query: 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(() => {
|
const results = useMemo(() => {
|
||||||
if (!query.trim() || !data) return [];
|
if (!query.trim() || !data) return [];
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
@@ -803,9 +855,11 @@ export default function Docs() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data: releases, error: releasesError } = useJson<ReleaseDoc[]>(`${DOCS_BASE}/index.json`);
|
const { data: releases, error: releasesError } = useJson<ReleaseDoc[]>(`${DOCS_BASE}/index.json`);
|
||||||
const { data: handbook, error: handbookError } = useJson<HandbookPage[]>(
|
|
||||||
version === null ? `${DOCS_BASE}/handbook/index.json` : null,
|
const activeRelease = releases?.find((r) => r.version === version) ?? null;
|
||||||
);
|
const handbookAvailable = version === null || activeRelease?.hasHandbook;
|
||||||
|
const handbookUrl = handbookAvailable ? `${DOCS_BASE}/${docsFile(version, "handbook/index.json")}` : null;
|
||||||
|
const { data: handbook, error: handbookError } = useJson<HandbookPage[]>(handbookUrl);
|
||||||
|
|
||||||
const isCurrentVersion =
|
const isCurrentVersion =
|
||||||
version === null ||
|
version === null ||
|
||||||
@@ -814,7 +868,7 @@ export default function Docs() {
|
|||||||
|
|
||||||
const refUrl = version === null
|
const refUrl = version === null
|
||||||
? `${DOCS_BASE}/reference.json`
|
? `${DOCS_BASE}/reference.json`
|
||||||
: releases?.find((r) => r.version === version)?.hasReference
|
: activeRelease?.hasReference
|
||||||
? `${DOCS_BASE}/versions/${version}.json`
|
? `${DOCS_BASE}/versions/${version}.json`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -832,20 +886,13 @@ export default function Docs() {
|
|||||||
return null;
|
return null;
|
||||||
}, [releases, versionInfo]);
|
}, [releases, versionInfo]);
|
||||||
|
|
||||||
// Redirect old-style /docs/vX.Y.Z to /docs/releases/vX.Y.Z
|
|
||||||
useEffect(() => {
|
|
||||||
if (version !== null && path.length === 0) {
|
|
||||||
setLocation(`/docs/releases/${version}`, { replace: true });
|
|
||||||
}
|
|
||||||
}, [version, path, setLocation]);
|
|
||||||
|
|
||||||
const handleVersionChange = (v: string | null) => {
|
const handleVersionChange = (v: string | null) => {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
if (v === null || v === currentVersion) {
|
if (v === null || v === currentVersion) {
|
||||||
setLocation("/docs");
|
setLocation("/docs");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLocation(`/docs/releases/${v}`);
|
setLocation(`/docs/${v}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- route resolution ----
|
// ---- route resolution ----
|
||||||
@@ -864,18 +911,23 @@ export default function Docs() {
|
|||||||
let content: React.ReactNode = null;
|
let content: React.ReactNode = null;
|
||||||
|
|
||||||
if (version !== null && path.length === 0) {
|
if (version !== null && path.length === 0) {
|
||||||
content = <ReleaseNoteView version={version} />;
|
content =
|
||||||
|
handbook && handbook.length > 0 ? (
|
||||||
|
<HandbookView slug={handbook[0].slug} pages={handbook} />
|
||||||
|
) : (
|
||||||
|
<ReleaseNoteView version={version} doc={activeRelease} />
|
||||||
|
);
|
||||||
} else if (section === "home") {
|
} else if (section === "home") {
|
||||||
content =
|
content =
|
||||||
handbook && handbook.length > 0 ? (
|
handbook && handbook.length > 0 ? (
|
||||||
<HandbookView slug={handbook[0].slug} />
|
<HandbookView slug={handbook[0].slug} pages={handbook} />
|
||||||
) : releases && releases.length > 0 ? (
|
) : releases && releases.length > 0 ? (
|
||||||
<ReleasesView versions={releases} />
|
<ReleasesView versions={releases} />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
|
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
|
||||||
);
|
);
|
||||||
} else if (section === "handbook" && param) {
|
} else if (section === "handbook" && param) {
|
||||||
content = <HandbookView slug={param} />;
|
content = <HandbookView slug={param} pages={handbook} />;
|
||||||
} else if (section === "reference" && param === "endpoints" && path[2]) {
|
} else if (section === "reference" && param === "endpoints" && path[2]) {
|
||||||
const tag = reference?.tags.find(
|
const tag = reference?.tags.find(
|
||||||
(tg) => tg.name.toLowerCase() === path[2].toLowerCase(),
|
(tg) => tg.name.toLowerCase() === path[2].toLowerCase(),
|
||||||
@@ -913,7 +965,7 @@ export default function Docs() {
|
|||||||
{reference.tags.map((tg) => (
|
{reference.tags.map((tg) => (
|
||||||
<Link
|
<Link
|
||||||
key={tg.name}
|
key={tg.name}
|
||||||
href={`/docs/reference/endpoints/${tg.name}`}
|
href={docsHref(version, `reference/endpoints/${tg.name}`)}
|
||||||
className="rounded-lg border p-3 text-sm hover:bg-accent/50"
|
className="rounded-lg border p-3 text-sm hover:bg-accent/50"
|
||||||
>
|
>
|
||||||
<span className="font-medium">{tg.name}</span>
|
<span className="font-medium">{tg.name}</span>
|
||||||
@@ -930,7 +982,7 @@ export default function Docs() {
|
|||||||
{reference.schemas.map((s) => (
|
{reference.schemas.map((s) => (
|
||||||
<Link
|
<Link
|
||||||
key={s.name}
|
key={s.name}
|
||||||
href={`/docs/reference/schemas/${s.name}`}
|
href={docsHref(version, `reference/schemas/${s.name}`)}
|
||||||
className="rounded-lg border p-3 text-sm font-mono hover:bg-accent/50"
|
className="rounded-lg border p-3 text-sm font-mono hover:bg-accent/50"
|
||||||
>
|
>
|
||||||
{s.name}
|
{s.name}
|
||||||
@@ -945,7 +997,7 @@ export default function Docs() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (section === "releases" && param) {
|
} 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") {
|
} else if (section === "releases") {
|
||||||
content = releases ? <ReleasesView versions={releases} /> : <Skeleton className="h-64 w-full" />;
|
content = releases ? <ReleasesView versions={releases} /> : <Skeleton className="h-64 w-full" />;
|
||||||
} else {
|
} else {
|
||||||
@@ -955,75 +1007,78 @@ export default function Docs() {
|
|||||||
const showSearch = version === null && section !== "releases";
|
const showSearch = version === null && section !== "releases";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex flex-col">
|
<DocsVersionContext.Provider value={version}>
|
||||||
<header className="border-b bg-card shrink-0">
|
<div className="min-h-screen bg-background flex flex-col">
|
||||||
<div className="mx-auto max-w-6xl px-4 md:px-6 h-14 flex items-center justify-between gap-3">
|
<header className="border-b bg-card shrink-0">
|
||||||
<div className="flex items-center gap-1 min-w-0">
|
<div className="mx-auto max-w-6xl px-4 md:px-6 h-14 flex items-center justify-between gap-3">
|
||||||
<Sheet open={navOpen} onOpenChange={setNavOpen}>
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
<SheetTrigger asChild>
|
<Sheet open={navOpen} onOpenChange={setNavOpen}>
|
||||||
<Button
|
<SheetTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className="lg:hidden -ml-1 shrink-0"
|
size="icon"
|
||||||
title={t("docs.nav")}
|
className="lg:hidden -ml-1 shrink-0"
|
||||||
data-testid="button-docs-nav-mobile"
|
title={t("docs.nav")}
|
||||||
>
|
data-testid="button-docs-nav-mobile"
|
||||||
<Menu className="h-5 w-5" />
|
>
|
||||||
</Button>
|
<Menu className="h-5 w-5" />
|
||||||
</SheetTrigger>
|
</Button>
|
||||||
<SheetContent side="left" className="w-80 overflow-y-auto p-4">
|
</SheetTrigger>
|
||||||
<SheetTitle className="sr-only">{t("docs.nav")}</SheetTitle>
|
<SheetContent side="left" className="w-80 overflow-y-auto p-4">
|
||||||
|
<SheetTitle className="sr-only">{t("docs.nav")}</SheetTitle>
|
||||||
|
<DocsNav
|
||||||
|
version={version}
|
||||||
|
handbook={handbook}
|
||||||
|
reference={reference}
|
||||||
|
versions={releases ?? []}
|
||||||
|
/>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center gap-2 text-primary font-bold text-lg min-w-0"
|
||||||
|
data-testid="link-docs-back"
|
||||||
|
>
|
||||||
|
<Wrench className="w-5 h-5 shrink-0" />
|
||||||
|
<span className="truncate">toolr</span>
|
||||||
|
<span className="hidden md:inline-flex items-center gap-1 text-xs font-normal text-muted-foreground border-l pl-2 ml-1">
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
|
{t("docs.backToApp")}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-6xl w-full flex-1 space-y-6 px-4 md:px-6 py-6 pb-12">
|
||||||
|
<DocsHeader
|
||||||
|
versions={releases ?? []}
|
||||||
|
activeVersion={headerVersion}
|
||||||
|
onVersionChange={handleVersionChange}
|
||||||
|
onSearchChange={showSearch ? setSearchQuery : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showSearch && <SearchOverlay query={searchQuery} onClose={() => setSearchQuery("")} />}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[240px_minmax(0,1fr)] gap-8">
|
||||||
|
<aside className="hidden lg:block">
|
||||||
|
<div className="sticky top-20 max-h-[calc(100vh-6rem)] overflow-auto">
|
||||||
<DocsNav
|
<DocsNav
|
||||||
version={version}
|
version={version}
|
||||||
handbook={handbook}
|
handbook={handbook}
|
||||||
reference={reference}
|
reference={reference}
|
||||||
versions={releases ?? []}
|
versions={releases ?? []}
|
||||||
/>
|
/>
|
||||||
</SheetContent>
|
</div>
|
||||||
</Sheet>
|
</aside>
|
||||||
<Link
|
<div className="min-w-0">{content}</div>
|
||||||
href="/"
|
|
||||||
className="inline-flex items-center gap-2 text-primary font-bold text-lg min-w-0"
|
|
||||||
data-testid="link-docs-back"
|
|
||||||
>
|
|
||||||
<Wrench className="w-5 h-5 shrink-0" />
|
|
||||||
<span className="truncate">toolr</span>
|
|
||||||
<span className="hidden md:inline-flex items-center gap-1 text-xs font-normal text-muted-foreground border-l pl-2 ml-1">
|
|
||||||
<ArrowLeft className="w-3.5 h-3.5" />
|
|
||||||
{t("docs.backToApp")}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="mx-auto max-w-6xl w-full flex-1 space-y-6 px-4 md:px-6 py-6 pb-12">
|
|
||||||
<DocsHeader
|
|
||||||
versions={releases ?? []}
|
|
||||||
activeVersion={headerVersion}
|
|
||||||
onVersionChange={handleVersionChange}
|
|
||||||
onSearchChange={showSearch ? setSearchQuery : undefined}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showSearch && <SearchOverlay query={searchQuery} onClose={() => setSearchQuery("")} />}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[240px_minmax(0,1fr)] gap-8">
|
|
||||||
<aside className="hidden lg:block">
|
|
||||||
<div className="sticky top-20 max-h-[calc(100vh-6rem)] overflow-auto">
|
|
||||||
<DocsNav
|
|
||||||
version={version}
|
|
||||||
handbook={handbook}
|
|
||||||
reference={reference}
|
|
||||||
versions={releases ?? []}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
<div className="min-w-0">{content}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DocsVersionContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Layout } from "@/components/layout";
|
import { Layout } from "@/components/layout";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -11,6 +12,7 @@ import { customFetch } from "@workspace/api-client-react";
|
|||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
export default function RedundancyPage() {
|
export default function RedundancyPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [data, setData] = useState<any[] | null>(null);
|
const [data, setData] = useState<any[] | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -28,10 +30,10 @@ export default function RedundancyPage() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ toolId, relatedToolId, betterToolId }),
|
body: JSON.stringify({ toolId, relatedToolId, betterToolId }),
|
||||||
});
|
});
|
||||||
toast({ title: "Evaluation saved" });
|
toast({ title: t("redundancy.toastEvalSaved") });
|
||||||
setData(await customFetch<any[]>("/api/admin/redundancy"));
|
setData(await customFetch<any[]>("/api/admin/redundancy"));
|
||||||
} catch {
|
} catch {
|
||||||
toast({ title: "Failed to save evaluation", variant: "destructive" });
|
toast({ title: t("redundancy.toastEvalFailed"), variant: "destructive" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,10 +46,10 @@ export default function RedundancyPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<AlertTriangle className="w-6 h-6 text-amber-500" />
|
<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">{t("redundancy.title")}</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">
|
<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>
|
</p>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -61,12 +63,12 @@ export default function RedundancyPage() {
|
|||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold capitalize">{group.category}</h2>
|
<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>
|
</div>
|
||||||
{group.totalMonthlyCost > 0 && (
|
{group.totalMonthlyCost > 0 && (
|
||||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
<DollarSign className="w-3 h-3" />
|
<DollarSign className="w-3 h-3" />
|
||||||
{group.totalMonthlyCost.toFixed(2)}/mo total
|
{group.totalMonthlyCost.toFixed(2)}{t("redundancy.totalMonthly")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -82,12 +84,12 @@ export default function RedundancyPage() {
|
|||||||
<span className="font-medium">{tool.name}</span>
|
<span className="font-medium">{tool.name}</span>
|
||||||
{tool.costs?.length > 0 && tool.totalMonthly > 0 && (
|
{tool.costs?.length > 0 && tool.totalMonthly > 0 && (
|
||||||
<Badge variant="outline" className="text-[10px] px-1 py-0 shrink-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>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground flex-wrap">
|
<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 && (
|
{tool.avgCombined != null && (
|
||||||
<>
|
<>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
@@ -104,7 +106,7 @@ export default function RedundancyPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -114,7 +116,7 @@ export default function RedundancyPage() {
|
|||||||
|
|
||||||
{group.pairs.length > 0 && (
|
{group.pairs.length > 0 && (
|
||||||
<div className="space-y-3">
|
<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) => (
|
{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" : ""}>
|
<Card key={i} className={pair.recommendation.certainty === "high" ? "border-green-300" : pair.recommendation.certainty === "medium" ? "border-amber-200" : ""}>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
@@ -126,11 +128,11 @@ export default function RedundancyPage() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
{pair.a.avgCombined != null ? pair.a.avgCombined.toFixed(1) : "N/A"} ★
|
{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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center shrink-0">
|
<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">
|
<div className="flex items-center gap-1 justify-center mt-0.5">
|
||||||
<Progress value={pair.overlap} className="w-12 h-1.5" />
|
<Progress value={pair.overlap} className="w-12 h-1.5" />
|
||||||
<span className="text-[10px] text-muted-foreground">{pair.overlap}%</span>
|
<span className="text-[10px] text-muted-foreground">{pair.overlap}%</span>
|
||||||
@@ -142,7 +144,7 @@ export default function RedundancyPage() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
{pair.b.avgCombined != null ? pair.b.avgCombined.toFixed(1) : "N/A"} ★
|
{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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,7 +190,7 @@ export default function RedundancyPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import {
|
|||||||
import { customFetch } from "@workspace/api-client-react";
|
import { customFetch } from "@workspace/api-client-react";
|
||||||
import { recordRecentTool } from "@/lib/recent-tools";
|
import { recordRecentTool } from "@/lib/recent-tools";
|
||||||
import { FieldHelp } from "@/components/field-help";
|
import { FieldHelp } from "@/components/field-help";
|
||||||
|
import { GuideHelp } from "@/components/guide-help";
|
||||||
|
|
||||||
const ratingSchema = z.object({
|
const ratingSchema = z.object({
|
||||||
usefulness: z.number().min(1).max(5),
|
usefulness: z.number().min(1).max(5),
|
||||||
@@ -120,24 +121,24 @@ export default function ToolDetail() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ relatedToolId: Number(linkToolId), relationType: linkType, notes: linkNotes || undefined }),
|
body: JSON.stringify({ relatedToolId: Number(linkToolId), relationType: linkType, notes: linkNotes || undefined }),
|
||||||
});
|
});
|
||||||
toast({ title: "Relation created" });
|
toast({ title: t("detail.toastRelationCreated") });
|
||||||
setLinkDialogOpen(false);
|
setLinkDialogOpen(false);
|
||||||
setLinkToolId("");
|
setLinkToolId("");
|
||||||
setLinkNotes("");
|
setLinkNotes("");
|
||||||
setLinkType("similar");
|
setLinkType("similar");
|
||||||
setSimilarData(await customFetch<any>(`/api/tools/${id}/similar`));
|
setSimilarData(await customFetch<any>(`/api/tools/${id}/similar`));
|
||||||
} catch (err: any) {
|
} 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) {
|
async function handleDeleteRelation(relationId: number) {
|
||||||
try {
|
try {
|
||||||
await customFetch(`/api/tools/relations/${relationId}`, { method: "DELETE" });
|
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`));
|
setSimilarData(await customFetch<any>(`/api/tools/${id}/similar`));
|
||||||
} catch {
|
} catch {
|
||||||
toast({ title: "Failed to delete relation", variant: "destructive" });
|
toast({ title: t("detail.toastRelationDeleteFailed"), variant: "destructive" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,12 +163,12 @@ export default function ToolDetail() {
|
|||||||
if (costAmount) body.cost = costAmount;
|
if (costAmount) body.cost = costAmount;
|
||||||
try {
|
try {
|
||||||
await customFetch(url, { method, body: JSON.stringify(body) });
|
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);
|
setCostDialogOpen(false);
|
||||||
resetCostForm();
|
resetCostForm();
|
||||||
setCosts(await customFetch<any[]>(`/api/tools/${id}/costs`));
|
setCosts(await customFetch<any[]>(`/api/tools/${id}/costs`));
|
||||||
} catch (err: any) {
|
} 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" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,10 +194,10 @@ export default function ToolDetail() {
|
|||||||
async function handleDeleteCost(costId: number) {
|
async function handleDeleteCost(costId: number) {
|
||||||
try {
|
try {
|
||||||
await customFetch(`/api/costs/${costId}`, { method: "DELETE" });
|
await customFetch(`/api/costs/${costId}`, { method: "DELETE" });
|
||||||
toast({ title: "Cost deleted" });
|
toast({ title: t("detail.toastCostDeleted") });
|
||||||
setCosts(await customFetch<any[]>(`/api/tools/${id}/costs`));
|
setCosts(await customFetch<any[]>(`/api/tools/${id}/costs`));
|
||||||
} catch {
|
} catch {
|
||||||
toast({ title: "Failed to delete cost", variant: "destructive" });
|
toast({ title: t("detail.toastCostDeleteFailed"), variant: "destructive" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,8 +237,8 @@ export default function ToolDetail() {
|
|||||||
createRating.mutate({ id, data }, {
|
createRating.mutate({ id, data }, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
title: "Rating submitted",
|
title: t("detail.toastRatingSubmitted"),
|
||||||
description: "Thank you for your feedback!",
|
description: t("detail.toastRatingThanks"),
|
||||||
});
|
});
|
||||||
setIsReviewFormOpen(false);
|
setIsReviewFormOpen(false);
|
||||||
form.reset();
|
form.reset();
|
||||||
@@ -251,8 +252,8 @@ export default function ToolDetail() {
|
|||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast({
|
toast({
|
||||||
title: "Failed to submit rating",
|
title: t("detail.toastRatingFailed"),
|
||||||
description: error.data?.error || error.message || "An unexpected error occurred.",
|
description: error.data?.error || error.message || t("detail.unexpectedError"),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -263,9 +264,9 @@ export default function ToolDetail() {
|
|||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="flex flex-col items-center justify-center py-20">
|
<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">
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
@@ -287,7 +288,7 @@ export default function ToolDetail() {
|
|||||||
{ id },
|
{ id },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({ title: "Tool deleted" });
|
toast({ title: t("detail.toastToolDeleted") });
|
||||||
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||||
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
||||||
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
|
||||||
@@ -296,7 +297,7 @@ export default function ToolDetail() {
|
|||||||
setLocation("/tools");
|
setLocation("/tools");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
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);
|
setDeleteOpen(false);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -308,7 +309,7 @@ export default function ToolDetail() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6 max-w-5xl mx-auto pb-10">
|
<div className="space-y-6 max-w-5xl mx-auto pb-10">
|
||||||
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
|
<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>
|
</Button>
|
||||||
|
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
@@ -407,7 +408,7 @@ export default function ToolDetail() {
|
|||||||
|
|
||||||
{tool.features && tool.features.length > 0 && (
|
{tool.features && tool.features.length > 0 && (
|
||||||
<div className="pt-6 border-t">
|
<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">
|
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
{tool.features.map((feature, i) => (
|
{tool.features.map((feature, i) => (
|
||||||
<li key={i} className="flex items-start gap-2">
|
<li key={i} className="flex items-start gap-2">
|
||||||
@@ -422,17 +423,17 @@ export default function ToolDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-10">Tool not found.</div>
|
<div className="text-center py-10">{t("detail.toolNotFound")}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Similar Tools Section */}
|
{/* Similar Tools Section */}
|
||||||
{tool && (
|
{tool && (
|
||||||
<div className="space-y-4 mt-8">
|
<div className="space-y-4 mt-8">
|
||||||
<div className="flex items-center justify-between">
|
<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 && (
|
{isAdmin && (
|
||||||
<Button variant="outline" size="sm" onClick={() => setLinkDialogOpen(true)} className="gap-2">
|
<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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -500,7 +501,7 @@ export default function ToolDetail() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>Score: {item.score}</span>
|
<span>{t("detail.score")}: {item.score}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -509,7 +510,7 @@ export default function ToolDetail() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : similarData && similarData.manual.length === 0 && similarData.auto.length === 0 ? (
|
) : 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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -518,44 +519,44 @@ export default function ToolDetail() {
|
|||||||
<Dialog open={linkDialogOpen} onOpenChange={setLinkDialogOpen}>
|
<Dialog open={linkDialogOpen} onOpenChange={setLinkDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Link Similar Tool</DialogTitle>
|
<DialogTitle>{t("detail.linkSimilarTool")}</DialogTitle>
|
||||||
<DialogDescription>Manually link this tool to another tool.</DialogDescription>
|
<DialogDescription>{t("detail.linkSimilarToolSub")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
<div className="space-y-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
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Enter target tool ID"
|
placeholder={t("detail.toolIdPlaceholder")}
|
||||||
value={linkToolId}
|
value={linkToolId}
|
||||||
onChange={(e) => setLinkToolId(e.target.value)}
|
onChange={(e) => setLinkToolId(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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}>
|
<Select value={linkType} onValueChange={setLinkType}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="similar">Similar</SelectItem>
|
<SelectItem value="similar">{t("detail.relationSimilar")}</SelectItem>
|
||||||
<SelectItem value="replaces">Replaces</SelectItem>
|
<SelectItem value="replaces">{t("detail.relationReplaces")}</SelectItem>
|
||||||
<SelectItem value="superseded_by">Superseded By</SelectItem>
|
<SelectItem value="superseded_by">{t("detail.relationSupersededBy")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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
|
<Textarea
|
||||||
placeholder="Why are these tools related?"
|
placeholder={t("detail.notesPlaceholder")}
|
||||||
value={linkNotes}
|
value={linkNotes}
|
||||||
onChange={(e) => setLinkNotes(e.target.value)}
|
onChange={(e) => setLinkNotes(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setLinkDialogOpen(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setLinkDialogOpen(false)}>{t("common.cancel")}</Button>
|
||||||
<Button onClick={handleCreateRelation} disabled={!linkToolId}>Create Link</Button>
|
<Button onClick={handleCreateRelation} disabled={!linkToolId}>{t("detail.createLink")}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -586,7 +587,7 @@ export default function ToolDetail() {
|
|||||||
{c.billingPeriod && <span className="text-[10px] text-muted-foreground uppercase">{c.billingPeriod}</span>}
|
{c.billingPeriod && <span className="text-[10px] text-muted-foreground uppercase">{c.billingPeriod}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-bold">
|
<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>
|
</div>
|
||||||
{c.notes && <p className="text-xs text-muted-foreground mt-1 italic">{c.notes}</p>}
|
{c.notes && <p className="text-xs text-muted-foreground mt-1 italic">{c.notes}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -606,7 +607,7 @@ export default function ToolDetail() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -615,42 +616,45 @@ export default function ToolDetail() {
|
|||||||
<Dialog open={costDialogOpen} onOpenChange={(o) => { if (!o) setCostDialogOpen(false); }}>
|
<Dialog open={costDialogOpen} onOpenChange={(o) => { if (!o) setCostDialogOpen(false); }}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editCost ? t("detail.edit") + " " + t("detail.costs") : t("detail.addCost")}</DialogTitle>
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<DialogDescription>Manage license cost information for this tool.</DialogDescription>
|
{editCost ? t("detail.edit") + " " + t("detail.costs") : t("detail.addCost")}
|
||||||
|
<GuideHelp guide="kosten" label={t("detail.costs")} />
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>{t("detail.costDialogSub")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
<div className="space-y-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}>
|
<Select value={costLicenseType} onValueChange={setCostLicenseType}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="free">Free</SelectItem>
|
<SelectItem value="free">{t("detail.licenseFree")}</SelectItem>
|
||||||
<SelectItem value="subscription">Subscription</SelectItem>
|
<SelectItem value="subscription">{t("detail.licenseSubscription")}</SelectItem>
|
||||||
<SelectItem value="one_time">One-Time</SelectItem>
|
<SelectItem value="one_time">{t("detail.licenseOneTime")}</SelectItem>
|
||||||
<SelectItem value="usage_based">Usage-Based</SelectItem>
|
<SelectItem value="usage_based">{t("detail.licenseUsageBased")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{costLicenseType === "subscription" && (
|
{costLicenseType === "subscription" && (
|
||||||
<div className="space-y-2">
|
<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}>
|
<Select value={costBillingPeriod} onValueChange={setCostBillingPeriod}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="monthly">Monthly</SelectItem>
|
<SelectItem value="monthly">{t("detail.billingMonthly")}</SelectItem>
|
||||||
<SelectItem value="quarterly">Quarterly</SelectItem>
|
<SelectItem value="quarterly">{t("detail.billingQuarterly")}</SelectItem>
|
||||||
<SelectItem value="yearly">Yearly</SelectItem>
|
<SelectItem value="yearly">{t("detail.billingYearly")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<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)} />
|
<Input type="number" step="0.01" placeholder="0.00" value={costAmount} onChange={(e) => setCostAmount(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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}>
|
<Select value={costCurrency} onValueChange={setCostCurrency}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -663,13 +667,13 @@ export default function ToolDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Notes</label>
|
<label className="text-sm font-medium">{t("detail.costNotes")}</label>
|
||||||
<Textarea placeholder="Billing details, contract info..." value={costNotes} onChange={(e) => setCostNotes(e.target.value)} />
|
<Textarea placeholder={t("detail.costNotesPlaceholder")} value={costNotes} onChange={(e) => setCostNotes(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setCostDialogOpen(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setCostDialogOpen(false)}>{t("common.cancel")}</Button>
|
||||||
<Button onClick={handleSaveCost}>Save</Button>
|
<Button onClick={handleSaveCost}>{t("common.save")}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -740,9 +744,9 @@ export default function ToolDetail() {
|
|||||||
<YAxis domain={[0, 5]} tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} axisLine={false} tickLine={false} />
|
<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") : "")} />
|
<Tooltip labelFormatter={(_, payload) => (payload?.[0] ? format(new Date(payload[0].payload.date), "dd.MM.yyyy HH:mm") : "")} />
|
||||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
<Line type="monotone" dataKey="combined" name="Combined" stroke="hsl(var(--primary))" strokeWidth={2} dot={false} />
|
<Line type="monotone" dataKey="combined" name={t("detail.score")} 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="usefulness" name={t("detail.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="usability" name={t("detail.usability")} stroke="hsl(var(--warning))" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -765,8 +769,11 @@ export default function ToolDetail() {
|
|||||||
{isReviewFormOpen && (
|
{isReviewFormOpen && (
|
||||||
<Card className="border-primary shadow-sm">
|
<Card className="border-primary shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t("detail.addReview")}</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
<CardDescription>Share your experience with {tool.name}</CardDescription>
|
{t("detail.addReview")}
|
||||||
|
<GuideHelp guide="bewerten" label={t("detail.addReview")} />
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t("detail.shareExperience", { name: tool.name })}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -822,12 +829,12 @@ export default function ToolDetail() {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
Comment (Optional)
|
{t("detail.commentOptional")}
|
||||||
<FieldHelp schema="RatingInput" field="comment">Comment</FieldHelp>
|
<FieldHelp schema="RatingInput" field="comment">{t("detail.commentLabel")}</FieldHelp>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="What do you think about this tool?"
|
placeholder={t("detail.commentPlaceholder")}
|
||||||
className="resize-none min-h-[100px]"
|
className="resize-none min-h-[100px]"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
@@ -843,11 +850,11 @@ export default function ToolDetail() {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
Name (Optional)
|
{t("detail.nameOptional")}
|
||||||
<FieldHelp schema="RatingInput" field="reviewerName">Name</FieldHelp>
|
<FieldHelp schema="RatingInput" field="reviewerName">{t("detail.nameLabel")}</FieldHelp>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Anonymous" {...field} />
|
<Input placeholder={t("detail.anonymousPlaceholder")} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -861,7 +868,7 @@ export default function ToolDetail() {
|
|||||||
onClick={() => setIsReviewFormOpen(false)}
|
onClick={() => setIsReviewFormOpen(false)}
|
||||||
disabled={createRating.isPending}
|
disabled={createRating.isPending}
|
||||||
>
|
>
|
||||||
Cancel
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={createRating.isPending}>
|
<Button type="submit" disabled={createRating.isPending}>
|
||||||
{createRating.isPending ? t("common.loading") : t("detail.submit")}
|
{createRating.isPending ? t("common.loading") : t("detail.submit")}
|
||||||
@@ -884,7 +891,7 @@ export default function ToolDetail() {
|
|||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex justify-between items-start mb-4">
|
<div className="flex justify-between items-start mb-4">
|
||||||
<div>
|
<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">
|
<span className="text-muted-foreground text-sm ml-2">
|
||||||
{format(new Date(rating.createdAt), "MMM d, yyyy")}
|
{format(new Date(rating.createdAt), "MMM d, yyyy")}
|
||||||
</span>
|
</span>
|
||||||
@@ -914,7 +921,7 @@ export default function ToolDetail() {
|
|||||||
<div className="text-center py-12 bg-muted/30 border border-dashed rounded-xl">
|
<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" />
|
<Star className="w-12 h-12 text-muted-foreground/30 mx-auto mb-3" />
|
||||||
<h4 className="text-lg font-medium">{t("detail.noReviews")}</h4>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,28 +31,35 @@ import { FeatureInput } from "@/components/feature-input";
|
|||||||
import { TagInput } from "@/components/tag-input";
|
import { TagInput } from "@/components/tag-input";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { FieldHelp } from "@/components/field-help";
|
import { FieldHelp } from "@/components/field-help";
|
||||||
|
import { GuideHelp } from "@/components/guide-help";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const toolSchema = z.object({
|
type ToolFormValues = z.infer<ReturnType<typeof buildToolSchema>>;
|
||||||
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<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() {
|
export default function ToolEdit() {
|
||||||
const [match, params] = useRoute("/tools/:id/edit");
|
const [match, params] = useRoute("/tools/:id/edit");
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const id = parseInt(params?.id || "0", 10);
|
const id = parseInt(params?.id || "0", 10);
|
||||||
|
const { t } = useTranslation();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const updateTool = useUpdateTool();
|
const updateTool = useUpdateTool();
|
||||||
const { isAuthenticated, isAdmin } = useAuth();
|
const { isAuthenticated, isAdmin } = useAuth();
|
||||||
|
|
||||||
|
const toolSchema = buildToolSchema(t);
|
||||||
|
|
||||||
const { data: tool, isLoading } = useGetTool(id, {
|
const { data: tool, isLoading } = useGetTool(id, {
|
||||||
query: { enabled: !!id, queryKey: getGetToolQueryKey(id) },
|
query: { enabled: !!id, queryKey: getGetToolQueryKey(id) },
|
||||||
});
|
});
|
||||||
@@ -107,7 +114,7 @@ export default function ToolEdit() {
|
|||||||
{ id, data: payload },
|
{ id, data: payload },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
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: getGetToolQueryKey(id) });
|
||||||
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||||
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
||||||
@@ -119,8 +126,8 @@ export default function ToolEdit() {
|
|||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
toast({
|
toast({
|
||||||
title: "Failed to update tool",
|
title: t("toolForm.toastUpdateFailed"),
|
||||||
description: err.data?.error || err.message || "An unexpected error occurred.",
|
description: err.data?.error || err.message || t("detail.unexpectedError"),
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -137,13 +144,13 @@ export default function ToolEdit() {
|
|||||||
<div className="max-w-3xl mx-auto space-y-6 pb-10">
|
<div className="max-w-3xl mx-auto space-y-6 pb-10">
|
||||||
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
|
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
|
||||||
<Link href={`/tools/${id}`}>
|
<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>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Edit Tool</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("toolForm.editTitle")}</h1>
|
||||||
<p className="text-muted-foreground">Update tool details and metadata.</p>
|
<p className="text-muted-foreground">{t("toolForm.editSubtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -159,9 +166,10 @@ export default function ToolEdit() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Pencil className="w-5 h-5 text-primary" />
|
<Pencil className="w-5 h-5 text-primary" />
|
||||||
Tool Details
|
{t("toolForm.toolDetails")}
|
||||||
|
<GuideHelp guide="tool-bearbeiten" label={t("toolForm.toolDetails")} />
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Modify the tool information below.</CardDescription>
|
<CardDescription>{t("toolForm.toolDetailsEditSub")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -173,11 +181,11 @@ export default function ToolEdit() {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
Name
|
{t("toolForm.name")}
|
||||||
<FieldHelp schema="ToolInput" field="name">Name</FieldHelp>
|
<FieldHelp schema="ToolInput" field="name">{t("toolForm.name")}</FieldHelp>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Tool name" {...field} />
|
<Input placeholder={t("toolForm.name")} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -189,8 +197,8 @@ export default function ToolEdit() {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
Category
|
{t("toolForm.category")}
|
||||||
<FieldHelp schema="ToolInput" field="category">Category</FieldHelp>
|
<FieldHelp schema="ToolInput" field="category">{t("toolForm.category")}</FieldHelp>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<CategoryCombobox value={field.value} onChange={field.onChange} />
|
<CategoryCombobox value={field.value} onChange={field.onChange} />
|
||||||
@@ -207,11 +215,11 @@ export default function ToolEdit() {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
Website URL (Optional)
|
{t("toolForm.websiteUrlOptional")}
|
||||||
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
|
<FieldHelp schema="ToolInput" field="websiteUrl">{t("toolForm.websiteUrl")}</FieldHelp>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="https://..." type="url" {...field} />
|
<Input placeholder={t("toolForm.urlPlaceholder")} type="url" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -224,8 +232,8 @@ export default function ToolEdit() {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
Icon / Logo URL (Optional)
|
{t("toolForm.iconUrlOptional")}
|
||||||
<FieldHelp schema="ToolInput" field="iconUrl">Icon / Logo URL</FieldHelp>
|
<FieldHelp schema="ToolInput" field="iconUrl">{t("toolForm.iconUrl")}</FieldHelp>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -233,16 +241,16 @@ export default function ToolEdit() {
|
|||||||
{field.value ? (
|
{field.value ? (
|
||||||
<img
|
<img
|
||||||
src={field.value}
|
src={field.value}
|
||||||
alt="icon preview"
|
alt={t("toolForm.iconPreview")}
|
||||||
className="w-full h-full object-contain p-0.5"
|
className="w-full h-full object-contain p-0.5"
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
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>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
placeholder="https://example.com/logo.png"
|
placeholder={t("toolForm.logoPlaceholder")}
|
||||||
type="url"
|
type="url"
|
||||||
{...field}
|
{...field}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
@@ -260,12 +268,12 @@ export default function ToolEdit() {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
Description
|
{t("toolForm.description")}
|
||||||
<FieldHelp schema="ToolInput" field="description">Description</FieldHelp>
|
<FieldHelp schema="ToolInput" field="description">{t("toolForm.description")}</FieldHelp>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="What does this tool do?"
|
placeholder={t("toolForm.descriptionPlaceholderEdit")}
|
||||||
className="min-h-[120px] resize-none"
|
className="min-h-[120px] resize-none"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
@@ -279,13 +287,13 @@ export default function ToolEdit() {
|
|||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||||
Features
|
{t("toolForm.features")}
|
||||||
<FieldHelp schema="ToolInput" field="features">Features</FieldHelp>
|
<FieldHelp schema="ToolInput" field="features">{t("toolForm.features")}</FieldHelp>
|
||||||
</h3>
|
</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>
|
</div>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => appendFeature({ value: "" })}>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -300,7 +308,7 @@ export default function ToolEdit() {
|
|||||||
<FeatureInput
|
<FeatureInput
|
||||||
value={field.value ?? ""}
|
value={field.value ?? ""}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
placeholder="e.g. Real-time collaboration"
|
placeholder={t("toolForm.featurePlaceholder")}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<Button
|
<Button
|
||||||
@@ -317,7 +325,7 @@ export default function ToolEdit() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{featureFields.length === 0 && (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
@@ -326,13 +334,13 @@ export default function ToolEdit() {
|
|||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||||
Tags
|
{t("toolForm.tags")}
|
||||||
<FieldHelp schema="ToolInput" field="tags">Tags</FieldHelp>
|
<FieldHelp schema="ToolInput" field="tags">{t("toolForm.tags")}</FieldHelp>
|
||||||
</h3>
|
</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>
|
</div>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => appendTag({ value: "" })}>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -345,7 +353,7 @@ export default function ToolEdit() {
|
|||||||
<FormItem className="flex items-center space-y-0 relative w-[200px]">
|
<FormItem className="flex items-center space-y-0 relative w-[200px]">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TagInput
|
<TagInput
|
||||||
placeholder="Tag"
|
placeholder={t("toolForm.tag")}
|
||||||
className="pr-8 h-9 text-sm"
|
className="pr-8 h-9 text-sm"
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
value={field.value ?? ""}
|
value={field.value ?? ""}
|
||||||
@@ -369,10 +377,10 @@ export default function ToolEdit() {
|
|||||||
|
|
||||||
<div className="pt-6 border-t flex justify-end gap-3">
|
<div className="pt-6 border-t flex justify-end gap-3">
|
||||||
<Button type="button" variant="outline" asChild>
|
<Button type="button" variant="outline" asChild>
|
||||||
<Link href={`/tools/${id}`}>Cancel</Link>
|
<Link href={`/tools/${id}`}>{t("common.cancel")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={updateTool.isPending || !isAuthenticated}>
|
<Button type="submit" disabled={updateTool.isPending || !isAuthenticated}>
|
||||||
{updateTool.isPending ? "Saving…" : "Save Changes"}
|
{updateTool.isPending ? t("toolForm.saving") : t("toolForm.saveChanges")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -19,26 +19,33 @@ import { FeatureInput } from "@/components/feature-input";
|
|||||||
import { TagInput } from "@/components/tag-input";
|
import { TagInput } from "@/components/tag-input";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { FieldHelp } from "@/components/field-help";
|
import { FieldHelp } from "@/components/field-help";
|
||||||
|
import { GuideHelp } from "@/components/guide-help";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const toolSchema = z.object({
|
type ToolFormValues = z.infer<ReturnType<typeof buildToolSchema>>;
|
||||||
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<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() {
|
export default function ToolNew() {
|
||||||
const [location, setLocation] = useLocation();
|
const [location, setLocation] = useLocation();
|
||||||
|
const { t } = useTranslation();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const createTool = useCreateTool();
|
const createTool = useCreateTool();
|
||||||
const { isAuthenticated, isLoading: authLoading, login } = useAuth();
|
const { isAuthenticated, isLoading: authLoading, login } = useAuth();
|
||||||
|
|
||||||
|
const toolSchema = buildToolSchema(t);
|
||||||
|
|
||||||
const form = useForm<ToolFormValues>({
|
const form = useForm<ToolFormValues>({
|
||||||
resolver: zodResolver(toolSchema),
|
resolver: zodResolver(toolSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -75,7 +82,7 @@ export default function ToolNew() {
|
|||||||
{ data: payload },
|
{ data: payload },
|
||||||
{
|
{
|
||||||
onSuccess: (newTool) => {
|
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: getListToolsQueryKey() });
|
||||||
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
||||||
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
|
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
|
||||||
@@ -85,7 +92,7 @@ export default function ToolNew() {
|
|||||||
setLocation(`/tools/${newTool.id}`);
|
setLocation(`/tools/${newTool.id}`);
|
||||||
},
|
},
|
||||||
onError: () => {
|
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" });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -96,24 +103,24 @@ export default function ToolNew() {
|
|||||||
<div className="max-w-3xl mx-auto space-y-6 pb-10">
|
<div className="max-w-3xl mx-auto space-y-6 pb-10">
|
||||||
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
|
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
|
||||||
<Link href="/tools">
|
<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>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Add a New Tool</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("toolForm.addTitle")}</h1>
|
||||||
<p className="text-muted-foreground">Submit a tool you use to let the community rate and review it.</p>
|
<p className="text-muted-foreground">{t("toolForm.addSubtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!authLoading && !isAuthenticated && (
|
{!authLoading && !isAuthenticated && (
|
||||||
<div className="flex items-center gap-4 rounded-md border border-primary/20 bg-primary/5 px-4 py-3">
|
<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" />
|
<LogIn className="w-5 h-5 text-primary shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium">Sign in required</p>
|
<p className="text-sm font-medium">{t("toolForm.signInRequired")}</p>
|
||||||
<p className="text-xs text-muted-foreground">You must be signed in to submit a tool.</p>
|
<p className="text-xs text-muted-foreground">{t("toolForm.signInRequiredSub")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={() => login(location)} data-testid="button-login-prompt">
|
<Button size="sm" onClick={() => login(location)} data-testid="button-login-prompt">
|
||||||
Sign in
|
{t("auth.signIn")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -122,9 +129,10 @@ export default function ToolNew() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Wrench className="w-5 h-5 text-primary" />
|
<Wrench className="w-5 h-5 text-primary" />
|
||||||
Tool Details
|
{t("toolForm.toolDetails")}
|
||||||
|
<GuideHelp guide="tool-anlegen" label={t("toolForm.toolDetails")} />
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Provide the basic information about the tool.</CardDescription>
|
<CardDescription>{t("toolForm.toolDetailsNewSub")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -136,11 +144,11 @@ export default function ToolNew() {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
Name
|
{t("toolForm.name")}
|
||||||
<FieldHelp schema="ToolInput" field="name">Name</FieldHelp>
|
<FieldHelp schema="ToolInput" field="name">{t("toolForm.name")}</FieldHelp>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -153,8 +161,8 @@ export default function ToolNew() {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
Category
|
{t("toolForm.category")}
|
||||||
<FieldHelp schema="ToolInput" field="category">Category</FieldHelp>
|
<FieldHelp schema="ToolInput" field="category">{t("toolForm.category")}</FieldHelp>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<CategoryCombobox
|
<CategoryCombobox
|
||||||
@@ -174,11 +182,11 @@ export default function ToolNew() {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
Website URL (Optional)
|
{t("toolForm.websiteUrlOptional")}
|
||||||
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
|
<FieldHelp schema="ToolInput" field="websiteUrl">{t("toolForm.websiteUrl")}</FieldHelp>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -191,8 +199,8 @@ export default function ToolNew() {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
Icon / Logo URL (Optional)
|
{t("toolForm.iconUrlOptional")}
|
||||||
<FieldHelp schema="ToolInput" field="iconUrl">Icon / Logo URL</FieldHelp>
|
<FieldHelp schema="ToolInput" field="iconUrl">{t("toolForm.iconUrl")}</FieldHelp>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -200,16 +208,16 @@ export default function ToolNew() {
|
|||||||
{field.value ? (
|
{field.value ? (
|
||||||
<img
|
<img
|
||||||
src={field.value}
|
src={field.value}
|
||||||
alt="icon preview"
|
alt={t("toolForm.iconPreview")}
|
||||||
className="w-full h-full object-contain p-0.5"
|
className="w-full h-full object-contain p-0.5"
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
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>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
placeholder="https://example.com/logo.png"
|
placeholder={t("toolForm.logoPlaceholder")}
|
||||||
type="url"
|
type="url"
|
||||||
{...field}
|
{...field}
|
||||||
data-testid="input-tool-icon-url"
|
data-testid="input-tool-icon-url"
|
||||||
@@ -228,12 +236,12 @@ export default function ToolNew() {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="inline-flex items-center gap-1.5">
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
Description
|
{t("toolForm.description")}
|
||||||
<FieldHelp schema="ToolInput" field="description">Description</FieldHelp>
|
<FieldHelp schema="ToolInput" field="description">{t("toolForm.description")}</FieldHelp>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="What does this tool do? Why do people use it?"
|
placeholder={t("toolForm.descriptionPlaceholderNew")}
|
||||||
className="min-h-[120px] resize-none"
|
className="min-h-[120px] resize-none"
|
||||||
{...field}
|
{...field}
|
||||||
data-testid="input-tool-description"
|
data-testid="input-tool-description"
|
||||||
@@ -248,10 +256,10 @@ export default function ToolNew() {
|
|||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||||
Features
|
{t("toolForm.features")}
|
||||||
<FieldHelp schema="ToolInput" field="features">Features</FieldHelp>
|
<FieldHelp schema="ToolInput" field="features">{t("toolForm.features")}</FieldHelp>
|
||||||
</h3>
|
</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>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -260,7 +268,7 @@ export default function ToolNew() {
|
|||||||
onClick={() => appendFeature({ value: "" })}
|
onClick={() => appendFeature({ value: "" })}
|
||||||
data-testid="button-add-feature"
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -276,7 +284,7 @@ export default function ToolNew() {
|
|||||||
<FeatureInput
|
<FeatureInput
|
||||||
value={field.value ?? ""}
|
value={field.value ?? ""}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
placeholder="e.g. Real-time collaboration"
|
placeholder={t("toolForm.featurePlaceholder")}
|
||||||
data-testid={`input-feature-${index}`}
|
data-testid={`input-feature-${index}`}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -295,7 +303,7 @@ export default function ToolNew() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{featureFields.length === 0 && (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
@@ -304,10 +312,10 @@ export default function ToolNew() {
|
|||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||||
Tags
|
{t("toolForm.tags")}
|
||||||
<FieldHelp schema="ToolInput" field="tags">Tags</FieldHelp>
|
<FieldHelp schema="ToolInput" field="tags">{t("toolForm.tags")}</FieldHelp>
|
||||||
</h3>
|
</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>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -316,7 +324,7 @@ export default function ToolNew() {
|
|||||||
onClick={() => appendTag({ value: "" })}
|
onClick={() => appendTag({ value: "" })}
|
||||||
data-testid="button-add-tag"
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -330,7 +338,7 @@ export default function ToolNew() {
|
|||||||
<FormItem className="flex items-center space-y-0 relative w-[200px]">
|
<FormItem className="flex items-center space-y-0 relative w-[200px]">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TagInput
|
<TagInput
|
||||||
placeholder="Tag"
|
placeholder={t("toolForm.tagPlaceholder")}
|
||||||
className="pr-8 h-9 text-sm"
|
className="pr-8 h-9 text-sm"
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
value={field.value ?? ""}
|
value={field.value ?? ""}
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ export default function ToolsBrowse() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setFeatures(features.filter((x) => x !== f))}
|
onClick={() => setFeatures(features.filter((x) => x !== f))}
|
||||||
className="rounded-sm hover:bg-muted p-0.5"
|
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" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -374,14 +374,14 @@ export default function ToolsBrowse() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setMinRating(null)}
|
onClick={() => setMinRating(null)}
|
||||||
className="rounded-sm hover:bg-muted p-0.5"
|
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" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<Button variant="link" size="sm" className="px-1 text-muted-foreground" onClick={clearFilters}>
|
<Button variant="link" size="sm" className="px-1 text-muted-foreground" onClick={clearFilters}>
|
||||||
Clear all
|
{t("common.clearAll")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -521,15 +521,15 @@ export default function ToolsBrowse() {
|
|||||||
<AlertDialog open={compareUpsellOpen} onOpenChange={setCompareUpsellOpen}>
|
<AlertDialog open={compareUpsellOpen} onOpenChange={setCompareUpsellOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Compare is a Premium feature</AlertDialogTitle>
|
<AlertDialogTitle>{t("browse.comparePremiumTitle")}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Comparing tools side-by-side is available to Premium and Enterprise users. Upgrade your plan to unlock it.
|
{t("browse.comparePremiumSub")}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Not now</AlertDialogCancel>
|
<AlertDialogCancel>{t("browse.notNow")}</AlertDialogCancel>
|
||||||
<AlertDialogAction asChild>
|
<AlertDialogAction asChild>
|
||||||
<a href="/admin?tab=plan">Upgrade to Premium</a>
|
<a href="/admin?tab=plan">{t("browse.upgradePremium")}</a>
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
getListAllTagsQueryKey,
|
getListAllTagsQueryKey,
|
||||||
getGetTopToolsQueryKey,
|
getGetTopToolsQueryKey,
|
||||||
getGetAnalyticsSummaryQueryKey,
|
getGetAnalyticsSummaryQueryKey,
|
||||||
type Tool,
|
|
||||||
} from "@workspace/api-client-react";
|
} from "@workspace/api-client-react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
@@ -91,12 +90,12 @@ export default function Trash() {
|
|||||||
{ data: { ids } },
|
{ data: { ids } },
|
||||||
{
|
{
|
||||||
onSuccess: (res) => {
|
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());
|
setSelected(new Set());
|
||||||
invalidate();
|
invalidate();
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
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 +106,13 @@ export default function Trash() {
|
|||||||
{ data: { ids } },
|
{ data: { ids } },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
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());
|
setSelected(new Set());
|
||||||
setConfirmDelete(false);
|
setConfirmDelete(false);
|
||||||
invalidate();
|
invalidate();
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
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 +123,13 @@ export default function Trash() {
|
|||||||
undefined,
|
undefined,
|
||||||
{
|
{
|
||||||
onSuccess: (res) => {
|
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());
|
setSelected(new Set());
|
||||||
setConfirmEmpty(false);
|
setConfirmEmpty(false);
|
||||||
invalidate();
|
invalidate();
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
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" });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -227,48 +226,48 @@ export default function Trash() {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selected.size === trashed.length && trashed.length > 0}
|
checked={selected.size === trashed.length && trashed.length > 0}
|
||||||
onCheckedChange={toggleAll}
|
onCheckedChange={toggleAll}
|
||||||
aria-label="Select all"
|
aria-label={t("trash.selectAll")}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>{t("trash.name")}</TableHead>
|
||||||
<TableHead>Category</TableHead>
|
<TableHead>{t("trash.category")}</TableHead>
|
||||||
<TableHead>{t("trash.deletedAt")}</TableHead>
|
<TableHead>{t("trash.deletedAt")}</TableHead>
|
||||||
<TableHead>{t("trash.deletedBy")}</TableHead>
|
<TableHead>{t("trash.deletedBy")}</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">{t("trash.actions")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{trashed.map((t: Tool) => (
|
{trashed.map((tool) => (
|
||||||
<TableRow key={t.id} className={selected.has(t.id) ? "bg-muted/40" : undefined}>
|
<TableRow key={tool.id} className={selected.has(tool.id) ? "bg-muted/40" : undefined}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selected.has(t.id)}
|
checked={selected.has(tool.id)}
|
||||||
onCheckedChange={() => toggle(t.id)}
|
onCheckedChange={() => toggle(tool.id)}
|
||||||
aria-label={`Select ${t.name}`}
|
aria-label={t("trash.selectName", { name: tool.name })}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium">{t.name}</TableCell>
|
<TableCell className="font-medium">{tool.name}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline">{t.category}</Badge>
|
<Badge variant="outline">{tool.category}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<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>
|
||||||
<TableCell className="text-sm text-muted-foreground">{t.deletedBy ?? "—"}</TableCell>
|
<TableCell className="text-sm text-muted-foreground">{tool.deletedBy ?? "—"}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
<Button variant="ghost" size="sm" onClick={() => handleRestore([t.id])} disabled={restore.isPending}>
|
<Button variant="ghost" size="sm" onClick={() => handleRestore([tool.id])} disabled={restore.isPending}>
|
||||||
<RotateCcw className="w-3.5 h-3.5" /> Restore
|
<RotateCcw className="w-3.5 h-3.5" /> {t("trash.restoreAction")}
|
||||||
</Button>
|
</Button>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-destructive hover:text-destructive"
|
className="text-destructive hover:text-destructive"
|
||||||
onClick={() => handleDeletePermanent([t.id])}
|
onClick={() => handleDeletePermanent([tool.id])}
|
||||||
disabled={deletePermanent.isPending}
|
disabled={deletePermanent.isPending}
|
||||||
>
|
>
|
||||||
<TrashIcon className="w-3.5 h-3.5" /> Delete
|
<TrashIcon className="w-3.5 h-3.5" /> {t("trash.deleteAction")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -285,13 +284,13 @@ export default function Trash() {
|
|||||||
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete {selectedIds.length} tool(s) permanently?</AlertDialogTitle>
|
<AlertDialogTitle>{t("trash.deleteConfirmTitle", { count: selectedIds.length })}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This permanently removes the selected tools along with all their ratings, costs and relations. This cannot be undone.
|
{t("trash.deleteConfirmSub")}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
onClick={() => handleDeletePermanent(selectedIds)}
|
onClick={() => handleDeletePermanent(selectedIds)}
|
||||||
@@ -305,13 +304,13 @@ export default function Trash() {
|
|||||||
<AlertDialog open={confirmEmpty} onOpenChange={setConfirmEmpty}>
|
<AlertDialog open={confirmEmpty} onOpenChange={setConfirmEmpty}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Empty the trash?</AlertDialogTitle>
|
<AlertDialogTitle>{t("trash.emptyConfirmTitle")}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<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>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
onClick={handleEmpty}
|
onClick={handleEmpty}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/use-auth";
|
|||||||
import { useWatchlist } from "@/hooks/use-watchlist";
|
import { useWatchlist } from "@/hooks/use-watchlist";
|
||||||
import { Layout } from "@/components/layout";
|
import { Layout } from "@/components/layout";
|
||||||
import { ToolCard } from "@/components/tool-card";
|
import { ToolCard } from "@/components/tool-card";
|
||||||
|
import { GuideHelp } from "@/components/guide-help";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { ShieldAlert, Bookmark } from "lucide-react";
|
import { ShieldAlert, Bookmark } from "lucide-react";
|
||||||
@@ -42,7 +43,10 @@ export default function Watchlist() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-6 pb-10">
|
<div className="space-y-6 pb-10">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("watchlist.title")}</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
|
||||||
|
{t("watchlist.title")}
|
||||||
|
<GuideHelp guide="watchlist" label={t("watchlist.title")} />
|
||||||
|
</h1>
|
||||||
<p className="text-muted-foreground">{t("watchlist.subtitle")}</p>
|
<p className="text-muted-foreground">{t("watchlist.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -58,7 +62,7 @@ export default function Watchlist() {
|
|||||||
{t("watchlist.emptySub")}
|
{t("watchlist.emptySub")}
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
<a href="/tools">Browse tools</a>
|
<a href="/tools">{t("common.browseTools")}</a>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 (0–5) |
|
||||||
|
| **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,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 | 1–5 stars |
|
||||||
|
| **Usability** | Yes | 1–5 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,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, 1–5) and `usability` (Bedienbarkeit, 1–5)
|
||||||
|
- 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,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**
|
||||||
|
(1–5 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)
|
||||||
@@ -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 (1–5) 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.
|
||||||
@@ -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).
|
||||||
@@ -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)).
|
||||||
@@ -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)
|
||||||
@@ -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").
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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).
|
||||||
@@ -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 (A–Z) | Alphabetically ascending |
|
||||||
|
| Name (Z–A) | 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,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).
|
||||||
@@ -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).
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
> nicht zutreffen entfernen. Die Seite wird unter `/docs/vX.Y.Z` in der App
|
> nicht zutreffen entfernen. Die Seite wird unter `/docs/vX.Y.Z` in der App
|
||||||
> angezeigt.
|
> angezeigt.
|
||||||
|
|
||||||
**Datum:** YYYY-MM-DD · **Tag:** [`vX.Y.Z`](https://git.kubebase.de/admin/tool-evaluator/tags/vX.Y.Z)
|
**Datum:** YYYY-MM-DD · **Tag:** [`vX.Y.Z`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/vX.Y.Z)
|
||||||
|
|
||||||
## Neue Features
|
## Neue Features
|
||||||
|
|
||||||
@@ -32,4 +32,4 @@
|
|||||||
## Links
|
## Links
|
||||||
|
|
||||||
- Commit: [`<short-sha>`](https://git.kubebase.de/admin/tool-evaluator/commit/<short-sha>)
|
- Commit: [`<short-sha>`](https://git.kubebase.de/admin/tool-evaluator/commit/<short-sha>)
|
||||||
- Tag: [`vX.Y.Z`](https://git.kubebase.de/admin/tool-evaluator/tags/vX.Y.Z)
|
- Tag: [`vX.Y.Z`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/vX.Y.Z)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# v0.6.0 — Release Notes
|
# v0.6.0 — Release Notes
|
||||||
|
|
||||||
**Datum:** 2026-08-03 · **Tag:** [`v0.6.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.6.0)
|
**Datum:** 2026-08-03 · **Tag:** [`v0.6.0`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.6.0)
|
||||||
|
|
||||||
## Neue Features
|
## Neue Features
|
||||||
|
|
||||||
@@ -34,4 +34,4 @@
|
|||||||
## Links
|
## Links
|
||||||
|
|
||||||
- Commit: [`2f66fff`](https://git.kubebase.de/admin/tool-evaluator/commit/2f66fff)
|
- Commit: [`2f66fff`](https://git.kubebase.de/admin/tool-evaluator/commit/2f66fff)
|
||||||
- Tag: [`v0.6.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.6.0)
|
- Tag: [`v0.6.0`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.6.0)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# v0.7.0 — Release Notes
|
# v0.7.0 — Release Notes
|
||||||
|
|
||||||
**Datum:** 2026-08-03 · **Tag:** [`v0.7.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.7.0)
|
**Datum:** 2026-08-03 · **Tag:** [`v0.7.0`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.7.0)
|
||||||
|
|
||||||
## Neue Features
|
## Neue Features
|
||||||
|
|
||||||
@@ -32,4 +32,4 @@
|
|||||||
## Links
|
## Links
|
||||||
|
|
||||||
- Commit: [`520f917`](https://git.kubebase.de/admin/tool-evaluator/commit/520f917)
|
- Commit: [`520f917`](https://git.kubebase.de/admin/tool-evaluator/commit/520f917)
|
||||||
- Tag: [`v0.7.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.7.0)
|
- Tag: [`v0.7.0`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.7.0)
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
# v0.8.0 — Release Notes
|
# v0.8.0 — Release Notes
|
||||||
|
|
||||||
**Datum:** 2026-08-03 · **Tag:** [`v0.8.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.8.0)
|
**Datum:** 2026-08-03 · **Tag:** [`v0.8.0`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.0)
|
||||||
|
|
||||||
## Neue Features
|
## Neue Features
|
||||||
|
|
||||||
@@ -43,4 +43,4 @@
|
|||||||
## Links
|
## Links
|
||||||
|
|
||||||
- Commit: [`6c92b63`](https://git.kubebase.de/admin/tool-evaluator/commit/6c92b63)
|
- Commit: [`6c92b63`](https://git.kubebase.de/admin/tool-evaluator/commit/6c92b63)
|
||||||
- Tag: [`v0.8.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.8.0)
|
- Tag: [`v0.8.0`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.0)
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
# v0.8.1 — Release Notes
|
# v0.8.1 — Release Notes
|
||||||
|
|
||||||
**Datum:** 2026-08-03 · **Tag:** [`v0.8.1`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.8.1)
|
**Datum:** 2026-08-03 · **Tag:** [`v0.8.1`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.1)
|
||||||
|
|
||||||
## Neue Features
|
## Neue Features
|
||||||
|
|
||||||
@@ -44,4 +44,4 @@
|
|||||||
## Links
|
## Links
|
||||||
|
|
||||||
- Commit: [`fdf2d74`](https://git.kubebase.de/admin/tool-evaluator/commit/fdf2d74)
|
- Commit: [`fdf2d74`](https://git.kubebase.de/admin/tool-evaluator/commit/fdf2d74)
|
||||||
- Tag: [`v0.8.1`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.8.1)
|
- Tag: [`v0.8.1`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.1)
|
||||||
|
|||||||
@@ -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,61 @@
|
|||||||
|
|
||||||
|
# Administration
|
||||||
|
|
||||||
|
Der Bereich **Admin** (`/admin`) ist ausschließlich für Admins zugänglich.
|
||||||
|
Ohne Admin-Rolle erscheint eine Zugriffsverweigerung.
|
||||||
|
|
||||||
|
> Oben rechts führt die Schaltfläche **Redundanz-Dashboard** zur automatischen
|
||||||
|
> Doppelungs-Erkennung (siehe [Redundanz](/docs/handbook/redundanz)).
|
||||||
|
|
||||||
|
## Tab „Nutzer"
|
||||||
|
|
||||||
|
Verwaltung der lokalen Konten.
|
||||||
|
|
||||||
|
- **Nutzer hinzufügen:** Benutzername (Pflicht), Passwort (mind. 6 Zeichen),
|
||||||
|
E-Mail (optional), **Rolle** (User/Admin), **Tarif** (Free/Premium/Enterprise).
|
||||||
|
- **Nutzer bearbeiten:** Rolle, Tarif und (für lokale Konten) ein neues Passwort
|
||||||
|
setzen. Für OIDC-Konten wird die Passwortverwaltung im Identitätsanbieter
|
||||||
|
(z. B. Keycloak) angeboten.
|
||||||
|
- **Nutzer löschen:** Entfernt das Konto endgültig (nicht für das eigene Konto).
|
||||||
|
|
||||||
|
API-Referenz:
|
||||||
|
[`POST /users`](/docs/reference/endpoints/users#createUser),
|
||||||
|
[`PATCH /users/{id}`](/docs/reference/endpoints/users#updateUser),
|
||||||
|
[`DELETE /users/{id}`](/docs/reference/endpoints/users#deleteUser).
|
||||||
|
|
||||||
|
## Tab „Tools"
|
||||||
|
|
||||||
|
Zentraler Zugriff auf den Tool-Katalog.
|
||||||
|
|
||||||
|
- **Suchen** nach Tools.
|
||||||
|
- Tools einzeln ansehen, bearbeiten oder in den Papierkorb verschieben.
|
||||||
|
- **Massenaktion:** mehrere Tools auswählen und in den Papierkorb verschieben
|
||||||
|
(Bestätigungsdialog; soft gelöschte Tools sind aus allen öffentlichen Ansichten
|
||||||
|
entfernt und können wiederhergestellt oder endgültig gelöscht werden).
|
||||||
|
|
||||||
|
## Tab „Audit-Log"
|
||||||
|
|
||||||
|
Chronologisches Protokoll aller Anlage-, Änderungs- und Löschvorgänge
|
||||||
|
(max. 100 Einträge): Aktion, Entität + ID, Zeitstempel, ausführende Person und
|
||||||
|
geänderte Felder.
|
||||||
|
|
||||||
|
API-Referenz: [`GET /audit-logs`](/docs/reference/endpoints/audit#listAuditLogs).
|
||||||
|
|
||||||
|
## Tab „System"
|
||||||
|
|
||||||
|
Versionsinformationen der laufenden Instanz:
|
||||||
|
|
||||||
|
- **Version** (z. B. `v0.8.1`),
|
||||||
|
- **Commit** (7-stelliger SHA, verlinkt zum Repository),
|
||||||
|
- **Build-Datum**,
|
||||||
|
- **Papierkorb-Aufbewahrung** („N Tage" oder „Für immer").
|
||||||
|
|
||||||
|
## Tool-Verknüpfungen (Admin)
|
||||||
|
|
||||||
|
Auf der Detailseite eines Tools kannst du als Admin **Verknüpfungen**
|
||||||
|
(eigene/„manual" sowie automatisch erkannte) verwalten:
|
||||||
|
|
||||||
|
- **Tool verknüpfen:** Dialog mit Tool-ID, **Beziehungstyp**
|
||||||
|
(Ähnlich / Ersetzt / Abgelöst durch) und optionalen Notizen.
|
||||||
|
- Beziehungstypen werden als Badges auf der Detailseite angezeigt.
|
||||||
|
- Manuelle Verknüpfungen lassen sich per Papierkorb-Icon wieder entfernen.
|
||||||
@@ -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 (0–5) |
|
||||||
|
| **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,29 @@
|
|||||||
|
|
||||||
|
# Analytics
|
||||||
|
|
||||||
|
Der Bereich **Analytics** (`/analytics`) ist ein öffentliches Dashboard mit
|
||||||
|
Kennzahlen und Diagrammen auf Basis aller Tools und Bewertungen.
|
||||||
|
|
||||||
|
## Kennzahlen (KPI-Karten)
|
||||||
|
|
||||||
|
- **Anzahl Tools** — wie viele Tools sind im Katalog erfasst.
|
||||||
|
- **Anzahl Bewertungen** — wie viele Bewertungen wurden insgesamt abgegeben.
|
||||||
|
- **Aktive Kategorien** — wie viele Kategorien existieren.
|
||||||
|
- **Durchschnittliche Bewertung** — globaler kombinierter Wert.
|
||||||
|
|
||||||
|
## Diagramme
|
||||||
|
|
||||||
|
| Diagramm | Inhalt |
|
||||||
|
| --- | --- |
|
||||||
|
| **Top 8 Tools** | Balkendiagramm der Tools mit der höchsten kombinierten Punktzahl (0–5) |
|
||||||
|
| **Tools je Kategorie** | Radar-Diagramm der Tool-Anzahl pro Kategorie |
|
||||||
|
| **Punkteverteilung** | Zwei horizontale Balken-Diagramme (Nützlichkeit & Bedienbarkeit) pro Stern |
|
||||||
|
|
||||||
|
Die Diagramme sind interaktiv (Tooltips beim Überfahren).
|
||||||
|
|
||||||
|
## 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 | 1–5 stars |
|
||||||
|
| **Usability** | Yes | 1–5 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,39 @@
|
|||||||
|
|
||||||
|
# Bewerten
|
||||||
|
|
||||||
|
Auf der Detailseite eines Tools kannst du deine Erfahrung teilen. Klicke auf
|
||||||
|
**Bewertung abgeben** (erfordert ein Konto).
|
||||||
|
|
||||||
|
## Formularfelder
|
||||||
|
|
||||||
|
| Feld | Pflicht | Hinweise |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Nützlichkeit** | Ja | 1–5 Sterne |
|
||||||
|
| **Bedienbarkeit** | Ja | 1–5 Sterne |
|
||||||
|
| **Kommentar** | Nein | Freitext |
|
||||||
|
| **Name** | Nein | Standard „Anonym" |
|
||||||
|
|
||||||
|
Neben den Feldern führt das **?‑Icon** direkt zur zugehörigen Feldbeschreibung
|
||||||
|
in der [Datenmodell-Referenz](/docs/reference/schemas/ratinginput).
|
||||||
|
|
||||||
|
## Was passiert nach dem Abgeben?
|
||||||
|
|
||||||
|
- Deine Bewertung wird sofort gespeichert und erscheint in der
|
||||||
|
**Bewertungsliste** der Detailseite.
|
||||||
|
- Die **Durchschnittswerte** (Nützlichkeit, Bedienbarkeit, Kombiniert) und die
|
||||||
|
**Punkteverteilung** werden aktualisiert.
|
||||||
|
- Die **Statistiken** im Bereich [Analytics](/docs/handbook/analytics) werden
|
||||||
|
neu berechnet.
|
||||||
|
|
||||||
|
## Statistik-Bereiche auf der Detailseite
|
||||||
|
|
||||||
|
- **Bewertungsübersicht:** Nützlichkeit & Bedienbarkeit als Durchschnitt mit
|
||||||
|
Fortschrittsbalken.
|
||||||
|
- **Punkteverteilung:** Anzahl der Bewertungen pro Stern (1★–5★).
|
||||||
|
- **Verlauf:** Linienchart der kombinierten/Teilwerte über die Zeit
|
||||||
|
(erst ab mehreren Bewertungen sichtbar).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- [`POST /tools/{id}/ratings`](/docs/reference/endpoints/ratings#createRating) — Bewertung abgeben
|
||||||
|
- [`GET /tools/{id}/ratings`](/docs/reference/endpoints/ratings#listToolRatings) — Bewertungen eines Tools
|
||||||
@@ -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, 1–5) and `usability` (Bedienbarkeit, 1–5)
|
||||||
|
- 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,70 @@
|
|||||||
|
|
||||||
|
# Datenmodell
|
||||||
|
|
||||||
|
Dieses Kapitel erklärt die zentralen Datenobjekte von toolr auf Ebene der
|
||||||
|
Anwendung. Die vollständige, automatisch generierte Referenz aller Felder,
|
||||||
|
Typen und Constraints findest du in der
|
||||||
|
[API-Referenz](/docs/reference/schemas/tool).
|
||||||
|
|
||||||
|
## Tool
|
||||||
|
|
||||||
|
Das Herzstück: ein im Katalog erfasstes Werkzeug.
|
||||||
|
|
||||||
|
| Eigenschaft | Beschreibung |
|
||||||
|
| --- | --- |
|
||||||
|
| `id` | Eindeutige Kennung |
|
||||||
|
| `name` | Anzeigename |
|
||||||
|
| `description` | Beschreibung (Was macht das Tool?) |
|
||||||
|
| `category` | Kategorie-Zuordnung |
|
||||||
|
| `websiteUrl` | Offizielle Website (optional) |
|
||||||
|
| `iconUrl` | Logo-/Icon-URL (optional) |
|
||||||
|
| `features` | Liste von Fähigkeiten |
|
||||||
|
| `tags` | Liste von Schlagwörtern |
|
||||||
|
| `createdAt` / `updatedAt` | Zeitstempel |
|
||||||
|
| `createdBy` | Erstellende Person |
|
||||||
|
| `deletedAt` / `deletedBy` | Soft-Löschung (Papierkorb) |
|
||||||
|
|
||||||
|
Eingabe-Formulare verwenden die abgeleiteten Schemas
|
||||||
|
[`ToolInput`](/docs/reference/schemas/toolinput) und
|
||||||
|
[`ToolUpdate`](/docs/reference/schemas/toolupdate).
|
||||||
|
Aggregierte Ansichten liefert [`ToolWithStats`](/docs/reference/schemas/toolwithstats)
|
||||||
|
(z. B. mit Durchschnittsbewertung).
|
||||||
|
|
||||||
|
## Rating (Bewertung)
|
||||||
|
|
||||||
|
Eine einzelne Bewertung zu einem Tool:
|
||||||
|
|
||||||
|
- `usefulness` (Nützlichkeit, 1–5) und `usability` (Bedienbarkeit, 1–5)
|
||||||
|
- optional `comment` und ein Anzeigename (`reviewerName`)
|
||||||
|
- Zeitstempel
|
||||||
|
|
||||||
|
Eingabe-Schema: [`RatingInput`](/docs/reference/schemas/ratinginput).
|
||||||
|
|
||||||
|
## User & Auth
|
||||||
|
|
||||||
|
- [`User`](/docs/reference/schemas/user) — Benutzerkonto mit Rolle (User/Admin)
|
||||||
|
und Tarif (Free/Premium/Enterprise).
|
||||||
|
- [`AuthUser`](/docs/reference/schemas/authuser) — das angemeldete Profil
|
||||||
|
inklusive `entitlements` (verfügbare Features).
|
||||||
|
- [`UserPreferences`](/docs/reference/schemas/userpreferences) — Ansichts- und
|
||||||
|
Dichte-Präferenzen sowie die `watchlist` (Liste von Tool-IDs).
|
||||||
|
|
||||||
|
## Analytics
|
||||||
|
|
||||||
|
Die Statistik-Endpunkte liefern aggregierte Daten:
|
||||||
|
|
||||||
|
- [`AnalyticsSummary`](/docs/reference/schemas/analyticssummary) — globale
|
||||||
|
Kennzahlen (Anzahl Tools/Bewertungen, Kategorien, Durchschnitt).
|
||||||
|
- [`TopToolEntry`](/docs/reference/schemas/TopToolEntry) — ein Eintrag der
|
||||||
|
Top-Tools.- [`CategoryStats`](/docs/reference/schemas/categorystats) — Tool-Anzahl je
|
||||||
|
Kategorie.
|
||||||
|
- [`RatingDistribution`](/docs/reference/schemas/ratingdistribution) —
|
||||||
|
Punkteverteilung (Nützlichkeit & Bedienbarkeit).
|
||||||
|
- [`ScoreBucket`](/docs/reference/schemas/scorebucket) — ein Werte-Bucket.
|
||||||
|
|
||||||
|
## Weitere
|
||||||
|
|
||||||
|
- [`VersionInfo`](/docs/reference/schemas/versioninfo) — Version, Commit-SHA,
|
||||||
|
Build-Datum und Papierkorb-Aufbewahrung der laufenden Instanz.
|
||||||
|
- [`AuditLog`](/docs/reference/schemas/auditlog) — ein Protokolleintrag
|
||||||
|
(Aktion, Entität, Zeitstempel, Akteur, Änderungen).
|
||||||
@@ -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**
|
||||||
|
(1–5 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)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
|
||||||
|
# Erste Schritte
|
||||||
|
|
||||||
|
Diese Seite führt dich durch die wichtigsten Abläufe in toolr — vom ersten
|
||||||
|
Besuch bis zum Anlegen und Bewerten eines Tools.
|
||||||
|
|
||||||
|
## 1. Anmelden
|
||||||
|
|
||||||
|
Die meisten Aktionen (Tool anlegen, bewerten, Watchlist, Vergleichen) erfordern
|
||||||
|
ein Konto. Klicke unten links auf **Anmelden**. Je nach Konfiguration der
|
||||||
|
Instanz hast du zwei Möglichkeiten:
|
||||||
|
|
||||||
|
- **Lokale Konten:** Benutzername + Passwort. Der Zugang wird von einem Admin
|
||||||
|
angelegt (siehe [Administration](/docs/handbook/administration)).
|
||||||
|
- **OIDC (SSO):** Anmelden mit dem konfigurierten Identitätsanbieter (z. B.
|
||||||
|
Keycloak).
|
||||||
|
|
||||||
|
Welcher Modus aktiv ist, steht im Endpunkt
|
||||||
|
[`GET /auth/mode`](/docs/reference/endpoints/auth#getAuthMode). Details findest
|
||||||
|
du im Abschnitt [Anmelden & Konto](/docs/handbook/konto).
|
||||||
|
|
||||||
|
## 2. Tools finden
|
||||||
|
|
||||||
|
Öffne den Bereich **Tools durchsuchen**:
|
||||||
|
|
||||||
|
- **Suchen** — Volltextsuche über Name & Beschreibung (Tastenkürzel `/`).
|
||||||
|
- **Filtern** — nach Kategorie, Tags, Features und Mindestbewertung
|
||||||
|
(`minRating`).
|
||||||
|
- **Sortieren** — nach Aktualität, Top-Bewertung, meistbewertet, Name
|
||||||
|
(auf-/absteigend) oder letztem Update.
|
||||||
|
|
||||||
|
Alle Optionen im Detail: [Tools finden & durchsuchen](/docs/handbook/tools-finden).
|
||||||
|
|
||||||
|
## 3. Tool anlegen
|
||||||
|
|
||||||
|
Gehe auf **Tool hinzufügen** und fülle das Formular aus. Details zu jedem Feld
|
||||||
|
findest du im Abschnitt [Tool anlegen](/docs/handbook/tool-anlegen) und in der
|
||||||
|
[Feld-Referenz](/docs/reference/schemas/toolinput).
|
||||||
|
|
||||||
|
## 4. Bewerten
|
||||||
|
|
||||||
|
Auf der Detailseite eines Tools kannst du **Nützlichkeit** und **Bedienbarkeit**
|
||||||
|
(jeweils 1–5) vergeben und optional einen Kommentar hinterlassen. Deine
|
||||||
|
Bewertung fließt sofort in die Statistiken ein.
|
||||||
|
Siehe [Bewerten](/docs/handbook/bewerten).
|
||||||
|
|
||||||
|
## 5. Weiterführend
|
||||||
|
|
||||||
|
- [Tools vergleichen](/docs/handbook/vergleichen)
|
||||||
|
- [Watchlist](/docs/handbook/watchlist)
|
||||||
|
- [Analytics](/docs/handbook/analytics)
|
||||||
|
- [Pläne & Berechtigungen](/docs/handbook/plaene)
|
||||||
|
- [Administration](/docs/handbook/administration)
|
||||||
@@ -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 (1–5) 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.
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"slug": "index",
|
||||||
|
"file": "index.md",
|
||||||
|
"title": "Überblick",
|
||||||
|
"order": 1,
|
||||||
|
"fileEn": "index.en.md",
|
||||||
|
"titleEn": "Overview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "getting-started",
|
||||||
|
"file": "getting-started.md",
|
||||||
|
"title": "Erste Schritte",
|
||||||
|
"order": 2,
|
||||||
|
"fileEn": "getting-started.en.md",
|
||||||
|
"titleEn": "Getting Started"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "konto",
|
||||||
|
"file": "konto.md",
|
||||||
|
"title": "Anmelden & Konto",
|
||||||
|
"order": 3,
|
||||||
|
"fileEn": "konto.en.md",
|
||||||
|
"titleEn": "Login & Account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "tools-finden",
|
||||||
|
"file": "tools-finden.md",
|
||||||
|
"title": "Tools finden & durchsuchen",
|
||||||
|
"order": 4,
|
||||||
|
"fileEn": "tools-finden.en.md",
|
||||||
|
"titleEn": "Find & browse tools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "tool-anlegen",
|
||||||
|
"file": "tool-anlegen.md",
|
||||||
|
"title": "Tool anlegen",
|
||||||
|
"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,
|
||||||
|
"fileEn": "tool-bearbeiten.en.md",
|
||||||
|
"titleEn": "Edit & delete tools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "bewerten",
|
||||||
|
"file": "bewerten.md",
|
||||||
|
"title": "Bewerten",
|
||||||
|
"order": 7,
|
||||||
|
"fileEn": "bewerten.en.md",
|
||||||
|
"titleEn": "Rating"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "watchlist",
|
||||||
|
"file": "watchlist.md",
|
||||||
|
"title": "Watchlist",
|
||||||
|
"order": 8,
|
||||||
|
"fileEn": "watchlist.en.md",
|
||||||
|
"titleEn": "Watchlist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "vergleichen",
|
||||||
|
"file": "vergleichen.md",
|
||||||
|
"title": "Vergleichen",
|
||||||
|
"order": 9,
|
||||||
|
"fileEn": "vergleichen.en.md",
|
||||||
|
"titleEn": "Compare"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "analytics",
|
||||||
|
"file": "analytics.md",
|
||||||
|
"title": "Analytics",
|
||||||
|
"order": 10,
|
||||||
|
"fileEn": "analytics.en.md",
|
||||||
|
"titleEn": "Analytics"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "plaene",
|
||||||
|
"file": "plaene.md",
|
||||||
|
"title": "Pläne & Berechtigungen",
|
||||||
|
"order": 11,
|
||||||
|
"fileEn": "plaene.en.md",
|
||||||
|
"titleEn": "Plans & Permissions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "kosten",
|
||||||
|
"file": "kosten.md",
|
||||||
|
"title": "Kosten erfassen",
|
||||||
|
"order": 12,
|
||||||
|
"fileEn": "kosten.en.md",
|
||||||
|
"titleEn": "Recording Costs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "administration",
|
||||||
|
"file": "administration.md",
|
||||||
|
"title": "Administration",
|
||||||
|
"order": 13,
|
||||||
|
"fileEn": "administration.en.md",
|
||||||
|
"titleEn": "Administration"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "redundanz",
|
||||||
|
"file": "redundanz.md",
|
||||||
|
"title": "Redundanz-Dashboard",
|
||||||
|
"order": 14,
|
||||||
|
"fileEn": "redundanz.en.md",
|
||||||
|
"titleEn": "Redundancy dashboard"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "papierkorb",
|
||||||
|
"file": "papierkorb.md",
|
||||||
|
"title": "Papierkorb",
|
||||||
|
"order": 15,
|
||||||
|
"fileEn": "papierkorb.en.md",
|
||||||
|
"titleEn": "Trash"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "tastatur",
|
||||||
|
"file": "tastatur.md",
|
||||||
|
"title": "Tastenkürzel & Kommandopalette",
|
||||||
|
"order": 16,
|
||||||
|
"fileEn": "tastatur.en.md",
|
||||||
|
"titleEn": "Keyboard shortcuts & command palette"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "datenmodell",
|
||||||
|
"file": "datenmodell.md",
|
||||||
|
"title": "Datenmodell",
|
||||||
|
"order": 17,
|
||||||
|
"fileEn": "datenmodell.en.md",
|
||||||
|
"titleEn": "Data model"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
|
||||||
|
# Willkommen bei toolr
|
||||||
|
|
||||||
|
toolr ist eine Plattform zum **Entdecken, Bewerten und Vergleichen von
|
||||||
|
Entwicklungstools**. Nutzer:innen pflegen einen gemeinsamen Katalog von Tools,
|
||||||
|
vergeben Bewertungen (Nützlichkeit & Bedienbarkeit) und nutzen Statistiken, um
|
||||||
|
die richtige Wahl zu treffen.
|
||||||
|
|
||||||
|
## Was kannst du mit toolr tun?
|
||||||
|
|
||||||
|
| Funktion | Beschreibung | Sichtbarkeit |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Tools durchsuchen** | Katalog filtern, sortieren und durchsuchen | Alle |
|
||||||
|
| **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 (1–5) plus Kommentar vergeben | Angemeldet |
|
||||||
|
| **Watchlist** | 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 |
|
||||||
|
| **Papierkorb** | Soft-gelöschte Tools wiederherstellen oder endgültig löschen | Premium |
|
||||||
|
| **Admin** | Nutzerverwaltung, Audit-Log, Systeminformationen | Admin |
|
||||||
|
| **Redundanz** | Automatische Doppelungs-Erkennung | Admin |
|
||||||
|
|
||||||
|
## Wie diese Doku aufgebaut ist
|
||||||
|
|
||||||
|
- **User Guide** (diese Seiten): Schritt-für-Schritt-Anleitungen für alle
|
||||||
|
Funktionen — von den [Ersten Schritten](/docs/handbook/getting-started) bis
|
||||||
|
zur [Administration](/docs/handbook/administration).
|
||||||
|
- **API-Referenz**: automatisch aus der OpenAPI-Spezifikation generiert — alle
|
||||||
|
[Endpunkte](/docs/reference/endpoints/tools) und
|
||||||
|
[Datenfelder](/docs/reference/schemas/toolinput) der aktuellen Version.
|
||||||
|
- **Release-Notes**: Was ist in welcher [Version](/docs/releases/v0.8.1) neu.
|
||||||
|
|
||||||
|
## Der Einstieg
|
||||||
|
|
||||||
|
Der schnellste Weg:
|
||||||
|
|
||||||
|
1. **Anmelden** — ohne Konto kannst du nur stöbern
|
||||||
|
(siehe [Erste Schritte](/docs/handbook/getting-started#1-anmelden)).
|
||||||
|
2. **Tools finden** — Suche, Filter und Sortierung im Bereich
|
||||||
|
[Tools durchsuchen](/docs/handbook/tools-finden).
|
||||||
|
3. **Tool anlegen** — über „Tool hinzufügen"
|
||||||
|
([Anleitung](/docs/handbook/tool-anlegen)).
|
||||||
|
4. **Bewerten** — auf der Detailseite eines Tools
|
||||||
|
([Anleitung](/docs/handbook/bewerten)).
|
||||||
|
|
||||||
|
## Kontakt & Quellcode
|
||||||
|
|
||||||
|
Der Quellcode liegt unter
|
||||||
|
[git.kubebase.de/admin/tool-evaluator](https://git.kubebase.de/admin/tool-evaluator) —
|
||||||
|
über das Repository-Icon oben rechts erreichst du ihn jederzeit.
|
||||||
@@ -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,53 @@
|
|||||||
|
|
||||||
|
# Anmelden & Konto
|
||||||
|
|
||||||
|
## Anmelden
|
||||||
|
|
||||||
|
Klicke unten links in der Seitenleiste auf **Anmelden**. Je nach Konfiguration
|
||||||
|
der Instanz:
|
||||||
|
|
||||||
|
- **Lokale Konten:** Benutzername und Passwort eingeben. Die Konten werden von
|
||||||
|
einem Admin angelegt (siehe [Administration](/docs/handbook/administration)).
|
||||||
|
- **OIDC (SSO):** Du wirst an den konfigurierten Identitätsanbieter
|
||||||
|
weitergeleitet und meldest dich dort an.
|
||||||
|
|
||||||
|
Der aktive Modus steht im Endpunkt
|
||||||
|
[`GET /auth/mode`](/docs/reference/endpoints/auth#getAuthMode).
|
||||||
|
|
||||||
|
> Die Login-Seite erreichst du direkt unter `/login`. Nach erfolgreicher
|
||||||
|
> Anmeldung wirst du zur ursprünglich aufgerufenen Seite zurückgeleitet.
|
||||||
|
|
||||||
|
## Benutzerprofil
|
||||||
|
|
||||||
|
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).
|
||||||
|
- **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.
|
||||||
|
- **Abmelden** — beendet deine Sitzung.
|
||||||
|
|
||||||
|
## Passwort ändern (lokales Konto)
|
||||||
|
|
||||||
|
1. Öffne das Benutzermenü unten links.
|
||||||
|
2. Wähle **Passwort ändern**.
|
||||||
|
3. Gib das **aktuelle** sowie ein **neues** Passwort ein (mind. 6 Zeichen) und
|
||||||
|
bestätige es.
|
||||||
|
4. Speichern — das Passwort wird sofort übernommen.
|
||||||
|
|
||||||
|
API-Referenz: [`POST /auth/me/password`](/docs/reference/endpoints/auth#changeMyPassword).
|
||||||
|
|
||||||
|
## Anzeigeeinstellungen
|
||||||
|
|
||||||
|
Über die Schaltflächen oben rechts kannst du:
|
||||||
|
|
||||||
|
- **Sprache** wechseln (Deutsch / Englisch),
|
||||||
|
- **Theme** umschalten (Hell / Dunkel / System),
|
||||||
|
- 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
|
||||||
|
[`GET /auth/me/preferences`](/docs/reference/endpoints/auth#getMePreferences)
|
||||||
|
gespeichert und über [`PUT /auth/me/preferences`](/docs/reference/endpoints/auth#updateMePreferences)
|
||||||
|
aktualisiert.
|
||||||
@@ -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,35 @@
|
|||||||
|
|
||||||
|
# Kosten erfassen
|
||||||
|
|
||||||
|
Auf der Detailseite eines Tools kannst du Kosten- und Lizenzmodelle eintragen,
|
||||||
|
damit die Gesamtkosten je Tool transparent werden.
|
||||||
|
|
||||||
|
> Kosten ist ein **Premium-Feature** (`costs`, Premium/Enterprise). Admins
|
||||||
|
> haben immer Zugriff.
|
||||||
|
|
||||||
|
## Kosten hinzufügen
|
||||||
|
|
||||||
|
Klicke auf **Kosten hinzufügen** im Kosten-Bereich der Detailseite und fülle
|
||||||
|
das Formular aus:
|
||||||
|
|
||||||
|
| Feld | Hinweise |
|
||||||
|
| --- | --- |
|
||||||
|
| **Lizenztyp** | Free / Subscription / One-Time / Usage-Based |
|
||||||
|
| **Abrechnungszeitraum** | Nur für „Subscription": Monatlich / Quartalsweise / Jährlich |
|
||||||
|
| **Kosten** | Betrag als Zahl |
|
||||||
|
| **Währung** | EUR / USD / GBP / CHF |
|
||||||
|
| **Notizen** | Optionaler Freitext |
|
||||||
|
|
||||||
|
Speichern legt den Eintrag an. Jeder Kosten-Eintrag wird als Karte mit
|
||||||
|
Lizenz-Badge, Abrechnungszeitraum, Betrag (`Betrag Währung` bzw. „Free") und
|
||||||
|
Notizen angezeigt.
|
||||||
|
|
||||||
|
## Kosten bearbeiten & löschen
|
||||||
|
|
||||||
|
Beim Überfahren einer Kosten-Karte erscheinen die Aktionen **Bearbeiten**
|
||||||
|
(Bleistift) und **Löschen** (Papierkorb).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Die Kosten-Daten werden über die Tool-Endpunkte verwaltet
|
||||||
|
(siehe [API-Referenz](/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 @@
|
|||||||
|
|
||||||
|
# Papierkorb
|
||||||
|
|
||||||
|
Der **Papierkorb** (`/trash`) enthält soft gelöschte Tools. Mit Papierkorb-Zugang
|
||||||
|
können sie wiederhergestellt werden; endgültiges Löschen ist Admins vorbehalten.
|
||||||
|
|
||||||
|
> Der Papierkorb ist ein **Premium-Feature** (`trash`, Premium/Enterprise).
|
||||||
|
> Admins haben immer Zugriff.
|
||||||
|
|
||||||
|
## Zugang
|
||||||
|
|
||||||
|
Der Papierkorb ist über das Benutzermenü oder die Seitenleiste erreichbar.
|
||||||
|
Ohne `trash`-Berechtigung erscheint ein Hinweis auf den Tarifwechsel.
|
||||||
|
|
||||||
|
## Wiederherstellen
|
||||||
|
|
||||||
|
- Markiere ein oder mehrere Tools (Checkboxen).
|
||||||
|
- Klicke auf **Wiederherstellen (N)** — die Tools erscheinen wieder in allen
|
||||||
|
öffentlichen Ansichten.
|
||||||
|
|
||||||
|
> Wiederherstellen steht jeder Person mit Papierkorb-Zugang zur Verfügung.
|
||||||
|
|
||||||
|
## Endgültig löschen (nur Admin)
|
||||||
|
|
||||||
|
- **Löschen (N)** entfernt die ausgewählten Tools **endgültig** — inklusive
|
||||||
|
aller Bewertungen, Kosten und Verknüpfungen. Das kann nicht rückgängig
|
||||||
|
gemacht werden.
|
||||||
|
- **Papierkorb leeren** entfernt alle soft gelöschten Tools endgültig.
|
||||||
|
|
||||||
|
## Tabelle
|
||||||
|
|
||||||
|
Der Papierkorb listet: Name, Kategorie, **Gelöscht am** (`tt.MM.jjjj HH:mm`),
|
||||||
|
**Gelöscht von** sowie Aktionen (Wiederherstellen; Löschen nur Admin). Die Suche
|
||||||
|
filtert nach Namen.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- [`GET /tools/trash`](/docs/reference/endpoints/tools#listTrashedTools) — Liste
|
||||||
|
- [`POST /tools/trash/restore`](/docs/reference/endpoints/tools#restoreTools) — Wiederherstellen
|
||||||
|
- [`DELETE /tools/trash`](/docs/reference/endpoints/tools#deleteTrashedTools) — Endgültig löschen (Admin)
|
||||||
|
- [`POST /tools/trash/empty`](/docs/reference/endpoints/tools#emptyTrash) — Papierkorb leeren (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,41 @@
|
|||||||
|
|
||||||
|
# Pläne & Berechtigungen
|
||||||
|
|
||||||
|
toolr unterscheidet **Tarife** (Tier) und **Rollen**. Admins umgehen alle
|
||||||
|
Feature-Beschränkungen.
|
||||||
|
|
||||||
|
## Tarife
|
||||||
|
|
||||||
|
| Tarif | Beschreibung |
|
||||||
|
| --- | --- |
|
||||||
|
| **Free** | Grundfunktionen: suchen, filtern, ansehen, Analytics |
|
||||||
|
| **Premium** | Zusätzlich Watchlist, Vergleichen, Papierkorb, Kosten |
|
||||||
|
| **Enterprise** | Alle Premium-Features + erweiterter Support |
|
||||||
|
|
||||||
|
### Feature-Berechtigungen
|
||||||
|
|
||||||
|
Premium/Enterprise schalten folgende Features frei:
|
||||||
|
|
||||||
|
| Feature | Funktion | Mehr erfahren |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `compare` | Tools vergleichen | [Vergleichen](/docs/handbook/vergleichen) |
|
||||||
|
| `watchlist` | Favoritenliste | [Watchlist](/docs/handbook/watchlist) |
|
||||||
|
| `trash` | Papierkorb (soft gelöschte Tools) | [Papierkorb](/docs/handbook/papierkorb) |
|
||||||
|
| `costs` | Kosten-/Lizenzmodelle erfassen | [Kosten erfassen](/docs/handbook/kosten) |
|
||||||
|
|
||||||
|
Fehlt dir ein Feature, zeigt die App einen **Upgrade-Hinweis** mit Link zur
|
||||||
|
Tarifverwaltung.
|
||||||
|
|
||||||
|
## Rollen
|
||||||
|
|
||||||
|
| Rolle | Berechtigungen |
|
||||||
|
| --- | --- |
|
||||||
|
| **User** | Standard-Konto: Tools anlegen/bewerten, eigene Tools bearbeiten |
|
||||||
|
| **Admin** | Alle User-Rechte + Verwaltung, Audit-Log, Redundanz, Papierkorb leeren, Tool-Verknüpfungen |
|
||||||
|
|
||||||
|
Admins passieren **alle** Feature-Checks — auch ohne Premium-Tarif.
|
||||||
|
|
||||||
|
## Tarif-/Rollenverwaltung
|
||||||
|
|
||||||
|
Die Zuordnung von Rolle und Tarif wird durch Admins im Bereich
|
||||||
|
[Administration](/docs/handbook/administration) (Tab „Nutzer") verwaltet.
|
||||||
@@ -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,38 @@
|
|||||||
|
|
||||||
|
# Redundanz-Dashboard
|
||||||
|
|
||||||
|
Das **Redundanz-Dashboard** (`/admin/redundancy`) ist ein Admin-Werkzeug zur
|
||||||
|
automatischen Erkennung doppelter oder stark überlappender Tools — jeweils
|
||||||
|
pro Kategorie — inklusive Kosten- und Bewertungsvergleich.
|
||||||
|
|
||||||
|
> Der Zugriff ist ausschließlich Admins vorbehalten (die API ist
|
||||||
|
> admin-geschützt).
|
||||||
|
|
||||||
|
## Aufbau
|
||||||
|
|
||||||
|
- **Pro Kategorie** wird eine Gruppe angezeigt: Name der Kategorie,
|
||||||
|
Anzahl Tools und Vergleiche sowie ggf. die **gesamten monatlichen Kosten**
|
||||||
|
(z. B. `€X.XX/mo gesamt`).
|
||||||
|
- Jedes Tool wird als Karte dargestellt: Name, monatliche Kosten, Anzahl der
|
||||||
|
Bewertungen, kombinierte Bewertung, Lizenz-Badges und Feature-Anzahl.
|
||||||
|
|
||||||
|
## Vergleiche & Empfehlungen
|
||||||
|
|
||||||
|
Für jedes Tool-Paar erscheint:
|
||||||
|
|
||||||
|
- Tool A vs. Tool B, jeweils mit Bewertung (`X.X ★`) und monatlichen Kosten.
|
||||||
|
- **Überlappung** in Prozent (Fortschrittsbalken in der Mitte).
|
||||||
|
- Eine **Empfehlung** mit Konfidenz-Farbe:
|
||||||
|
- **hoch** (grün), **mittel** (gelb), **niedrig** (grau)
|
||||||
|
- Das empfohlene, bessere Tool wird mit „Daumen hoch" markiert und begründet.
|
||||||
|
|
||||||
|
## Manuelle Bewertung
|
||||||
|
|
||||||
|
Du kannst ein Paar manuell bewerten: Klicke auf Tool A oder Tool B, um
|
||||||
|
festzuhalten, welches besser ist. Die Auswahl wird gespeichert und die
|
||||||
|
Darstellung aktualisiert.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- [`GET /api/admin/redundancy`](#) — Daten laden (admin-geschützt)
|
||||||
|
- [`POST /api/admin/redundancy/evaluate`](#) — manuelle Bewertung speichern
|
||||||
@@ -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,36 @@
|
|||||||
|
|
||||||
|
# Tastenkürzel & Kommandopalette
|
||||||
|
|
||||||
|
## Kommandopalette
|
||||||
|
|
||||||
|
Die Kommandopalette ist die zentrale Schnellnavigation:
|
||||||
|
|
||||||
|
- Öffnen mit **`⌘K`** (macOS) bzw. **`Ctrl+K`** (Windows/Linux).
|
||||||
|
- Alternativ über die Suchleiste oben rechts („Tools suchen… ⌘K") oder das
|
||||||
|
Such-Icon auf Mobilgeräten.
|
||||||
|
|
||||||
|
### Leerer Zustand
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Suche
|
||||||
|
|
||||||
|
Tippe, um live nach Tools zu suchen (max. 10 Ergebnisse, inkl. Bewertung
|
||||||
|
`X.X★`).
|
||||||
|
|
||||||
|
## Tastenkürzel im Überblick
|
||||||
|
|
||||||
|
| Kürzel | Aktion |
|
||||||
|
| --- | --- |
|
||||||
|
| `⌘K` / `Ctrl+K` | Kommandopalette öffnen |
|
||||||
|
| `/` | Suche im Bereich „Tools durchsuchen" fokussieren |
|
||||||
|
|
||||||
|
## Weitere Hinweise
|
||||||
|
|
||||||
|
- **Zuletzt angesehen** wird lokal im Browser gespeichert (max. 5 Einträge).
|
||||||
|
- Die Seitenleiste (linke Navigation) ist auf Desktop einklappbar; der
|
||||||
|
Breadcrumb oben zeigt deinen aktuellen Ort.
|
||||||
@@ -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,46 @@
|
|||||||
|
|
||||||
|
# Tool anlegen
|
||||||
|
|
||||||
|
Um ein neues Tool zum Katalog hinzuzufügen, klicke auf **Tool hinzufügen**
|
||||||
|
(`/tools/new`). Das Anlegen erfordert ein Konto — ohne Anmeldung erscheint ein
|
||||||
|
Hinweis mit Login-Button.
|
||||||
|
|
||||||
|
## Formularfelder
|
||||||
|
|
||||||
|
| Feld | Pflicht | Hinweise |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Name** | Ja | Mind. 2 Zeichen |
|
||||||
|
| **Kategorie** | Ja | Auswahlliste; neue Kategorien lassen sich direkt anlegen |
|
||||||
|
| **Website URL** | Nein | Gültige URL (z. B. `https://...`) |
|
||||||
|
| **Icon / Logo URL** | Nein | Gültige URL; Vorschau wird live angezeigt |
|
||||||
|
| **Beschreibung** | Ja | Mind. 10 Zeichen; beschreibe, was das Tool tut |
|
||||||
|
| **Features** | Nein | Dynamische Liste mit Autovervollständigung (max. 6) |
|
||||||
|
| **Tags** | Nein | Dynamische Liste mit Autovervollständigung |
|
||||||
|
|
||||||
|
Neben jedem Feld führt das **?‑Icon** direkt zur zugehörigen Feldbeschreibung
|
||||||
|
in der [Datenmodell-Referenz](/docs/reference/schemas/toolinput).
|
||||||
|
|
||||||
|
### Kategorie
|
||||||
|
|
||||||
|
- Tippe, um nach bestehenden Kategorien zu suchen.
|
||||||
|
- Wähle **+ Erstelle „..."**, um eine neue Kategorie anzulegen.
|
||||||
|
|
||||||
|
### Features & Tags
|
||||||
|
|
||||||
|
- **Feature hinzufügen** / **Tag hinzufügen** hängt eine neue Zeile an.
|
||||||
|
- Die Eingabefelder schlagen bestehende Features/Tags vor
|
||||||
|
(Autovervollständigung, max. 6 Vorschläge).
|
||||||
|
- Mit dem **×**‑Button entfernst du einzelne Zeilen.
|
||||||
|
- Features und Tags helfen beim Filtern und Wiederfinden.
|
||||||
|
|
||||||
|
## Speichern
|
||||||
|
|
||||||
|
Klicke auf **Tool hinzufügen**. Nach erfolgreicher Anlage wirst du auf die
|
||||||
|
Detailseite des neuen Tools weitergeleitet.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- [`POST /tools`](/docs/reference/endpoints/tools#createTool) — Tool anlegen
|
||||||
|
- [`GET /categories`](/docs/reference/endpoints/tools#listCategories) — Kategorien
|
||||||
|
- [`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,33 @@
|
|||||||
|
|
||||||
|
# Tool bearbeiten & löschen
|
||||||
|
|
||||||
|
## Bearbeiten
|
||||||
|
|
||||||
|
Auf der Detailseite eines Tools findest du die Schaltfläche **Bearbeiten**
|
||||||
|
(nur für die Person, die das Tool angelegt hat, sowie für Admins).
|
||||||
|
|
||||||
|
Die Bearbeitungsseite (`/tools/:id/edit`) enthält dieselben Felder wie beim
|
||||||
|
Anlegen (Name, Kategorie, Website/Icon-URL, Beschreibung, Features, Tags) —
|
||||||
|
bereits mit den aktuellen Werten befüllt.
|
||||||
|
|
||||||
|
- **Speichern** übernimmt die Änderungen.
|
||||||
|
- **Abbrechen** führt zurück zur Detailseite.
|
||||||
|
|
||||||
|
API-Referenz: [`PATCH /tools/{id}`](/docs/reference/endpoints/tools#updateTool).
|
||||||
|
|
||||||
|
## Löschen
|
||||||
|
|
||||||
|
Über **Löschen** auf der Detailseite wird das Tool entfernt. Das Verhalten
|
||||||
|
hängt von deinem Tarif ab:
|
||||||
|
|
||||||
|
- **Mit Papierkorb-Zugang** (Premium/Enterprise oder Admin): Das Tool wird
|
||||||
|
**soft gelöscht** — es verschwindet aus allen öffentlichen Ansichten, kann
|
||||||
|
aber im [Papierkorb](/docs/handbook/papierkorb) wiederhergestellt oder
|
||||||
|
endgültig gelöscht werden.
|
||||||
|
- **Ohne Papierkorb-Zugang:** Das Tool wird **endgültig** gelöscht und kann
|
||||||
|
nicht wiederhergestellt werden.
|
||||||
|
|
||||||
|
Die Löschung ist nur für die Person, die das Tool angelegt hat, sowie für
|
||||||
|
Admins möglich.
|
||||||
|
|
||||||
|
API-Referenz: [`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 (A–Z) | Alphabetically ascending |
|
||||||
|
| Name (Z–A) | 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,67 @@
|
|||||||
|
|
||||||
|
# Tools finden & durchsuchen
|
||||||
|
|
||||||
|
Der Bereich **Tools durchsuchen** (`/tools`) ist der Einstieg in den Katalog.
|
||||||
|
Hier kombinierst du Suche, Filter und Sortierung, um genau die Tools zu finden,
|
||||||
|
die dich interessieren.
|
||||||
|
|
||||||
|
## Suche
|
||||||
|
|
||||||
|
- Die **Suchleiste** durchsucht Name und Beschreibung (Volltext).
|
||||||
|
- Tastenkürzel: Drücke **`/`**, um die Suche zu fokussieren.
|
||||||
|
- Die Eingabe ist deaktiviert (Debounce), damit bei jedem Tastendruck sofort
|
||||||
|
nachgefiltert wird.
|
||||||
|
|
||||||
|
## Filtern
|
||||||
|
|
||||||
|
Über die Schaltfläche **Filter** (mit Badge für die Anzahl aktiver Filter)
|
||||||
|
öffnest du den Filter-Popover mit:
|
||||||
|
|
||||||
|
- **Tags** — Auswahl über Checkboxen (scrollbare Liste).
|
||||||
|
- **Features** — Auswahl über Checkboxen.
|
||||||
|
- **Mindestbewertung** — Schieberegler von 0 bis 5 (Schritte von 0,5); zeigt
|
||||||
|
z. B. „3.0+" an.
|
||||||
|
|
||||||
|
Aktive Filter erscheinen als **entfernbare Chips** über der Ergebnisliste.
|
||||||
|
Mit **Filter zurücksetzen** bzw. **Alle entfernen** räumst du sie wieder auf.
|
||||||
|
|
||||||
|
## Sortieren
|
||||||
|
|
||||||
|
Über das Dropdown **Sortieren** stehen folgende Optionen zur Verfügung:
|
||||||
|
|
||||||
|
| Sortierung | Beschreibung |
|
||||||
|
| --- | --- |
|
||||||
|
| Neueste | Neue Tools zuerst |
|
||||||
|
| Top bewertet | Nach kombinierter Bewertung |
|
||||||
|
| Meistbewertet | Nach Anzahl der Bewertungen |
|
||||||
|
| Name (A–Z) | Alphabetisch aufsteigend |
|
||||||
|
| Name (Z–A) | Alphabetisch absteigend |
|
||||||
|
| Zuletzt aktualisiert | Nach letztem Update |
|
||||||
|
|
||||||
|
## Ansicht & Dichte
|
||||||
|
|
||||||
|
- **Ansicht wechseln:** Raster / Tabelle / Zeilen.
|
||||||
|
- **Dichte:** gemütlich / kompakt (Schieberegler).
|
||||||
|
|
||||||
|
Deine Auswahl wird gespeichert — lokal im Browser und für angemeldete Nutzer:innen
|
||||||
|
zusätzlich serverseitig in den Präferenzen. Ansicht, Dichte, Suche, Filter und
|
||||||
|
Sortierung werden dabei in die URL übernommen, sodass du Ergebnisse teilen
|
||||||
|
kannst.
|
||||||
|
|
||||||
|
## Tabellenansicht
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
- 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).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Alle Such-, Filter- und Sortierparameter entsprechen den Query-Parametern von
|
||||||
|
[`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,42 @@
|
|||||||
|
|
||||||
|
# Vergleichen
|
||||||
|
|
||||||
|
Mit der Vergleichsfunktion stellst du mehrere Tools **nebeneinander** gegenüber —
|
||||||
|
ideal, um eine fundierte Entscheidung zu treffen.
|
||||||
|
|
||||||
|
> Vergleichen ist ein **Premium-Feature** (Premium/Enterprise) und steht Admins
|
||||||
|
> immer zur Verfügung.
|
||||||
|
|
||||||
|
## Tools auswählen
|
||||||
|
|
||||||
|
1. Im Bereich **Tools durchsuchen** klickst du auf jeder Karte/Zeile auf das
|
||||||
|
**Vergleichs-Icon** (Waage).
|
||||||
|
2. Unten erscheint die **Vergleichsleiste** mit den ausgewählten Tools als
|
||||||
|
Chips. Du kannst einzelne Tools entfernen (×) oder die Auswahl leeren.
|
||||||
|
3. Klicke auf **Vergleichen (N)**, um zur Vergleichsansicht zu gelangen.
|
||||||
|
|
||||||
|
> Ohne Premium-Tarif ist der Button gesperrt (Schloss-Icon). Über den
|
||||||
|
> Dialog gelangst du zum Tarifwechsel
|
||||||
|
> (siehe [Pläne & Berechtigungen](/docs/handbook/plaene)).
|
||||||
|
|
||||||
|
## Die Vergleichsansicht
|
||||||
|
|
||||||
|
Die Ansicht zeigt eine Tabelle mit einer Spalte pro Tool. Zeilen:
|
||||||
|
|
||||||
|
| Zeile | Inhalt |
|
||||||
|
| --- | --- |
|
||||||
|
| **Bewertung** | Sterne + Wert (z. B. `4.2/5`) |
|
||||||
|
| **Nützlichkeit** | Wert (X.X/5) |
|
||||||
|
| **Bedienbarkeit** | Wert (X.X/5) |
|
||||||
|
| **Anzahl Bewertungen** | Anzahl |
|
||||||
|
| **Beschreibung** | Text |
|
||||||
|
| **Features** | Badges |
|
||||||
|
| **Tags** | Badges |
|
||||||
|
| **Zuletzt aktualisiert** | Datum |
|
||||||
|
|
||||||
|
Der **beste Wert** pro Zeile wird hervorgehoben (mit Trophäen-Icon).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Die Vergleichsansicht liest die Daten über
|
||||||
|
[`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).
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
|
||||||
|
# Watchlist
|
||||||
|
|
||||||
|
Die **Watchlist** 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
|
||||||
|
> Admins immer zur Verfügung.
|
||||||
|
|
||||||
|
## Voraussetzung
|
||||||
|
|
||||||
|
Du benötigst einen Tarif mit `watchlist`-Berechtigung. Fehlt diese, erscheint
|
||||||
|
beim Lesezeichen ein Hinweis auf den Tarifwechsel
|
||||||
|
(siehe [Pläne & Berechtigungen](/docs/handbook/plaene)).
|
||||||
|
|
||||||
|
## Tool speichern
|
||||||
|
|
||||||
|
- 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 erneuter Klick entfernt es wieder.
|
||||||
|
|
||||||
|
## Watchlist ansehen
|
||||||
|
|
||||||
|
Öffne die Watchlist ü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?
|
||||||
|
|
||||||
|
Die Watchlist 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).
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# v0.8.2 — Release Notes
|
# v0.8.2 — Release Notes
|
||||||
|
|
||||||
**Datum:** 2026-08-04 · **Tag:** [`v0.8.2`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.8.2)
|
**Datum:** 2026-08-04 · **Tag:** [`v0.8.2`](https://git.kubebase.de/admin/tool-evaluator/releases/tag/v0.8.2)
|
||||||
|
|
||||||
## Fixes & Verbesserungen
|
## Fixes & Verbesserungen
|
||||||
|
|
||||||
@@ -33,4 +33,4 @@
|
|||||||
## Links
|
## Links
|
||||||
|
|
||||||
- Commit: [`33cf261`](https://git.kubebase.de/admin/tool-evaluator/commit/33cf261)
|
- Commit: [`33cf261`](https://git.kubebase.de/admin/tool-evaluator/commit/33cf261)
|
||||||
- Tag: [`v0.8.2`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.8.2)
|
- 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,61 @@
|
|||||||
|
|
||||||
|
# Administration
|
||||||
|
|
||||||
|
Der Bereich **Admin** (`/admin`) ist ausschließlich für Admins zugänglich.
|
||||||
|
Ohne Admin-Rolle erscheint eine Zugriffsverweigerung.
|
||||||
|
|
||||||
|
> Oben rechts führt die Schaltfläche **Redundanz-Dashboard** zur automatischen
|
||||||
|
> Doppelungs-Erkennung (siehe [Redundanz](/docs/handbook/redundanz)).
|
||||||
|
|
||||||
|
## Tab „Nutzer"
|
||||||
|
|
||||||
|
Verwaltung der lokalen Konten.
|
||||||
|
|
||||||
|
- **Nutzer hinzufügen:** Benutzername (Pflicht), Passwort (mind. 6 Zeichen),
|
||||||
|
E-Mail (optional), **Rolle** (User/Admin), **Tarif** (Free/Premium/Enterprise).
|
||||||
|
- **Nutzer bearbeiten:** Rolle, Tarif und (für lokale Konten) ein neues Passwort
|
||||||
|
setzen. Für OIDC-Konten wird die Passwortverwaltung im Identitätsanbieter
|
||||||
|
(z. B. Keycloak) angeboten.
|
||||||
|
- **Nutzer löschen:** Entfernt das Konto endgültig (nicht für das eigene Konto).
|
||||||
|
|
||||||
|
API-Referenz:
|
||||||
|
[`POST /users`](/docs/reference/endpoints/users#createUser),
|
||||||
|
[`PATCH /users/{id}`](/docs/reference/endpoints/users#updateUser),
|
||||||
|
[`DELETE /users/{id}`](/docs/reference/endpoints/users#deleteUser).
|
||||||
|
|
||||||
|
## Tab „Tools"
|
||||||
|
|
||||||
|
Zentraler Zugriff auf den Tool-Katalog.
|
||||||
|
|
||||||
|
- **Suchen** nach Tools.
|
||||||
|
- Tools einzeln ansehen, bearbeiten oder in den Papierkorb verschieben.
|
||||||
|
- **Massenaktion:** mehrere Tools auswählen und in den Papierkorb verschieben
|
||||||
|
(Bestätigungsdialog; soft gelöschte Tools sind aus allen öffentlichen Ansichten
|
||||||
|
entfernt und können wiederhergestellt oder endgültig gelöscht werden).
|
||||||
|
|
||||||
|
## Tab „Audit-Log"
|
||||||
|
|
||||||
|
Chronologisches Protokoll aller Anlage-, Änderungs- und Löschvorgänge
|
||||||
|
(max. 100 Einträge): Aktion, Entität + ID, Zeitstempel, ausführende Person und
|
||||||
|
geänderte Felder.
|
||||||
|
|
||||||
|
API-Referenz: [`GET /audit-logs`](/docs/reference/endpoints/audit#listAuditLogs).
|
||||||
|
|
||||||
|
## Tab „System"
|
||||||
|
|
||||||
|
Versionsinformationen der laufenden Instanz:
|
||||||
|
|
||||||
|
- **Version** (z. B. `v0.8.1`),
|
||||||
|
- **Commit** (7-stelliger SHA, verlinkt zum Repository),
|
||||||
|
- **Build-Datum**,
|
||||||
|
- **Papierkorb-Aufbewahrung** („N Tage" oder „Für immer").
|
||||||
|
|
||||||
|
## Tool-Verknüpfungen (Admin)
|
||||||
|
|
||||||
|
Auf der Detailseite eines Tools kannst du als Admin **Verknüpfungen**
|
||||||
|
(eigene/„manual" sowie automatisch erkannte) verwalten:
|
||||||
|
|
||||||
|
- **Tool verknüpfen:** Dialog mit Tool-ID, **Beziehungstyp**
|
||||||
|
(Ähnlich / Ersetzt / Abgelöst durch) und optionalen Notizen.
|
||||||
|
- Beziehungstypen werden als Badges auf der Detailseite angezeigt.
|
||||||
|
- Manuelle Verknüpfungen lassen sich per Papierkorb-Icon wieder entfernen.
|
||||||
@@ -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 (0–5) |
|
||||||
|
| **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,29 @@
|
|||||||
|
|
||||||
|
# Analytics
|
||||||
|
|
||||||
|
Der Bereich **Analytics** (`/analytics`) ist ein öffentliches Dashboard mit
|
||||||
|
Kennzahlen und Diagrammen auf Basis aller Tools und Bewertungen.
|
||||||
|
|
||||||
|
## Kennzahlen (KPI-Karten)
|
||||||
|
|
||||||
|
- **Anzahl Tools** — wie viele Tools sind im Katalog erfasst.
|
||||||
|
- **Anzahl Bewertungen** — wie viele Bewertungen wurden insgesamt abgegeben.
|
||||||
|
- **Aktive Kategorien** — wie viele Kategorien existieren.
|
||||||
|
- **Durchschnittliche Bewertung** — globaler kombinierter Wert.
|
||||||
|
|
||||||
|
## Diagramme
|
||||||
|
|
||||||
|
| Diagramm | Inhalt |
|
||||||
|
| --- | --- |
|
||||||
|
| **Top 8 Tools** | Balkendiagramm der Tools mit der höchsten kombinierten Punktzahl (0–5) |
|
||||||
|
| **Tools je Kategorie** | Radar-Diagramm der Tool-Anzahl pro Kategorie |
|
||||||
|
| **Punkteverteilung** | Zwei horizontale Balken-Diagramme (Nützlichkeit & Bedienbarkeit) pro Stern |
|
||||||
|
|
||||||
|
Die Diagramme sind interaktiv (Tooltips beim Überfahren).
|
||||||
|
|
||||||
|
## 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 | 1–5 stars |
|
||||||
|
| **Usability** | Yes | 1–5 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,39 @@
|
|||||||
|
|
||||||
|
# Bewerten
|
||||||
|
|
||||||
|
Auf der Detailseite eines Tools kannst du deine Erfahrung teilen. Klicke auf
|
||||||
|
**Bewertung abgeben** (erfordert ein Konto).
|
||||||
|
|
||||||
|
## Formularfelder
|
||||||
|
|
||||||
|
| Feld | Pflicht | Hinweise |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Nützlichkeit** | Ja | 1–5 Sterne |
|
||||||
|
| **Bedienbarkeit** | Ja | 1–5 Sterne |
|
||||||
|
| **Kommentar** | Nein | Freitext |
|
||||||
|
| **Name** | Nein | Standard „Anonym" |
|
||||||
|
|
||||||
|
Neben den Feldern führt das **?‑Icon** direkt zur zugehörigen Feldbeschreibung
|
||||||
|
in der [Datenmodell-Referenz](/docs/reference/schemas/ratinginput).
|
||||||
|
|
||||||
|
## Was passiert nach dem Abgeben?
|
||||||
|
|
||||||
|
- Deine Bewertung wird sofort gespeichert und erscheint in der
|
||||||
|
**Bewertungsliste** der Detailseite.
|
||||||
|
- Die **Durchschnittswerte** (Nützlichkeit, Bedienbarkeit, Kombiniert) und die
|
||||||
|
**Punkteverteilung** werden aktualisiert.
|
||||||
|
- Die **Statistiken** im Bereich [Analytics](/docs/handbook/analytics) werden
|
||||||
|
neu berechnet.
|
||||||
|
|
||||||
|
## Statistik-Bereiche auf der Detailseite
|
||||||
|
|
||||||
|
- **Bewertungsübersicht:** Nützlichkeit & Bedienbarkeit als Durchschnitt mit
|
||||||
|
Fortschrittsbalken.
|
||||||
|
- **Punkteverteilung:** Anzahl der Bewertungen pro Stern (1★–5★).
|
||||||
|
- **Verlauf:** Linienchart der kombinierten/Teilwerte über die Zeit
|
||||||
|
(erst ab mehreren Bewertungen sichtbar).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- [`POST /tools/{id}/ratings`](/docs/reference/endpoints/ratings#createRating) — Bewertung abgeben
|
||||||
|
- [`GET /tools/{id}/ratings`](/docs/reference/endpoints/ratings#listToolRatings) — Bewertungen eines Tools
|
||||||
@@ -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, 1–5) and `usability` (Bedienbarkeit, 1–5)
|
||||||
|
- 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,70 @@
|
|||||||
|
|
||||||
|
# Datenmodell
|
||||||
|
|
||||||
|
Dieses Kapitel erklärt die zentralen Datenobjekte von toolr auf Ebene der
|
||||||
|
Anwendung. Die vollständige, automatisch generierte Referenz aller Felder,
|
||||||
|
Typen und Constraints findest du in der
|
||||||
|
[API-Referenz](/docs/reference/schemas/tool).
|
||||||
|
|
||||||
|
## Tool
|
||||||
|
|
||||||
|
Das Herzstück: ein im Katalog erfasstes Werkzeug.
|
||||||
|
|
||||||
|
| Eigenschaft | Beschreibung |
|
||||||
|
| --- | --- |
|
||||||
|
| `id` | Eindeutige Kennung |
|
||||||
|
| `name` | Anzeigename |
|
||||||
|
| `description` | Beschreibung (Was macht das Tool?) |
|
||||||
|
| `category` | Kategorie-Zuordnung |
|
||||||
|
| `websiteUrl` | Offizielle Website (optional) |
|
||||||
|
| `iconUrl` | Logo-/Icon-URL (optional) |
|
||||||
|
| `features` | Liste von Fähigkeiten |
|
||||||
|
| `tags` | Liste von Schlagwörtern |
|
||||||
|
| `createdAt` / `updatedAt` | Zeitstempel |
|
||||||
|
| `createdBy` | Erstellende Person |
|
||||||
|
| `deletedAt` / `deletedBy` | Soft-Löschung (Papierkorb) |
|
||||||
|
|
||||||
|
Eingabe-Formulare verwenden die abgeleiteten Schemas
|
||||||
|
[`ToolInput`](/docs/reference/schemas/toolinput) und
|
||||||
|
[`ToolUpdate`](/docs/reference/schemas/toolupdate).
|
||||||
|
Aggregierte Ansichten liefert [`ToolWithStats`](/docs/reference/schemas/toolwithstats)
|
||||||
|
(z. B. mit Durchschnittsbewertung).
|
||||||
|
|
||||||
|
## Rating (Bewertung)
|
||||||
|
|
||||||
|
Eine einzelne Bewertung zu einem Tool:
|
||||||
|
|
||||||
|
- `usefulness` (Nützlichkeit, 1–5) und `usability` (Bedienbarkeit, 1–5)
|
||||||
|
- optional `comment` und ein Anzeigename (`reviewerName`)
|
||||||
|
- Zeitstempel
|
||||||
|
|
||||||
|
Eingabe-Schema: [`RatingInput`](/docs/reference/schemas/ratinginput).
|
||||||
|
|
||||||
|
## User & Auth
|
||||||
|
|
||||||
|
- [`User`](/docs/reference/schemas/user) — Benutzerkonto mit Rolle (User/Admin)
|
||||||
|
und Tarif (Free/Premium/Enterprise).
|
||||||
|
- [`AuthUser`](/docs/reference/schemas/authuser) — das angemeldete Profil
|
||||||
|
inklusive `entitlements` (verfügbare Features).
|
||||||
|
- [`UserPreferences`](/docs/reference/schemas/userpreferences) — Ansichts- und
|
||||||
|
Dichte-Präferenzen sowie die `watchlist` (Liste von Tool-IDs).
|
||||||
|
|
||||||
|
## Analytics
|
||||||
|
|
||||||
|
Die Statistik-Endpunkte liefern aggregierte Daten:
|
||||||
|
|
||||||
|
- [`AnalyticsSummary`](/docs/reference/schemas/analyticssummary) — globale
|
||||||
|
Kennzahlen (Anzahl Tools/Bewertungen, Kategorien, Durchschnitt).
|
||||||
|
- [`TopToolEntry`](/docs/reference/schemas/TopToolEntry) — ein Eintrag der
|
||||||
|
Top-Tools.- [`CategoryStats`](/docs/reference/schemas/categorystats) — Tool-Anzahl je
|
||||||
|
Kategorie.
|
||||||
|
- [`RatingDistribution`](/docs/reference/schemas/ratingdistribution) —
|
||||||
|
Punkteverteilung (Nützlichkeit & Bedienbarkeit).
|
||||||
|
- [`ScoreBucket`](/docs/reference/schemas/scorebucket) — ein Werte-Bucket.
|
||||||
|
|
||||||
|
## Weitere
|
||||||
|
|
||||||
|
- [`VersionInfo`](/docs/reference/schemas/versioninfo) — Version, Commit-SHA,
|
||||||
|
Build-Datum und Papierkorb-Aufbewahrung der laufenden Instanz.
|
||||||
|
- [`AuditLog`](/docs/reference/schemas/auditlog) — ein Protokolleintrag
|
||||||
|
(Aktion, Entität, Zeitstempel, Akteur, Änderungen).
|
||||||
@@ -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**
|
||||||
|
(1–5 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)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
|
||||||
|
# Erste Schritte
|
||||||
|
|
||||||
|
Diese Seite führt dich durch die wichtigsten Abläufe in toolr — vom ersten
|
||||||
|
Besuch bis zum Anlegen und Bewerten eines Tools.
|
||||||
|
|
||||||
|
## 1. Anmelden
|
||||||
|
|
||||||
|
Die meisten Aktionen (Tool anlegen, bewerten, Watchlist, Vergleichen) erfordern
|
||||||
|
ein Konto. Klicke unten links auf **Anmelden**. Je nach Konfiguration der
|
||||||
|
Instanz hast du zwei Möglichkeiten:
|
||||||
|
|
||||||
|
- **Lokale Konten:** Benutzername + Passwort. Der Zugang wird von einem Admin
|
||||||
|
angelegt (siehe [Administration](/docs/handbook/administration)).
|
||||||
|
- **OIDC (SSO):** Anmelden mit dem konfigurierten Identitätsanbieter (z. B.
|
||||||
|
Keycloak).
|
||||||
|
|
||||||
|
Welcher Modus aktiv ist, steht im Endpunkt
|
||||||
|
[`GET /auth/mode`](/docs/reference/endpoints/auth#getAuthMode). Details findest
|
||||||
|
du im Abschnitt [Anmelden & Konto](/docs/handbook/konto).
|
||||||
|
|
||||||
|
## 2. Tools finden
|
||||||
|
|
||||||
|
Öffne den Bereich **Tools durchsuchen**:
|
||||||
|
|
||||||
|
- **Suchen** — Volltextsuche über Name & Beschreibung (Tastenkürzel `/`).
|
||||||
|
- **Filtern** — nach Kategorie, Tags, Features und Mindestbewertung
|
||||||
|
(`minRating`).
|
||||||
|
- **Sortieren** — nach Aktualität, Top-Bewertung, meistbewertet, Name
|
||||||
|
(auf-/absteigend) oder letztem Update.
|
||||||
|
|
||||||
|
Alle Optionen im Detail: [Tools finden & durchsuchen](/docs/handbook/tools-finden).
|
||||||
|
|
||||||
|
## 3. Tool anlegen
|
||||||
|
|
||||||
|
Gehe auf **Tool hinzufügen** und fülle das Formular aus. Details zu jedem Feld
|
||||||
|
findest du im Abschnitt [Tool anlegen](/docs/handbook/tool-anlegen) und in der
|
||||||
|
[Feld-Referenz](/docs/reference/schemas/toolinput).
|
||||||
|
|
||||||
|
## 4. Bewerten
|
||||||
|
|
||||||
|
Auf der Detailseite eines Tools kannst du **Nützlichkeit** und **Bedienbarkeit**
|
||||||
|
(jeweils 1–5) vergeben und optional einen Kommentar hinterlassen. Deine
|
||||||
|
Bewertung fließt sofort in die Statistiken ein.
|
||||||
|
Siehe [Bewerten](/docs/handbook/bewerten).
|
||||||
|
|
||||||
|
## 5. Weiterführend
|
||||||
|
|
||||||
|
- [Tools vergleichen](/docs/handbook/vergleichen)
|
||||||
|
- [Watchlist](/docs/handbook/watchlist)
|
||||||
|
- [Analytics](/docs/handbook/analytics)
|
||||||
|
- [Pläne & Berechtigungen](/docs/handbook/plaene)
|
||||||
|
- [Administration](/docs/handbook/administration)
|
||||||
@@ -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 (1–5) 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.
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"slug": "index",
|
||||||
|
"file": "index.md",
|
||||||
|
"title": "Überblick",
|
||||||
|
"order": 1,
|
||||||
|
"fileEn": "index.en.md",
|
||||||
|
"titleEn": "Overview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "getting-started",
|
||||||
|
"file": "getting-started.md",
|
||||||
|
"title": "Erste Schritte",
|
||||||
|
"order": 2,
|
||||||
|
"fileEn": "getting-started.en.md",
|
||||||
|
"titleEn": "Getting Started"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "konto",
|
||||||
|
"file": "konto.md",
|
||||||
|
"title": "Anmelden & Konto",
|
||||||
|
"order": 3,
|
||||||
|
"fileEn": "konto.en.md",
|
||||||
|
"titleEn": "Login & Account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "tools-finden",
|
||||||
|
"file": "tools-finden.md",
|
||||||
|
"title": "Tools finden & durchsuchen",
|
||||||
|
"order": 4,
|
||||||
|
"fileEn": "tools-finden.en.md",
|
||||||
|
"titleEn": "Find & browse tools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "tool-anlegen",
|
||||||
|
"file": "tool-anlegen.md",
|
||||||
|
"title": "Tool anlegen",
|
||||||
|
"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,
|
||||||
|
"fileEn": "tool-bearbeiten.en.md",
|
||||||
|
"titleEn": "Edit & delete tools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "bewerten",
|
||||||
|
"file": "bewerten.md",
|
||||||
|
"title": "Bewerten",
|
||||||
|
"order": 7,
|
||||||
|
"fileEn": "bewerten.en.md",
|
||||||
|
"titleEn": "Rating"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "watchlist",
|
||||||
|
"file": "watchlist.md",
|
||||||
|
"title": "Watchlist",
|
||||||
|
"order": 8,
|
||||||
|
"fileEn": "watchlist.en.md",
|
||||||
|
"titleEn": "Watchlist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "vergleichen",
|
||||||
|
"file": "vergleichen.md",
|
||||||
|
"title": "Vergleichen",
|
||||||
|
"order": 9,
|
||||||
|
"fileEn": "vergleichen.en.md",
|
||||||
|
"titleEn": "Compare"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "analytics",
|
||||||
|
"file": "analytics.md",
|
||||||
|
"title": "Analytics",
|
||||||
|
"order": 10,
|
||||||
|
"fileEn": "analytics.en.md",
|
||||||
|
"titleEn": "Analytics"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "plaene",
|
||||||
|
"file": "plaene.md",
|
||||||
|
"title": "Pläne & Berechtigungen",
|
||||||
|
"order": 11,
|
||||||
|
"fileEn": "plaene.en.md",
|
||||||
|
"titleEn": "Plans & Permissions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "kosten",
|
||||||
|
"file": "kosten.md",
|
||||||
|
"title": "Kosten erfassen",
|
||||||
|
"order": 12,
|
||||||
|
"fileEn": "kosten.en.md",
|
||||||
|
"titleEn": "Recording Costs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "administration",
|
||||||
|
"file": "administration.md",
|
||||||
|
"title": "Administration",
|
||||||
|
"order": 13,
|
||||||
|
"fileEn": "administration.en.md",
|
||||||
|
"titleEn": "Administration"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "redundanz",
|
||||||
|
"file": "redundanz.md",
|
||||||
|
"title": "Redundanz-Dashboard",
|
||||||
|
"order": 14,
|
||||||
|
"fileEn": "redundanz.en.md",
|
||||||
|
"titleEn": "Redundancy dashboard"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "papierkorb",
|
||||||
|
"file": "papierkorb.md",
|
||||||
|
"title": "Papierkorb",
|
||||||
|
"order": 15,
|
||||||
|
"fileEn": "papierkorb.en.md",
|
||||||
|
"titleEn": "Trash"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "tastatur",
|
||||||
|
"file": "tastatur.md",
|
||||||
|
"title": "Tastenkürzel & Kommandopalette",
|
||||||
|
"order": 16,
|
||||||
|
"fileEn": "tastatur.en.md",
|
||||||
|
"titleEn": "Keyboard shortcuts & command palette"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "datenmodell",
|
||||||
|
"file": "datenmodell.md",
|
||||||
|
"title": "Datenmodell",
|
||||||
|
"order": 17,
|
||||||
|
"fileEn": "datenmodell.en.md",
|
||||||
|
"titleEn": "Data model"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
|
||||||
|
# Willkommen bei toolr
|
||||||
|
|
||||||
|
toolr ist eine Plattform zum **Entdecken, Bewerten und Vergleichen von
|
||||||
|
Entwicklungstools**. Nutzer:innen pflegen einen gemeinsamen Katalog von Tools,
|
||||||
|
vergeben Bewertungen (Nützlichkeit & Bedienbarkeit) und nutzen Statistiken, um
|
||||||
|
die richtige Wahl zu treffen.
|
||||||
|
|
||||||
|
## Was kannst du mit toolr tun?
|
||||||
|
|
||||||
|
| Funktion | Beschreibung | Sichtbarkeit |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Tools durchsuchen** | Katalog filtern, sortieren und durchsuchen | Alle |
|
||||||
|
| **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 (1–5) plus Kommentar vergeben | Angemeldet |
|
||||||
|
| **Watchlist** | 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 |
|
||||||
|
| **Papierkorb** | Soft-gelöschte Tools wiederherstellen oder endgültig löschen | Premium |
|
||||||
|
| **Admin** | Nutzerverwaltung, Audit-Log, Systeminformationen | Admin |
|
||||||
|
| **Redundanz** | Automatische Doppelungs-Erkennung | Admin |
|
||||||
|
|
||||||
|
## Wie diese Doku aufgebaut ist
|
||||||
|
|
||||||
|
- **User Guide** (diese Seiten): Schritt-für-Schritt-Anleitungen für alle
|
||||||
|
Funktionen — von den [Ersten Schritten](/docs/handbook/getting-started) bis
|
||||||
|
zur [Administration](/docs/handbook/administration).
|
||||||
|
- **API-Referenz**: automatisch aus der OpenAPI-Spezifikation generiert — alle
|
||||||
|
[Endpunkte](/docs/reference/endpoints/tools) und
|
||||||
|
[Datenfelder](/docs/reference/schemas/toolinput) der aktuellen Version.
|
||||||
|
- **Release-Notes**: Was ist in welcher [Version](/docs/releases/v0.8.1) neu.
|
||||||
|
|
||||||
|
## Der Einstieg
|
||||||
|
|
||||||
|
Der schnellste Weg:
|
||||||
|
|
||||||
|
1. **Anmelden** — ohne Konto kannst du nur stöbern
|
||||||
|
(siehe [Erste Schritte](/docs/handbook/getting-started#1-anmelden)).
|
||||||
|
2. **Tools finden** — Suche, Filter und Sortierung im Bereich
|
||||||
|
[Tools durchsuchen](/docs/handbook/tools-finden).
|
||||||
|
3. **Tool anlegen** — über „Tool hinzufügen"
|
||||||
|
([Anleitung](/docs/handbook/tool-anlegen)).
|
||||||
|
4. **Bewerten** — auf der Detailseite eines Tools
|
||||||
|
([Anleitung](/docs/handbook/bewerten)).
|
||||||
|
|
||||||
|
## Kontakt & Quellcode
|
||||||
|
|
||||||
|
Der Quellcode liegt unter
|
||||||
|
[git.kubebase.de/admin/tool-evaluator](https://git.kubebase.de/admin/tool-evaluator) —
|
||||||
|
über das Repository-Icon oben rechts erreichst du ihn jederzeit.
|
||||||
@@ -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,53 @@
|
|||||||
|
|
||||||
|
# Anmelden & Konto
|
||||||
|
|
||||||
|
## Anmelden
|
||||||
|
|
||||||
|
Klicke unten links in der Seitenleiste auf **Anmelden**. Je nach Konfiguration
|
||||||
|
der Instanz:
|
||||||
|
|
||||||
|
- **Lokale Konten:** Benutzername und Passwort eingeben. Die Konten werden von
|
||||||
|
einem Admin angelegt (siehe [Administration](/docs/handbook/administration)).
|
||||||
|
- **OIDC (SSO):** Du wirst an den konfigurierten Identitätsanbieter
|
||||||
|
weitergeleitet und meldest dich dort an.
|
||||||
|
|
||||||
|
Der aktive Modus steht im Endpunkt
|
||||||
|
[`GET /auth/mode`](/docs/reference/endpoints/auth#getAuthMode).
|
||||||
|
|
||||||
|
> Die Login-Seite erreichst du direkt unter `/login`. Nach erfolgreicher
|
||||||
|
> Anmeldung wirst du zur ursprünglich aufgerufenen Seite zurückgeleitet.
|
||||||
|
|
||||||
|
## Benutzerprofil
|
||||||
|
|
||||||
|
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).
|
||||||
|
- **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.
|
||||||
|
- **Abmelden** — beendet deine Sitzung.
|
||||||
|
|
||||||
|
## Passwort ändern (lokales Konto)
|
||||||
|
|
||||||
|
1. Öffne das Benutzermenü unten links.
|
||||||
|
2. Wähle **Passwort ändern**.
|
||||||
|
3. Gib das **aktuelle** sowie ein **neues** Passwort ein (mind. 6 Zeichen) und
|
||||||
|
bestätige es.
|
||||||
|
4. Speichern — das Passwort wird sofort übernommen.
|
||||||
|
|
||||||
|
API-Referenz: [`POST /auth/me/password`](/docs/reference/endpoints/auth#changeMyPassword).
|
||||||
|
|
||||||
|
## Anzeigeeinstellungen
|
||||||
|
|
||||||
|
Über die Schaltflächen oben rechts kannst du:
|
||||||
|
|
||||||
|
- **Sprache** wechseln (Deutsch / Englisch),
|
||||||
|
- **Theme** umschalten (Hell / Dunkel / System),
|
||||||
|
- 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
|
||||||
|
[`GET /auth/me/preferences`](/docs/reference/endpoints/auth#getMePreferences)
|
||||||
|
gespeichert und über [`PUT /auth/me/preferences`](/docs/reference/endpoints/auth#updateMePreferences)
|
||||||
|
aktualisiert.
|
||||||
@@ -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,35 @@
|
|||||||
|
|
||||||
|
# Kosten erfassen
|
||||||
|
|
||||||
|
Auf der Detailseite eines Tools kannst du Kosten- und Lizenzmodelle eintragen,
|
||||||
|
damit die Gesamtkosten je Tool transparent werden.
|
||||||
|
|
||||||
|
> Kosten ist ein **Premium-Feature** (`costs`, Premium/Enterprise). Admins
|
||||||
|
> haben immer Zugriff.
|
||||||
|
|
||||||
|
## Kosten hinzufügen
|
||||||
|
|
||||||
|
Klicke auf **Kosten hinzufügen** im Kosten-Bereich der Detailseite und fülle
|
||||||
|
das Formular aus:
|
||||||
|
|
||||||
|
| Feld | Hinweise |
|
||||||
|
| --- | --- |
|
||||||
|
| **Lizenztyp** | Free / Subscription / One-Time / Usage-Based |
|
||||||
|
| **Abrechnungszeitraum** | Nur für „Subscription": Monatlich / Quartalsweise / Jährlich |
|
||||||
|
| **Kosten** | Betrag als Zahl |
|
||||||
|
| **Währung** | EUR / USD / GBP / CHF |
|
||||||
|
| **Notizen** | Optionaler Freitext |
|
||||||
|
|
||||||
|
Speichern legt den Eintrag an. Jeder Kosten-Eintrag wird als Karte mit
|
||||||
|
Lizenz-Badge, Abrechnungszeitraum, Betrag (`Betrag Währung` bzw. „Free") und
|
||||||
|
Notizen angezeigt.
|
||||||
|
|
||||||
|
## Kosten bearbeiten & löschen
|
||||||
|
|
||||||
|
Beim Überfahren einer Kosten-Karte erscheinen die Aktionen **Bearbeiten**
|
||||||
|
(Bleistift) und **Löschen** (Papierkorb).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Die Kosten-Daten werden über die Tool-Endpunkte verwaltet
|
||||||
|
(siehe [API-Referenz](/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)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user