feat(i18n): full app + docs localization (de/en)
Build & Push Docker Image / build (push) Successful in 2m33s
Build & Push Docker Image / build (push) Successful in 2m33s
- translate remaining pages/components (admin, analytics, redundancy, trash, compare, browse, watchlist, admin tools tab, category combobox, theme toggle, tool preview card, tool-new/edit) to t() calls - locales: 453 keys each, parity verified - docs: bilingual handbook + release notes via *.en.md variants, language-aware docs.tsx (markdown paths, nav titles, search index), LanguageSwitcher in docs header - generate-docs: emit per-locale handbook/release/search output, localized index.json fields (fileEn/titleEn), en-aware snapshots
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "wouter";
|
||||
import {
|
||||
useListTools,
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
getListAllTagsQueryKey,
|
||||
getGetTopToolsQueryKey,
|
||||
getGetAnalyticsSummaryQueryKey,
|
||||
type ToolWithStats,
|
||||
} from "@workspace/api-client-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -27,6 +27,7 @@ import { Search, Trash2, ExternalLink, Pencil, Star } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export function AdminToolsTab() {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin, isLoading: authLoading } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -82,20 +83,20 @@ export function AdminToolsTab() {
|
||||
{ data: { ids } },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
toast({ title: "Tools moved to trash", description: `${res.trashed ?? ids.length} tool(s) moved to trash.` });
|
||||
toast({ title: t("adminTools.toastMoved"), description: t("adminTools.toastMovedSub", { count: res.trashed ?? ids.length }) });
|
||||
setSelected(new Set());
|
||||
setConfirmTrash(false);
|
||||
invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({ title: "Failed to move to trash", description: err.data?.error ?? err.message, variant: "destructive" });
|
||||
toast({ title: t("adminTools.toastMoveFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!authLoading && !isAdmin) {
|
||||
return <p className="text-sm text-muted-foreground py-8 text-center">Admin access required.</p>;
|
||||
return <p className="text-sm text-muted-foreground py-8 text-center">{t("adminTools.accessRequired")}</p>;
|
||||
}
|
||||
|
||||
const selectedIds = [...selected];
|
||||
@@ -104,9 +105,9 @@ export function AdminToolsTab() {
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle>All Tools</CardTitle>
|
||||
<CardTitle>{t("adminTools.allTools")}</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedIds.length > 0 ? `${selectedIds.length} selected` : `${allTools.length} tool(s)`}
|
||||
{selectedIds.length > 0 ? t("adminTools.selected", { count: selectedIds.length }) : t("adminTools.toolCount", { count: allTools.length })}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -114,7 +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" />
|
||||
<Input
|
||||
className="pl-8 w-56"
|
||||
placeholder="Search tools…"
|
||||
placeholder={t("adminTools.searchPlaceholder")}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
/>
|
||||
@@ -125,7 +126,7 @@ export function AdminToolsTab() {
|
||||
disabled={selectedIds.length === 0 || trash.isPending}
|
||||
onClick={() => setConfirmTrash(true)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" /> Move to trash ({selectedIds.length})
|
||||
<Trash2 className="w-4 h-4 mr-2" /> {t("adminTools.moveToTrash", { count: selectedIds.length })}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -135,7 +136,7 @@ export function AdminToolsTab() {
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
|
||||
</div>
|
||||
) : allTools.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">{search ? "No tools match your search." : "No tools yet."}</p>
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">{search ? t("adminTools.noMatch") : t("adminTools.noToolsYet")}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -144,58 +145,58 @@ export function AdminToolsTab() {
|
||||
<Checkbox
|
||||
checked={selected.size === allTools.length && allTools.length > 0}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label="Select all"
|
||||
aria-label={t("adminTools.selectAll")}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Rating</TableHead>
|
||||
<TableHead>Created by</TableHead>
|
||||
<TableHead>Created at</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
<TableHead>{t("adminTools.name")}</TableHead>
|
||||
<TableHead>{t("adminTools.category")}</TableHead>
|
||||
<TableHead>{t("adminTools.rating")}</TableHead>
|
||||
<TableHead>{t("adminTools.createdBy")}</TableHead>
|
||||
<TableHead>{t("adminTools.createdAt")}</TableHead>
|
||||
<TableHead className="text-right">{t("adminTools.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{allTools.map((t: ToolWithStats) => (
|
||||
<TableRow key={t.id} className={selected.has(t.id) ? "bg-muted/40" : undefined}>
|
||||
{allTools.map((tool) => (
|
||||
<TableRow key={tool.id} className={selected.has(tool.id) ? "bg-muted/40" : undefined}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selected.has(t.id)}
|
||||
onCheckedChange={() => toggle(t.id)}
|
||||
aria-label={`Select ${t.name}`}
|
||||
checked={selected.has(tool.id)}
|
||||
onCheckedChange={() => toggle(tool.id)}
|
||||
aria-label={t("adminTools.selectName", { name: tool.name })}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{t.name}</TableCell>
|
||||
<TableCell className="font-medium">{tool.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{t.category}</Badge>
|
||||
<Badge variant="outline">{tool.category}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{t.ratingCount > 0 ? (
|
||||
{tool.ratingCount > 0 ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Star className="w-3 h-3 fill-amber-500 text-amber-500" />
|
||||
{t.avgCombined != null ? t.avgCombined.toFixed(1) : "—"} ({t.ratingCount})
|
||||
{tool.avgCombined != null ? tool.avgCombined.toFixed(1) : "—"} ({tool.ratingCount})
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{t.createdBy ?? "—"}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{tool.createdBy ?? "—"}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{format(new Date(t.createdAt), "dd.MM.yyyy")}
|
||||
{format(new Date(tool.createdAt), "dd.MM.yyyy")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
|
||||
<Link href={`/tools/${t.id}`}><ExternalLink className="w-3.5 h-3.5" /></Link>
|
||||
<Link href={`/tools/${tool.id}`}><ExternalLink className="w-3.5 h-3.5" /></Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
|
||||
<Link href={`/tools/${t.id}/edit`}><Pencil className="w-3.5 h-3.5" /></Link>
|
||||
<Link href={`/tools/${tool.id}/edit`}><Pencil className="w-3.5 h-3.5" /></Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleTrash([t.id])}
|
||||
onClick={() => handleTrash([tool.id])}
|
||||
disabled={trash.isPending}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
@@ -212,18 +213,18 @@ export function AdminToolsTab() {
|
||||
<AlertDialog open={confirmTrash} onOpenChange={setConfirmTrash}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Move {selectedIds.length} tool(s) to trash?</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("adminTools.confirmTitle", { count: selectedIds.length })}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The selected tools will be hidden from all public views and moved to the trash, where they can be restored or permanently deleted.
|
||||
{t("adminTools.confirmSub")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => handleTrash(selectedIds)}
|
||||
>
|
||||
Move to trash
|
||||
{t("adminTools.moveToTrashAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -23,7 +24,8 @@ interface CategoryComboboxProps {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function CategoryCombobox({ value, onChange, placeholder = "Select or type a category..." }: CategoryComboboxProps) {
|
||||
export function CategoryCombobox({ value, onChange, placeholder }: CategoryComboboxProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState(value);
|
||||
|
||||
@@ -49,6 +51,8 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
|
||||
(c) => c.toLowerCase() === inputValue.toLowerCase()
|
||||
);
|
||||
|
||||
const resolvedPlaceholder = placeholder ?? t("category.placeholder");
|
||||
|
||||
function select(val: string) {
|
||||
onChange(val);
|
||||
setInputValue(val);
|
||||
@@ -66,7 +70,7 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
|
||||
data-testid="button-category-combobox"
|
||||
>
|
||||
<span className={cn(!value && "text-muted-foreground")}>
|
||||
{value || placeholder}
|
||||
{value || resolvedPlaceholder}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -74,7 +78,7 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="Search or enter new category..."
|
||||
placeholder={t("category.searchPlaceholder")}
|
||||
value={inputValue}
|
||||
onValueChange={(v) => {
|
||||
setInputValue(v);
|
||||
@@ -84,10 +88,10 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
|
||||
/>
|
||||
<CommandList>
|
||||
{filtered.length === 0 && !showCreateOption && (
|
||||
<CommandEmpty>No categories found.</CommandEmpty>
|
||||
<CommandEmpty>{t("category.noCategories")}</CommandEmpty>
|
||||
)}
|
||||
{filtered.length > 0 && (
|
||||
<CommandGroup heading="Known categories">
|
||||
<CommandGroup heading={t("category.knownCategories")}>
|
||||
{filtered.map((cat) => (
|
||||
<CommandItem
|
||||
key={cat}
|
||||
@@ -104,13 +108,13 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ
|
||||
</CommandGroup>
|
||||
)}
|
||||
{showCreateOption && (
|
||||
<CommandGroup heading="Create new">
|
||||
<CommandGroup heading={t("category.createNew")}>
|
||||
<CommandItem
|
||||
value={inputValue}
|
||||
onSelect={() => select(inputValue.trim())}
|
||||
data-testid="item-category-create-new"
|
||||
>
|
||||
<span className="text-primary font-medium">+ Create</span>
|
||||
<span className="text-primary font-medium">{t("category.create")}</span>
|
||||
<span className="ml-2 text-muted-foreground">“{inputValue.trim()}”</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChevronDown, Lightbulb, LightbulbOff, Monitor, Moon, Sun } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import { useTheme, type Theme } from "@/hooks/use-theme";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { t } = useTranslation();
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const isDark = resolvedTheme === "dark";
|
||||
|
||||
@@ -25,8 +27,8 @@ export function ThemeToggle() {
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={quickToggle}
|
||||
title={isDark ? "Switch to light theme" : "Switch to dark theme"}
|
||||
aria-label={isDark ? "Switch to light theme" : "Switch to dark theme"}
|
||||
title={isDark ? t("theme.switchLight") : t("theme.switchDark")}
|
||||
aria-label={isDark ? t("theme.switchLight") : t("theme.switchDark")}
|
||||
data-testid="button-theme-quick-toggle"
|
||||
>
|
||||
{isDark ? <Lightbulb className="w-4 h-4" /> : <LightbulbOff className="w-4 h-4" />}
|
||||
@@ -37,27 +39,27 @@ export function ThemeToggle() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
aria-label="Choose theme"
|
||||
title="Choose theme"
|
||||
aria-label={t("theme.chooseTitle")}
|
||||
title={t("theme.chooseTitle")}
|
||||
data-testid="button-theme-menu"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={6}>
|
||||
<DropdownMenuLabel>Theme</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>{t("theme.title")}</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup value={theme} onValueChange={(v) => setTheme(v as Theme)}>
|
||||
<DropdownMenuRadioItem value="light">
|
||||
<Sun className="w-4 h-4 mr-2" />
|
||||
Light
|
||||
{t("theme.light")}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="dark">
|
||||
<Moon className="w-4 h-4 mr-2" />
|
||||
Dark
|
||||
{t("theme.dark")}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="system">
|
||||
<Monitor className="w-4 h-4 mr-2" />
|
||||
System
|
||||
{t("theme.system")}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link } from "wouter";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ToolWithStats } from "@workspace/api-client-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -6,6 +7,7 @@ import { RatingStars } from "@/components/rating-stars";
|
||||
import { MiniBars } from "@/components/mini-bars";
|
||||
|
||||
export function ToolPreviewCard({ tool }: { tool: ToolWithStats }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
@@ -22,7 +24,7 @@ export function ToolPreviewCard({ tool }: { tool: ToolWithStats }) {
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">{tool.ratingCount} review{tool.ratingCount === 1 ? "" : "s"}</span>
|
||||
<span className="text-muted-foreground">{t("browse.reviewCount", { count: tool.ratingCount })}</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{tool.avgCombined != null ? tool.avgCombined.toFixed(1) : "–"}/5
|
||||
</span>
|
||||
@@ -47,8 +49,7 @@ export function ToolPreviewCard({ tool }: { tool: ToolWithStats }) {
|
||||
href={`/tools/${tool.id}`}
|
||||
className="block w-full rounded-md border border-primary/40 bg-primary/5 px-3 py-1.5 text-center text-xs font-medium text-primary hover:bg-primary/10"
|
||||
>
|
||||
View details
|
||||
</Link>
|
||||
</div>
|
||||
{t("common.viewDetails")}
|
||||
</Link> </div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,9 @@
|
||||
"loading": "Wird geladen…",
|
||||
"all": "Alle",
|
||||
"none": "Keine",
|
||||
"language": "Sprache"
|
||||
"language": "Sprache",
|
||||
"viewDetails": "Details ansehen",
|
||||
"guideTooltip": "Anleitung in der Dokumentation"
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Willkommen bei toolr",
|
||||
@@ -80,6 +82,8 @@
|
||||
"category": "Kategorie",
|
||||
"rating": "Bewertung",
|
||||
"reviews": "Bewertungen",
|
||||
"reviewCount_one": "{{count}} Bewertung",
|
||||
"reviewCount_other": "{{count}} Bewertungen",
|
||||
"noToolsFound": "Keine Tools gefunden",
|
||||
"noToolsMatch": "Wir konnten keine Tools finden, die zu deinen Filtern passen. Versuche, die Suchkriterien anzupassen, oder füge ein neues Tool hinzu.",
|
||||
"addTool": "Tool hinzufügen",
|
||||
@@ -103,7 +107,9 @@
|
||||
"features": "Funktionen",
|
||||
"minRating": "Mindestbewertung",
|
||||
"any": "Beliebig",
|
||||
"clearFilters": "Filter zurücksetzen"
|
||||
"clearFilters": "Filter zurücksetzen",
|
||||
"removeFeature": "Funktion entfernen: {{feature}}",
|
||||
"removeMinRating": "Mindestbewertung entfernen"
|
||||
},
|
||||
"detail": {
|
||||
"ratingBreakdown": "Bewertungsaufschlüsselung",
|
||||
@@ -133,7 +139,65 @@
|
||||
"deleteToTrash": "Dies verschiebt {{name}} in den Papierkorb. Es kann später wiederhergestellt werden.",
|
||||
"deletePermanent": "Dies entfernt {{name}} dauerhaft inklusive aller Bewertungen. Das kann nicht rückgängig gemacht werden.",
|
||||
"deleting": "Löschen…",
|
||||
"deleteAction": "Löschen"
|
||||
"deleteAction": "Löschen",
|
||||
"invalidToolId": "Ungültige Tool-ID",
|
||||
"backToBrowse": "Zurück zur Übersicht",
|
||||
"keyFeatures": "Wichtige Funktionen",
|
||||
"toolNotFound": "Tool nicht gefunden.",
|
||||
"similarTools": "Ähnliche Tools",
|
||||
"noSimilarTools": "Keine ähnlichen Tools gefunden.",
|
||||
"linkTool": "Tool verknüpfen",
|
||||
"linkSimilarTool": "Ähnliches Tool verknüpfen",
|
||||
"linkSimilarToolSub": "Verknüpfe dieses Tool manuell mit einem anderen Tool.",
|
||||
"toolId": "Tool-ID",
|
||||
"toolIdPlaceholder": "Ziel-Tool-ID eingeben",
|
||||
"relationType": "Beziehungstyp",
|
||||
"relationSimilar": "Ähnlich",
|
||||
"relationReplaces": "Ersetzt",
|
||||
"relationSupersededBy": "Ersetzt durch",
|
||||
"notesOptional": "Notizen (optional)",
|
||||
"notesPlaceholder": "Warum sind diese Tools verwandt?",
|
||||
"createLink": "Verknüpfung erstellen",
|
||||
"noCostInfo": "Noch keine Kosteninformationen hinzugefügt.",
|
||||
"costDialogSub": "Verwalte die Lizenzkosteninformationen für dieses Tool.",
|
||||
"licenseType": "Lizenztyp",
|
||||
"licenseFree": "Kostenlos",
|
||||
"licenseSubscription": "Abonnement",
|
||||
"licenseOneTime": "Einmalig",
|
||||
"licenseUsageBased": "Nutzungsbasiert",
|
||||
"billingPeriod": "Abrechnungszeitraum",
|
||||
"billingMonthly": "Monatlich",
|
||||
"billingQuarterly": "Quartalsweise",
|
||||
"billingYearly": "Jährlich",
|
||||
"costLabel": "Kosten",
|
||||
"currency": "Währung",
|
||||
"costNotes": "Notizen",
|
||||
"costNotesPlaceholder": "Abrechnungsdetails, Vertragsinformationen…",
|
||||
"commentOptional": "Kommentar (optional)",
|
||||
"commentLabel": "Kommentar",
|
||||
"commentPlaceholder": "Was denkst du über dieses Tool?",
|
||||
"nameOptional": "Name (optional)",
|
||||
"nameLabel": "Name",
|
||||
"anonymousPlaceholder": "Anonym",
|
||||
"anonymousEngineer": "Anonymer Ingenieur",
|
||||
"beFirstToReview": "Sei der Erste, der seine Gedanken zu diesem Tool teilt.",
|
||||
"shareExperience": "Teile deine Erfahrung mit {{name}}",
|
||||
"score": "Punktestand",
|
||||
"toastRelationCreated": "Beziehung erstellt",
|
||||
"toastRelationFailed": "Beziehung konnte nicht erstellt werden",
|
||||
"toastRelationDeleted": "Beziehung gelöscht",
|
||||
"toastRelationDeleteFailed": "Beziehung konnte nicht gelöscht werden",
|
||||
"toastCostUpdated": "Kosten aktualisiert",
|
||||
"toastCostAdded": "Kosten hinzugefügt",
|
||||
"toastCostFailed": "Kosten konnten nicht gespeichert werden",
|
||||
"toastCostDeleted": "Kosten gelöscht",
|
||||
"toastCostDeleteFailed": "Kosten konnten nicht gelöscht werden",
|
||||
"toastRatingSubmitted": "Bewertung abgesendet",
|
||||
"toastRatingThanks": "Danke für dein Feedback!",
|
||||
"toastRatingFailed": "Bewertung konnte nicht abgesendet werden",
|
||||
"toastToolDeleted": "Tool gelöscht",
|
||||
"toastDeleteFailed": "Löschen fehlgeschlagen",
|
||||
"unexpectedError": "Ein unerwarteter Fehler ist aufgetreten."
|
||||
},
|
||||
"compare": {
|
||||
"title": "Tools vergleichen",
|
||||
@@ -176,7 +240,29 @@
|
||||
"deletePermanently": "Endgültig löschen",
|
||||
"emptyTrash": "Papierkorb leeren",
|
||||
"deletedAt": "Gelöscht am",
|
||||
"deletedBy": "Gelöscht von"
|
||||
"deletedBy": "Gelöscht von",
|
||||
"name": "Name",
|
||||
"category": "Kategorie",
|
||||
"actions": "Aktionen",
|
||||
"selectAll": "Alle auswählen",
|
||||
"selectName": "{{name}} auswählen",
|
||||
"restoreAction": "Wiederherstellen",
|
||||
"deleteAction": "Löschen",
|
||||
"deleteConfirmTitle": "{{count}} Tool(s) endgültig löschen?",
|
||||
"deleteConfirmSub": "Dies entfernt die ausgewählten Tools dauerhaft inklusive aller Bewertungen, Kosten und Beziehungen. Das kann nicht rückgängig gemacht werden.",
|
||||
"emptyConfirmTitle": "Papierkorb leeren?",
|
||||
"emptyConfirmSub": "Dies entfernt alle {{count}} Tool(s) im Papierkorb dauerhaft inklusive ihrer Bewertungen, Kosten und Beziehungen. Das kann nicht rückgängig gemacht werden.",
|
||||
"toastRestored": "Tools wiederhergestellt",
|
||||
"toastRestoredSub": "{{count}} Tool(s) wiederhergestellt.",
|
||||
"toastRestoreFailed": "Wiederherstellen fehlgeschlagen",
|
||||
"toastDeleted": "Tools gelöscht",
|
||||
"toastDeletedSub": "{{count}} Tool(s) endgültig entfernt.",
|
||||
"toastDeleteFailed": "Löschen fehlgeschlagen",
|
||||
"toastEmptied": "Papierkorb geleert",
|
||||
"toastEmptiedSub": "{{count}} Tool(s) endgültig entfernt.",
|
||||
"toastEmptyFailed": "Papierkorb konnte nicht geleert werden",
|
||||
"searchPlaceholder": "Tools suchen…",
|
||||
"toolCount": "{{count}} Tool(s)"
|
||||
},
|
||||
"notFound": {
|
||||
"text": "Diese Seite existiert nicht.",
|
||||
@@ -209,7 +295,10 @@
|
||||
"parameter": "Parameter",
|
||||
"noResults": "Keine Treffer",
|
||||
"fields": "Felder",
|
||||
"fieldHelpHint": "Hinweis: Formular-Felder verlinken per ?-Icon direkt zu den jeweiligen Zeilen dieser Tabelle."
|
||||
"fieldHelpHint": "Hinweis: Formular-Felder verlinken per ?-Icon direkt zu den jeweiligen Zeilen dieser Tabelle.",
|
||||
"name": "Name",
|
||||
"in": "In",
|
||||
"requestBody": "Request-Body"
|
||||
},
|
||||
"command": {
|
||||
"navigate": "Navigation",
|
||||
@@ -217,5 +306,192 @@
|
||||
"tools": "Tools",
|
||||
"noResults": "Keine Tools für „{{query}}“ gefunden.",
|
||||
"startTyping": "Beginne zu tippen, um Tools zu suchen."
|
||||
},
|
||||
"admin": {
|
||||
"accessRequired": "Admin-Zugriff erforderlich",
|
||||
"accessRequiredSub": "Du benötigst Admin-Rechte, um diese Seite anzusehen.",
|
||||
"goHome": "Zur Startseite",
|
||||
"panel": "Admin-Bereich",
|
||||
"panelSub": "Benutzer verwalten und Systemänderungen einsehen.",
|
||||
"redundancyDashboard": "Redundanz-Dashboard",
|
||||
"tabUsers": "Benutzer",
|
||||
"tabTools": "Tools",
|
||||
"tabAudit": "Audit-Log",
|
||||
"tabSystem": "System",
|
||||
"localUsers": "Lokale Benutzer",
|
||||
"localUsersSub": "Konten für die lokale Authentifizierung verwalten.",
|
||||
"addUser": "Benutzer hinzufügen",
|
||||
"noUsersYet": "Noch keine Benutzer.",
|
||||
"auditLog": "Audit-Log",
|
||||
"auditLogSub": "Alle vom System protokollierten Erstellungs-, Aktualisierungs- und Löschvorgänge.",
|
||||
"by": "von",
|
||||
"noAuditEntries": "Noch keine Audit-Einträge.",
|
||||
"system": "System",
|
||||
"systemSub": "Build-Informationen der aktuell laufenden Bereitstellung.",
|
||||
"version": "Version",
|
||||
"commit": "Commit",
|
||||
"buildDate": "Build-Datum",
|
||||
"trashRetention": "Papierkorb-Aufbewahrung",
|
||||
"days": "Tage",
|
||||
"keepForever": "Für immer behalten",
|
||||
"createNewUser": "Neuen Benutzer erstellen",
|
||||
"username": "Benutzername",
|
||||
"password": "Passwort",
|
||||
"emailOptional": "E-Mail (optional)",
|
||||
"role": "Rolle",
|
||||
"roleUser": "Benutzer",
|
||||
"roleAdmin": "Admin",
|
||||
"plan": "Plan",
|
||||
"planFree": "Kostenlos",
|
||||
"planPremium": "Premium",
|
||||
"planEnterprise": "Enterprise",
|
||||
"creating": "Erstelle…",
|
||||
"createUser": "Benutzer erstellen",
|
||||
"editUser": "Benutzer bearbeiten — {{username}}",
|
||||
"setPassword": "Passwort festlegen",
|
||||
"resetsPassword": "Setzt das Passwort des Benutzers sofort zurück.",
|
||||
"idpManaged": "Das Passwort wird vom Identity-Provider (Keycloak) verwaltet. Setze es dort zurück.",
|
||||
"setting": "Setze…",
|
||||
"deleteUser": "Benutzer löschen",
|
||||
"deleteUserConfirm": "Bist du sicher, dass du {{username}} löschen möchtest? Das kann nicht rückgängig gemacht werden.",
|
||||
"minPasswordPlaceholder": "min. 6 Zeichen",
|
||||
"usernamePlaceholder": "benutzername",
|
||||
"emailPlaceholder": "user@example.com",
|
||||
"toastUserCreated": "Benutzer erstellt",
|
||||
"toastUserCreatedSub": "{{username}} wurde erstellt.",
|
||||
"toastUserCreateFailed": "Benutzer konnte nicht erstellt werden",
|
||||
"toastUserUpdated": "Benutzer aktualisiert",
|
||||
"toastUserUpdateFailed": "Benutzer konnte nicht aktualisiert werden",
|
||||
"toastPwTooShort": "Passwort zu kurz",
|
||||
"toastPwTooShortSub": "Mindestens 6 Zeichen.",
|
||||
"toastPwUpdated": "Passwort aktualisiert",
|
||||
"toastPwUpdatedSub": "Passwort für {{username}} wurde festgelegt.",
|
||||
"toastPwSetFailed": "Passwort konnte nicht festgelegt werden",
|
||||
"toastUserDeleted": "Benutzer gelöscht",
|
||||
"toastUserDeletedSub": "{{username}} wurde entfernt.",
|
||||
"toastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden"
|
||||
},
|
||||
"adminTools": {
|
||||
"accessRequired": "Admin-Zugriff erforderlich.",
|
||||
"allTools": "Alle Tools",
|
||||
"toolCount": "{{count}} Tool(s)",
|
||||
"selected": "{{count}} ausgewählt",
|
||||
"searchPlaceholder": "Tools suchen…",
|
||||
"moveToTrash": "In den Papierkorb verschieben ({{count}})",
|
||||
"noMatch": "Keine Tools entsprechen deiner Suche.",
|
||||
"noToolsYet": "Noch keine Tools.",
|
||||
"selectAll": "Alle auswählen",
|
||||
"selectName": "{{name}} auswählen",
|
||||
"name": "Name",
|
||||
"category": "Kategorie",
|
||||
"rating": "Bewertung",
|
||||
"createdBy": "Erstellt von",
|
||||
"createdAt": "Erstellt am",
|
||||
"actions": "Aktionen",
|
||||
"confirmTitle": "{{count}} Tool(s) in den Papierkorb verschieben?",
|
||||
"confirmSub": "Die ausgewählten Tools werden aus allen öffentlichen Ansichten entfernt und in den Papierkorb verschoben, wo sie wiederhergestellt oder endgültig gelöscht werden können.",
|
||||
"moveToTrashAction": "In den Papierkorb verschieben",
|
||||
"toastMoved": "Tools in den Papierkorb verschoben",
|
||||
"toastMovedSub": "{{count}} Tool(s) in den Papierkorb verschoben.",
|
||||
"toastMoveFailed": "Verschieben in den Papierkorb fehlgeschlagen"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Plattform-Analysen",
|
||||
"subtitle": "Makro-Einblicke in die Leistung der Tools und das Engagement der Community.",
|
||||
"totalToolsIndexed": "Indexierte Tools gesamt",
|
||||
"totalRatingsCast": "Abgegebene Bewertungen gesamt",
|
||||
"activeCategories": "Aktive Kategorien",
|
||||
"avgGlobalScore": "Ø Globaler Punktestand",
|
||||
"top8Tools": "Top 8 Tools nach kombiniertem Punktestand",
|
||||
"top8ToolsSub": "Die bestbewerteten Tools der Plattform",
|
||||
"toolsByCategory": "Tools nach Kategorie",
|
||||
"toolsByCategorySub": "Verteilung der Tools auf Kategorien",
|
||||
"globalRatingDistribution": "Globale Bewertungsverteilung",
|
||||
"globalRatingDistributionSub": "Wie Benutzer über alle Tools abstimmen",
|
||||
"radarTools": "Tools"
|
||||
},
|
||||
"redundancy": {
|
||||
"title": "Tool-Analyse & Empfehlungen",
|
||||
"subtitle": "Automatische Redundanz-Erkennung mit Kosten- und Bewertungsvergleich. Admins können manuell bestätigen, welches Tool die bessere Wahl ist.",
|
||||
"toolsComparisons": "{{tools}} Tools, {{pairs}} Vergleiche",
|
||||
"totalMonthly": "/Monat gesamt",
|
||||
"reviews": "Bewertungen",
|
||||
"features": "Features",
|
||||
"comparisonsTitle": "Vergleiche & Empfehlungen",
|
||||
"vs": "gegen",
|
||||
"noTools": "Keine Tools gefunden.",
|
||||
"perMonth": "/Monat",
|
||||
"toastEvalSaved": "Auswertung gespeichert",
|
||||
"toastEvalFailed": "Auswertung konnte nicht gespeichert werden"
|
||||
},
|
||||
"toolForm": {
|
||||
"backToBrowse": "Zurück zur Übersicht",
|
||||
"backToTool": "Zurück zum Tool",
|
||||
"addTitle": "Neues Tool hinzufügen",
|
||||
"addSubtitle": "Reiche ein Tool ein, das du nutzt, damit die Community es bewerten kann.",
|
||||
"editTitle": "Tool bearbeiten",
|
||||
"editSubtitle": "Aktualisiere Tool-Details und Metadaten.",
|
||||
"signInRequired": "Anmeldung erforderlich",
|
||||
"signInRequiredSub": "Du musst angemeldet sein, um ein Tool einzureichen.",
|
||||
"toolDetails": "Tool-Details",
|
||||
"toolDetailsNewSub": "Gib die grundlegenden Informationen zum Tool an.",
|
||||
"toolDetailsEditSub": "Ändere die Tool-Informationen unten.",
|
||||
"name": "Name",
|
||||
"nameMin": "Name muss mindestens 2 Zeichen haben",
|
||||
"category": "Kategorie",
|
||||
"categoryRequired": "Kategorie ist erforderlich",
|
||||
"websiteUrl": "Website-URL",
|
||||
"websiteUrlOptional": "Website-URL (optional)",
|
||||
"iconUrl": "Icon-/Logo-URL",
|
||||
"iconUrlOptional": "Icon-/Logo-URL (optional)",
|
||||
"iconPreview": "Icon-Vorschau",
|
||||
"description": "Beschreibung",
|
||||
"descriptionMin": "Beschreibung muss mindestens 10 Zeichen haben",
|
||||
"invalidUrl": "Muss eine gültige URL sein",
|
||||
"features": "Features",
|
||||
"featuresNewSub": "Liste die wichtigsten Funktionen auf. Vorhandene Features anderer Tools sind auswählbar.",
|
||||
"featuresEditSub": "Wichtige Funktionen dieses Tools. Vorhandene Features anderer Tools sind auswählbar.",
|
||||
"addFeature": "Feature hinzufügen",
|
||||
"noFeatures": "Keine Features hinzugefügt.",
|
||||
"featurePlaceholder": "z. B. Echtzeit-Zusammenarbeit",
|
||||
"tags": "Tags",
|
||||
"tagsNewSub": "Schlüsselwörter, um dieses Tool zu finden. Vorhandene Tags anderer Tools sind auswählbar.",
|
||||
"tagsEditSub": "Schlüsselwörter für dieses Tool. Vorhandene Tags anderer Tools sind auswählbar.",
|
||||
"addTag": "Tag hinzufügen",
|
||||
"tag": "Tag",
|
||||
"namePlaceholder": "z. B. React, Next.js, Postgres",
|
||||
"urlPlaceholder": "https://…",
|
||||
"logoPlaceholder": "https://example.com/logo.png",
|
||||
"descriptionPlaceholderNew": "Was macht dieses Tool? Warum nutzen es Menschen?",
|
||||
"descriptionPlaceholderEdit": "Was macht dieses Tool?",
|
||||
"addingTool": "Füge Tool hinzu…",
|
||||
"submitTool": "Tool einreichen",
|
||||
"saving": "Speichere…",
|
||||
"saveChanges": "Änderungen speichern",
|
||||
"toastAdded": "Tool erfolgreich hinzugefügt",
|
||||
"toastAddedSub": "Dein Tool ist jetzt zur Bewertung verfügbar.",
|
||||
"toastAddFailed": "Tool konnte nicht hinzugefügt werden",
|
||||
"toastUpdated": "Tool aktualisiert",
|
||||
"toastUpdatedSub": "Änderungen erfolgreich gespeichert.",
|
||||
"toastUpdateFailed": "Tool konnte nicht aktualisiert werden",
|
||||
"tagsHelp": "Schlüsselwörter, die helfen, dieses Tool zu finden. Vorhandene Tags anderer Tools sind auswählbar.",
|
||||
"tagPlaceholder": "Tag"
|
||||
},
|
||||
"category": {
|
||||
"placeholder": "Kategorie auswählen oder eingeben…",
|
||||
"searchPlaceholder": "Kategorie suchen oder neue eingeben…",
|
||||
"noCategories": "Keine Kategorien gefunden.",
|
||||
"knownCategories": "Bekannte Kategorien",
|
||||
"createNew": "Neu erstellen",
|
||||
"create": "+ Erstellen"
|
||||
},
|
||||
"theme": {
|
||||
"title": "Design",
|
||||
"light": "Hell",
|
||||
"dark": "Dunkel",
|
||||
"system": "System",
|
||||
"chooseTitle": "Design auswählen",
|
||||
"switchLight": "Zum hellen Design wechseln",
|
||||
"switchDark": "Zum dunklen Design wechseln"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,9 @@
|
||||
"loading": "Loading…",
|
||||
"all": "All",
|
||||
"none": "None",
|
||||
"language": "Language"
|
||||
"language": "Language",
|
||||
"viewDetails": "View details",
|
||||
"guideTooltip": "Guide in the documentation"
|
||||
},
|
||||
"home": {
|
||||
"welcome": "Welcome to toolr",
|
||||
@@ -80,6 +82,8 @@
|
||||
"category": "Category",
|
||||
"rating": "Rating",
|
||||
"reviews": "Reviews",
|
||||
"reviewCount_one": "{{count}} review",
|
||||
"reviewCount_other": "{{count}} reviews",
|
||||
"noToolsFound": "No tools found",
|
||||
"noToolsMatch": "We couldn't find any tools matching your current filters. Try adjusting your search criteria or add a new tool.",
|
||||
"addTool": "Add Tool",
|
||||
@@ -103,7 +107,9 @@
|
||||
"features": "Features",
|
||||
"minRating": "Min. rating",
|
||||
"any": "Any",
|
||||
"clearFilters": "Clear filters"
|
||||
"clearFilters": "Clear filters",
|
||||
"removeFeature": "Remove feature: {{feature}}",
|
||||
"removeMinRating": "Remove min rating"
|
||||
},
|
||||
"detail": {
|
||||
"ratingBreakdown": "Rating Breakdown",
|
||||
@@ -133,7 +139,65 @@
|
||||
"deleteToTrash": "This will move {{name}} to the trash. It can be restored later.",
|
||||
"deletePermanent": "This will permanently remove {{name}} and all its ratings. This cannot be undone.",
|
||||
"deleting": "Deleting…",
|
||||
"deleteAction": "Delete"
|
||||
"deleteAction": "Delete",
|
||||
"invalidToolId": "Invalid Tool ID",
|
||||
"backToBrowse": "Back to browse",
|
||||
"keyFeatures": "Key Features",
|
||||
"toolNotFound": "Tool not found.",
|
||||
"similarTools": "Similar Tools",
|
||||
"noSimilarTools": "No similar tools found.",
|
||||
"linkTool": "Link Tool",
|
||||
"linkSimilarTool": "Link Similar Tool",
|
||||
"linkSimilarToolSub": "Manually link this tool to another tool.",
|
||||
"toolId": "Tool ID",
|
||||
"toolIdPlaceholder": "Enter target tool ID",
|
||||
"relationType": "Relation Type",
|
||||
"relationSimilar": "Similar",
|
||||
"relationReplaces": "Replaces",
|
||||
"relationSupersededBy": "Superseded By",
|
||||
"notesOptional": "Notes (Optional)",
|
||||
"notesPlaceholder": "Why are these tools related?",
|
||||
"createLink": "Create Link",
|
||||
"noCostInfo": "No cost information added yet.",
|
||||
"costDialogSub": "Manage license cost information for this tool.",
|
||||
"licenseType": "License Type",
|
||||
"licenseFree": "Free",
|
||||
"licenseSubscription": "Subscription",
|
||||
"licenseOneTime": "One-Time",
|
||||
"licenseUsageBased": "Usage-Based",
|
||||
"billingPeriod": "Billing Period",
|
||||
"billingMonthly": "Monthly",
|
||||
"billingQuarterly": "Quarterly",
|
||||
"billingYearly": "Yearly",
|
||||
"costLabel": "Cost",
|
||||
"currency": "Currency",
|
||||
"costNotes": "Notes",
|
||||
"costNotesPlaceholder": "Billing details, contract info...",
|
||||
"commentOptional": "Comment (Optional)",
|
||||
"commentLabel": "Comment",
|
||||
"commentPlaceholder": "What do you think about this tool?",
|
||||
"nameOptional": "Name (Optional)",
|
||||
"nameLabel": "Name",
|
||||
"anonymousPlaceholder": "Anonymous",
|
||||
"anonymousEngineer": "Anonymous Engineer",
|
||||
"beFirstToReview": "Be the first to share your thoughts on this tool.",
|
||||
"shareExperience": "Share your experience with {{name}}",
|
||||
"score": "Score",
|
||||
"toastRelationCreated": "Relation created",
|
||||
"toastRelationFailed": "Failed to create relation",
|
||||
"toastRelationDeleted": "Relation deleted",
|
||||
"toastRelationDeleteFailed": "Failed to delete relation",
|
||||
"toastCostUpdated": "Cost updated",
|
||||
"toastCostAdded": "Cost added",
|
||||
"toastCostFailed": "Failed to save cost",
|
||||
"toastCostDeleted": "Cost deleted",
|
||||
"toastCostDeleteFailed": "Failed to delete cost",
|
||||
"toastRatingSubmitted": "Rating submitted",
|
||||
"toastRatingThanks": "Thank you for your feedback!",
|
||||
"toastRatingFailed": "Failed to submit rating",
|
||||
"toastToolDeleted": "Tool deleted",
|
||||
"toastDeleteFailed": "Failed to delete",
|
||||
"unexpectedError": "An unexpected error occurred."
|
||||
},
|
||||
"compare": {
|
||||
"title": "Compare Tools",
|
||||
@@ -176,7 +240,29 @@
|
||||
"deletePermanently": "Delete permanently",
|
||||
"emptyTrash": "Empty trash",
|
||||
"deletedAt": "Deleted at",
|
||||
"deletedBy": "Deleted by"
|
||||
"deletedBy": "Deleted by",
|
||||
"name": "Name",
|
||||
"category": "Category",
|
||||
"actions": "Actions",
|
||||
"selectAll": "Select all",
|
||||
"selectName": "Select {{name}}",
|
||||
"restoreAction": "Restore",
|
||||
"deleteAction": "Delete",
|
||||
"deleteConfirmTitle": "Delete {{count}} tool(s) permanently?",
|
||||
"deleteConfirmSub": "This permanently removes the selected tools along with all their ratings, costs and relations. This cannot be undone.",
|
||||
"emptyConfirmTitle": "Empty the trash?",
|
||||
"emptyConfirmSub": "This permanently removes all {{count}} tool(s) in the trash along with their ratings, costs and relations. This cannot be undone.",
|
||||
"toastRestored": "Tools restored",
|
||||
"toastRestoredSub": "{{count}} tool(s) restored.",
|
||||
"toastRestoreFailed": "Failed to restore",
|
||||
"toastDeleted": "Tools deleted",
|
||||
"toastDeletedSub": "{{count}} tool(s) permanently removed.",
|
||||
"toastDeleteFailed": "Failed to delete",
|
||||
"toastEmptied": "Trash emptied",
|
||||
"toastEmptiedSub": "{{count}} tool(s) permanently removed.",
|
||||
"toastEmptyFailed": "Failed to empty trash",
|
||||
"searchPlaceholder": "Search tools…",
|
||||
"toolCount": "{{count}} tool(s)"
|
||||
},
|
||||
"notFound": {
|
||||
"text": "This page doesn't exist.",
|
||||
@@ -191,7 +277,7 @@
|
||||
"latest": "Latest",
|
||||
"repo": "Repository",
|
||||
"nav": "Documentation",
|
||||
"guides": "Guide",
|
||||
"guides": "Guides",
|
||||
"endpoints": "Endpoints",
|
||||
"schemas": "Data models",
|
||||
"releases": "Release notes",
|
||||
@@ -209,7 +295,10 @@
|
||||
"parameter": "Parameter",
|
||||
"noResults": "No results",
|
||||
"fields": "Fields",
|
||||
"fieldHelpHint": "Note: form fields link via the ? icon directly to the respective rows of this table."
|
||||
"fieldHelpHint": "Note: form fields link via the ? icon directly to the respective rows of this table.",
|
||||
"name": "Name",
|
||||
"in": "In",
|
||||
"requestBody": "Request Body"
|
||||
},
|
||||
"command": {
|
||||
"navigate": "Navigate",
|
||||
@@ -217,5 +306,192 @@
|
||||
"tools": "Tools",
|
||||
"noResults": "No tools found for \"{{query}}\".",
|
||||
"startTyping": "Start typing to search tools."
|
||||
},
|
||||
"admin": {
|
||||
"accessRequired": "Admin Access Required",
|
||||
"accessRequiredSub": "You need admin rights to view this page.",
|
||||
"goHome": "Go Home",
|
||||
"panel": "Admin Panel",
|
||||
"panelSub": "Manage users and review system changes.",
|
||||
"redundancyDashboard": "Redundancy Dashboard",
|
||||
"tabUsers": "Users",
|
||||
"tabTools": "Tools",
|
||||
"tabAudit": "Audit Log",
|
||||
"tabSystem": "System",
|
||||
"localUsers": "Local Users",
|
||||
"localUsersSub": "Manage accounts for local authentication.",
|
||||
"addUser": "Add User",
|
||||
"noUsersYet": "No users yet.",
|
||||
"auditLog": "Audit Log",
|
||||
"auditLogSub": "All create, update and delete operations tracked by the system.",
|
||||
"by": "by",
|
||||
"noAuditEntries": "No audit entries yet.",
|
||||
"system": "System",
|
||||
"systemSub": "Build information of the currently live deployment.",
|
||||
"version": "Version",
|
||||
"commit": "Commit",
|
||||
"buildDate": "Build date",
|
||||
"trashRetention": "Trash retention",
|
||||
"days": "days",
|
||||
"keepForever": "Keep forever",
|
||||
"createNewUser": "Create New User",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"emailOptional": "Email (Optional)",
|
||||
"role": "Role",
|
||||
"roleUser": "User",
|
||||
"roleAdmin": "Admin",
|
||||
"plan": "Plan",
|
||||
"planFree": "Free",
|
||||
"planPremium": "Premium",
|
||||
"planEnterprise": "Enterprise",
|
||||
"creating": "Creating…",
|
||||
"createUser": "Create User",
|
||||
"editUser": "Edit User — {{username}}",
|
||||
"setPassword": "Set Password",
|
||||
"resetsPassword": "Resets the user's password immediately.",
|
||||
"idpManaged": "Password is managed by the identity provider (Keycloak). Reset it there.",
|
||||
"setting": "Setting…",
|
||||
"deleteUser": "Delete User",
|
||||
"deleteUserConfirm": "Are you sure you want to delete {{username}}? This cannot be undone.",
|
||||
"minPasswordPlaceholder": "min. 6 characters",
|
||||
"usernamePlaceholder": "username",
|
||||
"emailPlaceholder": "user@example.com",
|
||||
"toastUserCreated": "User created",
|
||||
"toastUserCreatedSub": "{{username}} has been created.",
|
||||
"toastUserCreateFailed": "Failed to create user",
|
||||
"toastUserUpdated": "User updated",
|
||||
"toastUserUpdateFailed": "Failed to update user",
|
||||
"toastPwTooShort": "Password too short",
|
||||
"toastPwTooShortSub": "Minimum 6 characters.",
|
||||
"toastPwUpdated": "Password updated",
|
||||
"toastPwUpdatedSub": "Password for {{username}} has been set.",
|
||||
"toastPwSetFailed": "Failed to set password",
|
||||
"toastUserDeleted": "User deleted",
|
||||
"toastUserDeletedSub": "{{username}} has been removed.",
|
||||
"toastUserDeleteFailed": "Failed to delete user"
|
||||
},
|
||||
"adminTools": {
|
||||
"accessRequired": "Admin access required.",
|
||||
"allTools": "All Tools",
|
||||
"toolCount": "{{count}} tool(s)",
|
||||
"selected": "{{count}} selected",
|
||||
"searchPlaceholder": "Search tools…",
|
||||
"moveToTrash": "Move to trash ({{count}})",
|
||||
"noMatch": "No tools match your search.",
|
||||
"noToolsYet": "No tools yet.",
|
||||
"selectAll": "Select all",
|
||||
"selectName": "Select {{name}}",
|
||||
"name": "Name",
|
||||
"category": "Category",
|
||||
"rating": "Rating",
|
||||
"createdBy": "Created by",
|
||||
"createdAt": "Created at",
|
||||
"actions": "Actions",
|
||||
"confirmTitle": "Move {{count}} tool(s) to trash?",
|
||||
"confirmSub": "The selected tools will be hidden from all public views and moved to the trash, where they can be restored or permanently deleted.",
|
||||
"moveToTrashAction": "Move to trash",
|
||||
"toastMoved": "Tools moved to trash",
|
||||
"toastMovedSub": "{{count}} tool(s) moved to trash.",
|
||||
"toastMoveFailed": "Failed to move to trash"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Platform Analytics",
|
||||
"subtitle": "Macro-level insights into tool performance and community engagement.",
|
||||
"totalToolsIndexed": "Total Tools Indexed",
|
||||
"totalRatingsCast": "Total Ratings Cast",
|
||||
"activeCategories": "Active Categories",
|
||||
"avgGlobalScore": "Avg Global Score",
|
||||
"top8Tools": "Top 8 Tools by Combined Score",
|
||||
"top8ToolsSub": "Highest rated tools across the platform",
|
||||
"toolsByCategory": "Tools by Category",
|
||||
"toolsByCategorySub": "Distribution of tools across categories",
|
||||
"globalRatingDistribution": "Global Rating Distribution",
|
||||
"globalRatingDistributionSub": "How users are voting across all tools",
|
||||
"radarTools": "Tools"
|
||||
},
|
||||
"redundancy": {
|
||||
"title": "Tool Analysis & Recommendations",
|
||||
"subtitle": "Automatic redundancy detection with cost and rating comparison. Admin can manually confirm which tool is the better choice.",
|
||||
"toolsComparisons": "{{tools}} tools, {{pairs}} comparisons",
|
||||
"totalMonthly": "/mo total",
|
||||
"reviews": "reviews",
|
||||
"features": "features",
|
||||
"comparisonsTitle": "Comparisons & Recommendations",
|
||||
"vs": "vs",
|
||||
"noTools": "No tools found.",
|
||||
"perMonth": "/mo",
|
||||
"toastEvalSaved": "Evaluation saved",
|
||||
"toastEvalFailed": "Failed to save evaluation"
|
||||
},
|
||||
"toolForm": {
|
||||
"backToBrowse": "Back to browse",
|
||||
"backToTool": "Back to tool",
|
||||
"addTitle": "Add a New Tool",
|
||||
"addSubtitle": "Submit a tool you use to let the community rate and review it.",
|
||||
"editTitle": "Edit Tool",
|
||||
"editSubtitle": "Update tool details and metadata.",
|
||||
"signInRequired": "Sign in required",
|
||||
"signInRequiredSub": "You must be signed in to submit a tool.",
|
||||
"toolDetails": "Tool Details",
|
||||
"toolDetailsNewSub": "Provide the basic information about the tool.",
|
||||
"toolDetailsEditSub": "Modify the tool information below.",
|
||||
"name": "Name",
|
||||
"nameMin": "Name must be at least 2 characters",
|
||||
"category": "Category",
|
||||
"categoryRequired": "Category is required",
|
||||
"websiteUrl": "Website URL",
|
||||
"websiteUrlOptional": "Website URL (Optional)",
|
||||
"iconUrl": "Icon / Logo URL",
|
||||
"iconUrlOptional": "Icon / Logo URL (Optional)",
|
||||
"iconPreview": "icon preview",
|
||||
"description": "Description",
|
||||
"descriptionMin": "Description must be at least 10 characters",
|
||||
"invalidUrl": "Must be a valid URL",
|
||||
"features": "Features",
|
||||
"featuresNewSub": "List key capabilities. Existing features from other tools are selectable.",
|
||||
"featuresEditSub": "Key capabilities of this tool. Existing features from other tools are selectable.",
|
||||
"addFeature": "Add Feature",
|
||||
"noFeatures": "No features added.",
|
||||
"featurePlaceholder": "e.g. Real-time collaboration",
|
||||
"tags": "Tags",
|
||||
"tagsNewSub": "Keywords to help find this tool. Existing tags from other tools are selectable.",
|
||||
"tagsEditSub": "Keywords for this tool. Existing tags from other tools are selectable.",
|
||||
"addTag": "Add Tag",
|
||||
"tag": "Tag",
|
||||
"namePlaceholder": "e.g. React, Next.js, Postgres",
|
||||
"urlPlaceholder": "https://...",
|
||||
"logoPlaceholder": "https://example.com/logo.png",
|
||||
"descriptionPlaceholderNew": "What does this tool do? Why do people use it?",
|
||||
"descriptionPlaceholderEdit": "What does this tool do?",
|
||||
"addingTool": "Adding Tool...",
|
||||
"submitTool": "Submit Tool",
|
||||
"saving": "Saving…",
|
||||
"saveChanges": "Save Changes",
|
||||
"toastAdded": "Tool added successfully",
|
||||
"toastAddedSub": "Your tool is now available for review.",
|
||||
"toastAddFailed": "Failed to add tool",
|
||||
"toastUpdated": "Tool updated",
|
||||
"toastUpdatedSub": "Changes saved successfully.",
|
||||
"toastUpdateFailed": "Failed to update tool",
|
||||
"tagsHelp": "Keywords to help find this tool. Existing tags from other tools are selectable.",
|
||||
"tagPlaceholder": "Tag"
|
||||
},
|
||||
"category": {
|
||||
"placeholder": "Select or type a category...",
|
||||
"searchPlaceholder": "Search or enter new category...",
|
||||
"noCategories": "No categories found.",
|
||||
"knownCategories": "Known categories",
|
||||
"createNew": "Create new",
|
||||
"create": "+ Create"
|
||||
},
|
||||
"theme": {
|
||||
"title": "Theme",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System",
|
||||
"chooseTitle": "Choose theme",
|
||||
"switchLight": "Switch to light theme",
|
||||
"switchDark": "Switch to dark theme"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Users, ScrollText, ShieldAlert, Plus, Pencil, Trash2, Clock, AlertTriangle, Wrench, Server } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { AdminToolsTab } from "@/components/admin-tools-tab";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function Admin() {
|
||||
const { t } = useTranslation();
|
||||
const [, setLocation] = useLocation();
|
||||
const { user, isAdmin, isLoading: authLoading } = useAuth();
|
||||
const { toast } = useToast();
|
||||
@@ -68,9 +70,9 @@ export default function Admin() {
|
||||
<Layout>
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
|
||||
<h2 className="text-2xl font-bold">Admin Access Required</h2>
|
||||
<p className="text-muted-foreground">You need admin rights to view this page.</p>
|
||||
<Button variant="outline" onClick={() => setLocation("/")}>Go Home</Button>
|
||||
<h2 className="text-2xl font-bold">{t("admin.accessRequired")}</h2>
|
||||
<p className="text-muted-foreground">{t("admin.accessRequiredSub")}</p>
|
||||
<Button variant="outline" onClick={() => setLocation("/")}>{t("admin.goHome")}</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
@@ -82,7 +84,7 @@ export default function Admin() {
|
||||
{ data: { username: newUsername, password: newPassword, email: newEmail || undefined, role: newRole, tier: newTier } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: "User created", description: `${newUsername} has been created.` });
|
||||
toast({ title: t("admin.toastUserCreated"), description: t("admin.toastUserCreatedSub", { username: newUsername }) });
|
||||
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
|
||||
setCreateOpen(false);
|
||||
setNewUsername("");
|
||||
@@ -91,7 +93,7 @@ export default function Admin() {
|
||||
setNewRole("user");
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({ title: "Failed to create user", description: err.data?.error ?? err.message, variant: "destructive" });
|
||||
toast({ title: t("admin.toastUserCreateFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -103,12 +105,12 @@ export default function Admin() {
|
||||
{ id: editUser.id, data: { role: editUser.role as "admin" | "user", tier: editUser.tier as "free" | "premium" | "enterprise" } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: "User updated" });
|
||||
toast({ title: t("admin.toastUserUpdated") });
|
||||
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
|
||||
setEditUser(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({ title: "Failed to update user", description: err.data?.error ?? err.message, variant: "destructive" });
|
||||
toast({ title: t("admin.toastUserUpdateFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -117,18 +119,18 @@ export default function Admin() {
|
||||
const handleSetUserPassword = () => {
|
||||
if (!editUser || !editPassword) return;
|
||||
if (editPassword.length < 6) {
|
||||
toast({ title: "Password too short", description: "Minimum 6 characters.", variant: "destructive" });
|
||||
toast({ title: t("admin.toastPwTooShort"), description: t("admin.toastPwTooShortSub"), variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
setUserPassword.mutate(
|
||||
{ id: editUser.id, data: { password: editPassword } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: "Password updated", description: `Password for ${editUser.username} has been set.` });
|
||||
toast({ title: t("admin.toastPwUpdated"), description: t("admin.toastPwUpdatedSub", { username: editUser.username }) });
|
||||
setEditPassword("");
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({ title: "Failed to set password", description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" });
|
||||
toast({ title: t("admin.toastPwSetFailed"), description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" });
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -140,12 +142,12 @@ export default function Admin() {
|
||||
{ id: deleteConfirm.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: "User deleted", description: `${deleteConfirm.username} has been removed.` });
|
||||
toast({ title: t("admin.toastUserDeleted"), description: t("admin.toastUserDeletedSub", { username: deleteConfirm.username }) });
|
||||
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
|
||||
setDeleteConfirm(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({ title: "Failed to delete user", description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" });
|
||||
toast({ title: t("admin.toastUserDeleteFailed"), description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" });
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -163,11 +165,11 @@ export default function Admin() {
|
||||
<div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Admin Panel</h1>
|
||||
<p className="text-muted-foreground">Manage users and review system changes.</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("admin.panel")}</h1>
|
||||
<p className="text-muted-foreground">{t("admin.panelSub")}</p>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm" className="gap-2">
|
||||
<Link href="/admin/redundancy"><AlertTriangle className="w-4 h-4" /> Redundancy Dashboard</Link>
|
||||
<Link href="/admin/redundancy"><AlertTriangle className="w-4 h-4" /> {t("admin.redundancyDashboard")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,16 +177,16 @@ export default function Admin() {
|
||||
<Tabs defaultValue="users">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="users" className="gap-2">
|
||||
<Users className="w-4 h-4" /> Users
|
||||
<Users className="w-4 h-4" /> {t("admin.tabUsers")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tools" className="gap-2">
|
||||
<Wrench className="w-4 h-4" /> Tools
|
||||
<Wrench className="w-4 h-4" /> {t("admin.tabTools")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="audit" className="gap-2">
|
||||
<ScrollText className="w-4 h-4" /> Audit Log
|
||||
<ScrollText className="w-4 h-4" /> {t("admin.tabAudit")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="system" className="gap-2">
|
||||
<Server className="w-4 h-4" /> System
|
||||
<Server className="w-4 h-4" /> {t("admin.tabSystem")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -192,11 +194,11 @@ export default function Admin() {
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Local Users</CardTitle>
|
||||
<CardDescription>Manage accounts for local authentication.</CardDescription>
|
||||
<CardTitle>{t("admin.localUsers")}</CardTitle>
|
||||
<CardDescription>{t("admin.localUsersSub")}</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" /> Add User
|
||||
<Plus className="w-4 h-4 mr-2" /> {t("admin.addUser")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -248,7 +250,7 @@ export default function Admin() {
|
||||
</div>
|
||||
))}
|
||||
{(!users || users.length === 0) && (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">No users yet.</p>
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">{t("admin.noUsersYet")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -263,8 +265,8 @@ export default function Admin() {
|
||||
<TabsContent value="audit">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Audit Log</CardTitle>
|
||||
<CardDescription>All create, update and delete operations tracked by the system.</CardDescription>
|
||||
<CardTitle>{t("admin.auditLog")}</CardTitle>
|
||||
<CardDescription>{t("admin.auditLogSub")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingLogs ? (
|
||||
@@ -289,8 +291,7 @@ export default function Admin() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>by <span className="font-medium text-foreground">{log.username}</span></span>
|
||||
{log.changes && (
|
||||
<span>{t("admin.by")} <span className="font-medium text-foreground">{log.username}</span></span> {log.changes && (
|
||||
<span className="truncate max-w-[400px] font-mono bg-muted px-1.5 py-0.5 rounded text-[11px]">
|
||||
{log.changes.length > 120 ? log.changes.slice(0, 120) + "…" : log.changes}
|
||||
</span>
|
||||
@@ -299,7 +300,7 @@ export default function Admin() {
|
||||
</div>
|
||||
))}
|
||||
{(!auditLogs || auditLogs.length === 0) && (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">No audit entries yet.</p>
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">{t("admin.noAuditEntries")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -310,17 +311,17 @@ export default function Admin() {
|
||||
<TabsContent value="system">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>System</CardTitle>
|
||||
<CardDescription>Build information of the currently live deployment.</CardDescription>
|
||||
<CardTitle>{t("admin.system")}</CardTitle>
|
||||
<CardDescription>{t("admin.systemSub")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="divide-y">
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-sm text-muted-foreground">Version</span>
|
||||
<span className="text-sm text-muted-foreground">{t("admin.version")}</span>
|
||||
<span className="text-sm font-medium">{versionInfo?.version || "dev"}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-sm text-muted-foreground">Commit</span>
|
||||
<span className="text-sm text-muted-foreground">{t("admin.commit")}</span>
|
||||
{versionInfo?.commitSha ? (
|
||||
<a
|
||||
href={`https://git.kubebase.de/admin/tool-evaluator/commit/${versionInfo.commitSha}`}
|
||||
@@ -336,17 +337,17 @@ export default function Admin() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-sm text-muted-foreground">Build date</span>
|
||||
<span className="text-sm text-muted-foreground">{t("admin.buildDate")}</span>
|
||||
<span className="text-sm">
|
||||
{versionInfo?.buildDate ? format(new Date(versionInfo.buildDate), "dd.MM.yyyy HH:mm") : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-sm text-muted-foreground">Trash retention</span>
|
||||
<span className="text-sm text-muted-foreground">{t("admin.trashRetention")}</span>
|
||||
<span className="text-sm">
|
||||
{(versionInfo?.trashRetentionDays ?? 0) > 0
|
||||
? `${versionInfo?.trashRetentionDays} days`
|
||||
: "Keep forever"}
|
||||
? `${versionInfo?.trashRetentionDays} ${t("admin.days")}`
|
||||
: t("admin.keepForever")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -359,51 +360,51 @@ export default function Admin() {
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New User</DialogTitle>
|
||||
<DialogTitle>{t("admin.createNewUser")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Username</Label>
|
||||
<Input value={newUsername} onChange={(e) => setNewUsername(e.target.value)} placeholder="username" />
|
||||
<Label>{t("admin.username")}</Label>
|
||||
<Input value={newUsername} onChange={(e) => setNewUsername(e.target.value)} placeholder={t("admin.usernamePlaceholder")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Password</Label>
|
||||
<PasswordInput value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="min. 6 characters" />
|
||||
<Label>{t("admin.password")}</Label>
|
||||
<PasswordInput value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder={t("admin.minPasswordPlaceholder")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Email (Optional)</Label>
|
||||
<Input type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} placeholder="user@example.com" />
|
||||
<Label>{t("admin.emailOptional")}</Label>
|
||||
<Input type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} placeholder={t("admin.emailPlaceholder")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Role</Label>
|
||||
<Label>{t("admin.role")}</Label>
|
||||
<Select value={newRole} onValueChange={(v) => setNewRole(v as "admin" | "user")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="user">{t("admin.roleUser")}</SelectItem>
|
||||
<SelectItem value="admin">{t("admin.roleAdmin")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Plan</Label>
|
||||
<Label>{t("admin.plan")}</Label>
|
||||
<Select value={newTier} onValueChange={(v) => setNewTier(v as "free" | "premium" | "enterprise")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="free">Free</SelectItem>
|
||||
<SelectItem value="premium">Premium</SelectItem>
|
||||
<SelectItem value="enterprise">Enterprise</SelectItem>
|
||||
<SelectItem value="free">{t("admin.planFree")}</SelectItem>
|
||||
<SelectItem value="premium">{t("admin.planPremium")}</SelectItem>
|
||||
<SelectItem value="enterprise">{t("admin.planEnterprise")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>{t("common.cancel")}</Button>
|
||||
<Button onClick={handleCreateUser} disabled={createUser.isPending || !newUsername || !newPassword}>
|
||||
{createUser.isPending ? "Creating…" : "Create User"}
|
||||
{createUser.isPending ? t("admin.creating") : t("admin.createUser")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -412,11 +413,11 @@ export default function Admin() {
|
||||
<Dialog open={!!editUser} onOpenChange={(open) => { if (!open) { setEditUser(null); setEditPassword(""); } }}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit User — {editUser?.username}</DialogTitle>
|
||||
<DialogTitle>{t("admin.editUser", { username: editUser?.username })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Role</Label>
|
||||
<Label>{t("admin.role")}</Label>
|
||||
<Select
|
||||
value={editUser?.role || "user"}
|
||||
onValueChange={(v) => setEditUser(editUser ? { ...editUser, role: v } : null)}
|
||||
@@ -425,13 +426,13 @@ export default function Admin() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="user">{t("admin.roleUser")}</SelectItem>
|
||||
<SelectItem value="admin">{t("admin.roleAdmin")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Plan</Label>
|
||||
<Label>{t("admin.plan")}</Label>
|
||||
<Select
|
||||
value={editUser?.tier || "free"}
|
||||
onValueChange={(v) => setEditUser(editUser ? { ...editUser, tier: v } : null)}
|
||||
@@ -440,36 +441,36 @@ export default function Admin() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="free">Free</SelectItem>
|
||||
<SelectItem value="premium">Premium</SelectItem>
|
||||
<SelectItem value="enterprise">Enterprise</SelectItem>
|
||||
<SelectItem value="free">{t("admin.planFree")}</SelectItem>
|
||||
<SelectItem value="premium">{t("admin.planPremium")}</SelectItem>
|
||||
<SelectItem value="enterprise">{t("admin.planEnterprise")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{editUser?.authProvider !== "oidc" ? (
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<Label>Set Password</Label>
|
||||
<Label>{t("admin.setPassword")}</Label>
|
||||
<PasswordInput
|
||||
value={editPassword}
|
||||
onChange={(e) => setEditPassword(e.target.value)}
|
||||
placeholder="min. 6 characters"
|
||||
placeholder={t("admin.minPasswordPlaceholder")}
|
||||
data-testid="input-set-password"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Resets the user's password immediately.</p>
|
||||
<p className="text-xs text-muted-foreground">{t("admin.resetsPassword")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 border-t pt-4 text-sm text-muted-foreground">
|
||||
Password is managed by the identity provider (Keycloak). Reset it there.
|
||||
{t("admin.idpManaged")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="flex-col sm:flex-row sm:justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => { setEditUser(null); setEditPassword(""); }}>Cancel</Button>
|
||||
<Button variant="outline" onClick={() => { setEditUser(null); setEditPassword(""); }}>{t("common.cancel")}</Button>
|
||||
<Button
|
||||
onClick={handleUpdateUser}
|
||||
disabled={updateUser.isPending}
|
||||
>
|
||||
Save
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
{editUser?.authProvider !== "oidc" && (
|
||||
<Button
|
||||
@@ -477,7 +478,7 @@ export default function Admin() {
|
||||
onClick={handleSetUserPassword}
|
||||
disabled={setUserPassword.isPending || !editPassword}
|
||||
>
|
||||
{setUserPassword.isPending ? "Setting…" : "Set Password"}
|
||||
{setUserPassword.isPending ? t("admin.setting") : t("admin.setPassword")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
@@ -487,15 +488,15 @@ export default function Admin() {
|
||||
<Dialog open={!!deleteConfirm} onOpenChange={(open) => !open && setDeleteConfirm(null)}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete User</DialogTitle>
|
||||
<DialogTitle>{t("admin.deleteUser")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
Are you sure you want to delete <span className="font-medium text-foreground">{deleteConfirm?.username}</span>? This cannot be undone.
|
||||
{t("admin.deleteUserConfirm", { username: deleteConfirm?.username })}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>Cancel</Button>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>{t("common.cancel")}</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteUser} disabled={deleteUser.isPending}>
|
||||
{deleteUser.isPending ? "Deleting…" : "Delete"}
|
||||
{deleteUser.isPending ? t("detail.deleting") : t("common.delete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
useGetRatingDistribution,
|
||||
GetTopToolsMetric
|
||||
} from "@workspace/api-client-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Layout } from "@/components/layout";
|
||||
import { GuideHelp } from "@/components/guide-help";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
import { BarChart3, TrendingUp, Layers, Activity } from "lucide-react";
|
||||
|
||||
export default function Analytics() {
|
||||
const { t } = useTranslation();
|
||||
const { data: summary, isLoading: loadingSummary } = useGetAnalyticsSummary();
|
||||
|
||||
const { data: topTools, isLoading: loadingTopTools } = useGetTopTools({
|
||||
@@ -50,10 +52,10 @@ export default function Analytics() {
|
||||
<div className="space-y-6 pb-10">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-2">
|
||||
Platform Analytics
|
||||
<GuideHelp guide="analytics" label="Platform Analytics" />
|
||||
{t("analytics.title")}
|
||||
<GuideHelp guide="analytics" label={t("analytics.title")} />
|
||||
</h1>
|
||||
<p className="text-muted-foreground">Macro-level insights into tool performance and community engagement.</p>
|
||||
<p className="text-muted-foreground">{t("analytics.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{/* Top KPI Cards */}
|
||||
@@ -61,7 +63,7 @@ export default function Analytics() {
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium text-muted-foreground">Total Tools Indexed</span>
|
||||
<span className="text-sm font-medium text-muted-foreground">{t("analytics.totalToolsIndexed")}</span>
|
||||
<Layers className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
||||
@@ -73,7 +75,7 @@ export default function Analytics() {
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium text-muted-foreground">Total Ratings Cast</span>
|
||||
<span className="text-sm font-medium text-muted-foreground">{t("analytics.totalRatingsCast")}</span>
|
||||
<Activity className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
||||
@@ -85,7 +87,7 @@ export default function Analytics() {
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium text-muted-foreground">Active Categories</span>
|
||||
<span className="text-sm font-medium text-muted-foreground">{t("analytics.activeCategories")}</span>
|
||||
<BarChart3 className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
||||
@@ -97,7 +99,7 @@ export default function Analytics() {
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium text-muted-foreground">Avg Global Score</span>
|
||||
<span className="text-sm font-medium text-muted-foreground">{t("analytics.avgGlobalScore")}</span>
|
||||
<TrendingUp className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
{loadingSummary ? <Skeleton className="h-8 w-16" /> : (
|
||||
@@ -112,8 +114,8 @@ export default function Analytics() {
|
||||
{/* Top Tools Chart */}
|
||||
<Card className="col-span-1 lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Top 8 Tools by Combined Score</CardTitle>
|
||||
<CardDescription>Highest rated tools across the platform</CardDescription>
|
||||
<CardTitle>{t("analytics.top8Tools")}</CardTitle>
|
||||
<CardDescription>{t("analytics.top8ToolsSub")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingTopTools ? (
|
||||
@@ -140,8 +142,8 @@ export default function Analytics() {
|
||||
{/* Category Breakdown */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tools by Category</CardTitle>
|
||||
<CardDescription>Distribution of tools across categories</CardDescription>
|
||||
<CardTitle>{t("analytics.toolsByCategory")}</CardTitle>
|
||||
<CardDescription>{t("analytics.toolsByCategorySub")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingCategories ? (
|
||||
@@ -153,7 +155,7 @@ export default function Analytics() {
|
||||
<PolarGrid stroke="hsl(var(--border))" />
|
||||
<PolarAngleAxis dataKey="category" tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} />
|
||||
<PolarRadiusAxis angle={30} domain={[0, 'auto']} tick={false} axisLine={false} />
|
||||
<Radar name="Tools" dataKey="tools" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.3} />
|
||||
<Radar name={t("analytics.radarTools")} dataKey="tools" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.3} />
|
||||
<RechartsTooltip />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -165,8 +167,8 @@ export default function Analytics() {
|
||||
{/* Rating Distributions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Global Rating Distribution</CardTitle>
|
||||
<CardDescription>How users are voting across all tools</CardDescription>
|
||||
<CardTitle>{t("analytics.globalRatingDistribution")}</CardTitle>
|
||||
<CardDescription>{t("analytics.globalRatingDistributionSub")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingDistribution ? (
|
||||
@@ -174,7 +176,7 @@ export default function Analytics() {
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">Usefulness</span>
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">{t("detail.usefulness")}</span>
|
||||
<div className="h-[110px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={usefulnessData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
||||
@@ -188,7 +190,7 @@ export default function Analytics() {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">Usability</span>
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 block">{t("detail.usability")}</span>
|
||||
<div className="h-[110px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={usabilityData} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
|
||||
|
||||
@@ -145,7 +145,7 @@ export default function Compare() {
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Usefulness</TableCell>
|
||||
<TableCell className="font-medium">{t("detail.usefulness")}</TableCell>
|
||||
{list.map((t) => (
|
||||
<TableCell key={t.id} className={cn(isBest(t.avgUsefulness, bestUsefulness) && "bg-primary/5")}>
|
||||
<span className="tabular-nums">{fmt(t.avgUsefulness)}/5</span>
|
||||
@@ -153,7 +153,7 @@ export default function Compare() {
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Usability</TableCell>
|
||||
<TableCell className="font-medium">{t("detail.usability")}</TableCell>
|
||||
{list.map((t) => (
|
||||
<TableCell key={t.id} className={cn(isBest(t.avgUsability, bestUsability) && "bg-primary/5")}>
|
||||
<span className="tabular-nums">{fmt(t.avgUsability)}/5</span>
|
||||
@@ -177,7 +177,7 @@ export default function Compare() {
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Features</TableCell>
|
||||
<TableCell className="font-medium">{t("filter.features")}</TableCell>
|
||||
{list.map((t) => (
|
||||
<TableCell key={t.id} className="align-top">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -190,7 +190,7 @@ export default function Compare() {
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Tags</TableCell>
|
||||
<TableCell className="font-medium">{t("filter.tags")}</TableCell>
|
||||
{list.map((t) => (
|
||||
<TableCell key={t.id} className="align-top">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { LanguageSwitcher } from "@/components/language-switcher";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -57,6 +58,20 @@ function docsFile(version: string | null, relPath: string) {
|
||||
return version === null ? relPath : `versions/${version}/${relPath}`;
|
||||
}
|
||||
|
||||
// Prefer the English file/title variant when the active UI language is English.
|
||||
function useDocsLocale() {
|
||||
const { i18n } = useTranslation();
|
||||
return (i18n.language ?? "en").toLowerCase().startsWith("en") ? "en" : "de";
|
||||
}
|
||||
|
||||
function localizedFile(file: string, fileEn: string | null, locale: "en" | "de"): string {
|
||||
return locale === "en" && fileEn ? fileEn : file;
|
||||
}
|
||||
|
||||
function localizedTitle(title: string, titleEn: string | null, locale: "en" | "de"): string {
|
||||
return locale === "en" && titleEn ? titleEn : title;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -100,13 +115,22 @@ type Reference = { tags: TagGroup[]; schemas: SchemaModel[] };
|
||||
type ReleaseDoc = {
|
||||
version: string;
|
||||
file: string;
|
||||
fileEn: string | null;
|
||||
title: string;
|
||||
titleEn: string | null;
|
||||
date: string | null;
|
||||
hasReference: boolean;
|
||||
hasHandbook: boolean;
|
||||
};
|
||||
|
||||
type HandbookPage = { slug: string; file: string; title: string; order: number };
|
||||
type HandbookPage = {
|
||||
slug: string;
|
||||
file: string;
|
||||
fileEn: string | null;
|
||||
title: string;
|
||||
titleEn: string | null;
|
||||
order: number;
|
||||
};
|
||||
|
||||
type SearchEntry = { title: string; href: string; kind: string; text: string };
|
||||
|
||||
@@ -339,6 +363,7 @@ function DocsNav({
|
||||
}) {
|
||||
const [location] = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const locale = useDocsLocale();
|
||||
|
||||
const navLink = (href: string) => {
|
||||
const active = location === href || (href !== "/docs" && location.startsWith(href));
|
||||
@@ -353,7 +378,7 @@ function DocsNav({
|
||||
icon: BookOpen,
|
||||
items: handbook.map((p) => ({
|
||||
href: docsHref(version, `handbook/${p.slug}`),
|
||||
label: p.title,
|
||||
label: localizedTitle(p.title, p.titleEn, locale),
|
||||
active: navLink(docsHref(version, `handbook/${p.slug}`)),
|
||||
})),
|
||||
});
|
||||
@@ -536,7 +561,7 @@ function FieldTable({ fields }: { fields: Field[] }) {
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{f.required ? (
|
||||
<Badge className="bg-primary/10 text-primary border-primary/20">required</Badge>
|
||||
<Badge className="bg-primary/10 text-primary border-primary/20">{t("docs.required")}</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">–</span>
|
||||
)}
|
||||
@@ -611,8 +636,8 @@ function EndpointTagView({ tag }: { tag: TagGroup }) {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50 text-left text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<th className="px-3 py-2 font-semibold">Name</th>
|
||||
<th className="px-3 py-2 font-semibold">In</th>
|
||||
<th className="px-3 py-2 font-semibold">{t("docs.name")}</th>
|
||||
<th className="px-3 py-2 font-semibold">{t("docs.in")}</th>
|
||||
<th className="px-3 py-2 font-semibold">{t("docs.type")}</th>
|
||||
<th className="px-3 py-2 font-semibold">{t("docs.required")}</th>
|
||||
<th className="px-3 py-2 font-semibold">{t("docs.description")}</th>
|
||||
@@ -625,7 +650,7 @@ function EndpointTagView({ tag }: { tag: TagGroup }) {
|
||||
<td className="px-3 py-1.5 text-muted-foreground">{p.in}</td>
|
||||
<td className="px-3 py-1.5"><FieldTypeChip type={p.type} /></td>
|
||||
<td className="px-3 py-1.5">
|
||||
{p.required ? <Badge className="bg-primary/10 text-primary border-primary/20">req</Badge> : "–"}
|
||||
{p.required ? <Badge className="bg-primary/10 text-primary border-primary/20">{t("docs.required")}</Badge> : "–"}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-muted-foreground">
|
||||
{p.description}
|
||||
@@ -642,7 +667,7 @@ function EndpointTagView({ tag }: { tag: TagGroup }) {
|
||||
{ep.requestBody && (
|
||||
<div className="mb-3 rounded-lg border bg-muted/30 p-3">
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Request Body {ep.requestBody.required && <Badge className="ml-1">required</Badge>}
|
||||
{t("docs.requestBody")} {ep.requestBody.required && <Badge className="ml-1">{t("docs.required")}</Badge>}
|
||||
</p>
|
||||
<FieldTypeChip type={ep.requestBody.schema} />
|
||||
</div>
|
||||
@@ -679,6 +704,7 @@ function EndpointTagView({ tag }: { tag: TagGroup }) {
|
||||
function ReleasesView({ versions }: { versions: ReleaseDoc[] }) {
|
||||
const [, setLocation] = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const locale = useDocsLocale();
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
<div className="flex-1 min-w-0 max-w-3xl space-y-4">
|
||||
@@ -697,7 +723,7 @@ function ReleasesView({ versions }: { versions: ReleaseDoc[] }) {
|
||||
<FileText className="h-4 w-4 shrink-0 text-primary" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{v.title}</span>
|
||||
<span className="font-semibold">{localizedTitle(v.title, v.titleEn, locale)}</span>
|
||||
<Badge variant="secondary">{v.version}</Badge>
|
||||
</div>
|
||||
{v.date && (
|
||||
@@ -707,7 +733,7 @@ function ReleasesView({ versions }: { versions: ReleaseDoc[] }) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{v.hasReference && <Badge className="bg-primary/10 text-primary">API-Referenz</Badge>}
|
||||
{v.hasReference && <Badge className="bg-primary/10 text-primary">{t("docs.reference")}</Badge>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -717,7 +743,9 @@ function ReleasesView({ versions }: { versions: ReleaseDoc[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ReleaseNoteView({ version }: { version: string }) {
|
||||
function ReleaseNoteView({ version, doc }: { version: string; doc: ReleaseDoc | null }) {
|
||||
const locale = useDocsLocale();
|
||||
const file = doc ? `releases/${localizedFile(doc.file, doc.fileEn, locale)}` : `releases/${version}.md`;
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
<div className="flex-1 min-w-0 max-w-3xl">
|
||||
@@ -733,16 +761,20 @@ function ReleaseNoteView({ version }: { version: string }) {
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
<MarkdownView file={`releases/${version}.md`} />
|
||||
<MarkdownView file={file} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HandbookView({ slug }: { slug: string }) {
|
||||
function HandbookView({ slug, pages }: { slug: string; pages: HandbookPage[] | null }) {
|
||||
const version = useDocsVersion();
|
||||
const handbookFile = `${slug}.md`;
|
||||
return <MarkdownView file={docsFile(version, `handbook/${handbookFile}`)} />;
|
||||
const locale = useDocsLocale();
|
||||
const page = pages?.find((p) => p.slug === slug);
|
||||
const file = page
|
||||
? docsFile(version, `handbook/${localizedFile(page.file, page.fileEn, locale)}`)
|
||||
: docsFile(version, `handbook/${slug}.md`);
|
||||
return <MarkdownView file={file} />;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -750,7 +782,9 @@ function HandbookView({ slug }: { slug: string }) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useDocsSearch(query: string) {
|
||||
const { data, error } = useJson<SearchEntry[]>(query ? `${DOCS_BASE}/search.json` : null);
|
||||
const locale = useDocsLocale();
|
||||
const indexFile = locale === "en" ? "search.en.json" : "search.json";
|
||||
const { data, error } = useJson<SearchEntry[]>(query ? `${DOCS_BASE}/${indexFile}` : null);
|
||||
const results = useMemo(() => {
|
||||
if (!query.trim() || !data) return [];
|
||||
const q = query.trim().toLowerCase();
|
||||
@@ -879,21 +913,21 @@ export default function Docs() {
|
||||
if (version !== null && path.length === 0) {
|
||||
content =
|
||||
handbook && handbook.length > 0 ? (
|
||||
<HandbookView slug={handbook[0].slug} />
|
||||
<HandbookView slug={handbook[0].slug} pages={handbook} />
|
||||
) : (
|
||||
<ReleaseNoteView version={version} />
|
||||
<ReleaseNoteView version={version} doc={activeRelease} />
|
||||
);
|
||||
} else if (section === "home") {
|
||||
content =
|
||||
handbook && handbook.length > 0 ? (
|
||||
<HandbookView slug={handbook[0].slug} />
|
||||
<HandbookView slug={handbook[0].slug} pages={handbook} />
|
||||
) : releases && releases.length > 0 ? (
|
||||
<ReleasesView versions={releases} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
|
||||
);
|
||||
} else if (section === "handbook" && param) {
|
||||
content = <HandbookView slug={param} />;
|
||||
content = <HandbookView slug={param} pages={handbook} />;
|
||||
} else if (section === "reference" && param === "endpoints" && path[2]) {
|
||||
const tag = reference?.tags.find(
|
||||
(tg) => tg.name.toLowerCase() === path[2].toLowerCase(),
|
||||
@@ -963,7 +997,7 @@ export default function Docs() {
|
||||
</div>
|
||||
);
|
||||
} else if (section === "releases" && param) {
|
||||
content = <ReleaseNoteView version={param} />;
|
||||
content = <ReleaseNoteView version={param} doc={releases?.find((r) => r.version === param) ?? null} />;
|
||||
} else if (section === "releases") {
|
||||
content = releases ? <ReleasesView versions={releases} /> : <Skeleton className="h-64 w-full" />;
|
||||
} else {
|
||||
@@ -1014,6 +1048,7 @@ export default function Docs() {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<LanguageSwitcher />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Layout } from "@/components/layout";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -11,6 +12,7 @@ import { customFetch } from "@workspace/api-client-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export default function RedundancyPage() {
|
||||
const { t } = useTranslation();
|
||||
const [data, setData] = useState<any[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
@@ -28,10 +30,10 @@ export default function RedundancyPage() {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ toolId, relatedToolId, betterToolId }),
|
||||
});
|
||||
toast({ title: "Evaluation saved" });
|
||||
toast({ title: t("redundancy.toastEvalSaved") });
|
||||
setData(await customFetch<any[]>("/api/admin/redundancy"));
|
||||
} catch {
|
||||
toast({ title: "Failed to save evaluation", variant: "destructive" });
|
||||
toast({ title: t("redundancy.toastEvalFailed"), variant: "destructive" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,10 +46,10 @@ export default function RedundancyPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="w-6 h-6 text-amber-500" />
|
||||
<h1 className="text-3xl font-bold">Tool Analysis & Recommendations</h1>
|
||||
<h1 className="text-3xl font-bold">{t("redundancy.title")}</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Automatic redundancy detection with cost and rating comparison. Admin can manually confirm which tool is the better choice.
|
||||
{t("redundancy.subtitle")}
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
@@ -61,12 +63,12 @@ export default function RedundancyPage() {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold capitalize">{group.category}</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{group.tools.length} tools, {group.pairs.length} comparisons</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{t("redundancy.toolsComparisons", { tools: group.tools.length, pairs: group.pairs.length })}</p>
|
||||
</div>
|
||||
{group.totalMonthlyCost > 0 && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<DollarSign className="w-3 h-3" />
|
||||
{group.totalMonthlyCost.toFixed(2)}/mo total
|
||||
{group.totalMonthlyCost.toFixed(2)}{t("redundancy.totalMonthly")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -82,12 +84,12 @@ export default function RedundancyPage() {
|
||||
<span className="font-medium">{tool.name}</span>
|
||||
{tool.costs?.length > 0 && tool.totalMonthly > 0 && (
|
||||
<Badge variant="outline" className="text-[10px] px-1 py-0 shrink-0">
|
||||
{tool.totalMonthly.toFixed(2)}/mo
|
||||
{tool.totalMonthly.toFixed(2)}{t("redundancy.perMonth")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground flex-wrap">
|
||||
<span>{tool.ratingCount} reviews</span>
|
||||
<span>{tool.ratingCount} {t("redundancy.reviews")}</span>
|
||||
{tool.avgCombined != null && (
|
||||
<>
|
||||
<span>·</span>
|
||||
@@ -104,7 +106,7 @@ export default function RedundancyPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0">{tool.features.length} features</Badge>
|
||||
<Badge variant="outline" className="shrink-0">{tool.features.length} {t("redundancy.features")}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -114,7 +116,7 @@ export default function RedundancyPage() {
|
||||
|
||||
{group.pairs.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Comparisons & Recommendations</h3>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">{t("redundancy.comparisonsTitle")}</h3>
|
||||
{group.pairs.map((pair: any, i: number) => (
|
||||
<Card key={i} className={pair.recommendation.certainty === "high" ? "border-green-300" : pair.recommendation.certainty === "medium" ? "border-amber-200" : ""}>
|
||||
<CardContent className="p-4">
|
||||
@@ -126,11 +128,11 @@ export default function RedundancyPage() {
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{pair.a.avgCombined != null ? pair.a.avgCombined.toFixed(1) : "N/A"} ★
|
||||
{pair.a.totalMonthly > 0 ? ` · ${pair.a.totalMonthly.toFixed(2)}/mo` : ""}
|
||||
{pair.a.totalMonthly > 0 ? ` · ${pair.a.totalMonthly.toFixed(2)}${t("redundancy.perMonth")}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-center shrink-0">
|
||||
<div className="text-xs text-muted-foreground font-medium">vs</div>
|
||||
<div className="text-xs text-muted-foreground font-medium">{t("redundancy.vs")}</div>
|
||||
<div className="flex items-center gap-1 justify-center mt-0.5">
|
||||
<Progress value={pair.overlap} className="w-12 h-1.5" />
|
||||
<span className="text-[10px] text-muted-foreground">{pair.overlap}%</span>
|
||||
@@ -142,7 +144,7 @@ export default function RedundancyPage() {
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{pair.b.avgCombined != null ? pair.b.avgCombined.toFixed(1) : "N/A"} ★
|
||||
{pair.b.totalMonthly > 0 ? ` · ${pair.b.totalMonthly.toFixed(2)}/mo` : ""}
|
||||
{pair.b.totalMonthly > 0 ? ` · ${pair.b.totalMonthly.toFixed(2)}${t("redundancy.perMonth")}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,7 +190,7 @@ export default function RedundancyPage() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground py-8 text-center">No tools found.</p>
|
||||
<p className="text-muted-foreground py-8 text-center">{t("redundancy.noTools")}</p>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
@@ -121,24 +121,24 @@ export default function ToolDetail() {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ relatedToolId: Number(linkToolId), relationType: linkType, notes: linkNotes || undefined }),
|
||||
});
|
||||
toast({ title: "Relation created" });
|
||||
toast({ title: t("detail.toastRelationCreated") });
|
||||
setLinkDialogOpen(false);
|
||||
setLinkToolId("");
|
||||
setLinkNotes("");
|
||||
setLinkType("similar");
|
||||
setSimilarData(await customFetch<any>(`/api/tools/${id}/similar`));
|
||||
} catch (err: any) {
|
||||
toast({ title: "Failed to create relation", description: err.data?.error ?? err.message, variant: "destructive" });
|
||||
toast({ title: t("detail.toastRelationFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteRelation(relationId: number) {
|
||||
try {
|
||||
await customFetch(`/api/tools/relations/${relationId}`, { method: "DELETE" });
|
||||
toast({ title: "Relation deleted" });
|
||||
toast({ title: t("detail.toastRelationDeleted") });
|
||||
setSimilarData(await customFetch<any>(`/api/tools/${id}/similar`));
|
||||
} catch {
|
||||
toast({ title: "Failed to delete relation", variant: "destructive" });
|
||||
toast({ title: t("detail.toastRelationDeleteFailed"), variant: "destructive" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,12 +163,12 @@ export default function ToolDetail() {
|
||||
if (costAmount) body.cost = costAmount;
|
||||
try {
|
||||
await customFetch(url, { method, body: JSON.stringify(body) });
|
||||
toast({ title: editCost ? "Cost updated" : "Cost added" });
|
||||
toast({ title: editCost ? t("detail.toastCostUpdated") : t("detail.toastCostAdded") });
|
||||
setCostDialogOpen(false);
|
||||
resetCostForm();
|
||||
setCosts(await customFetch<any[]>(`/api/tools/${id}/costs`));
|
||||
} catch (err: any) {
|
||||
toast({ title: "Failed to save cost", description: err.data?.error ?? err.message, variant: "destructive" });
|
||||
toast({ title: t("detail.toastCostFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,10 +194,10 @@ export default function ToolDetail() {
|
||||
async function handleDeleteCost(costId: number) {
|
||||
try {
|
||||
await customFetch(`/api/costs/${costId}`, { method: "DELETE" });
|
||||
toast({ title: "Cost deleted" });
|
||||
toast({ title: t("detail.toastCostDeleted") });
|
||||
setCosts(await customFetch<any[]>(`/api/tools/${id}/costs`));
|
||||
} catch {
|
||||
toast({ title: "Failed to delete cost", variant: "destructive" });
|
||||
toast({ title: t("detail.toastCostDeleteFailed"), variant: "destructive" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,8 +237,8 @@ export default function ToolDetail() {
|
||||
createRating.mutate({ id, data }, {
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Rating submitted",
|
||||
description: "Thank you for your feedback!",
|
||||
title: t("detail.toastRatingSubmitted"),
|
||||
description: t("detail.toastRatingThanks"),
|
||||
});
|
||||
setIsReviewFormOpen(false);
|
||||
form.reset();
|
||||
@@ -252,8 +252,8 @@ export default function ToolDetail() {
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Failed to submit rating",
|
||||
description: error.data?.error || error.message || "An unexpected error occurred.",
|
||||
title: t("detail.toastRatingFailed"),
|
||||
description: error.data?.error || error.message || t("detail.unexpectedError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
@@ -264,9 +264,9 @@ export default function ToolDetail() {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<h2 className="text-2xl font-bold">Invalid Tool ID</h2>
|
||||
<h2 className="text-2xl font-bold">{t("detail.invalidToolId")}</h2>
|
||||
<Button variant="link" asChild className="mt-4">
|
||||
<Link href="/tools"><ArrowLeft className="w-4 h-4 mr-2" /> Back to tools</Link>
|
||||
<Link href="/tools"><ArrowLeft className="w-4 h-4 mr-2" /> {t("detail.backToBrowse")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -288,7 +288,7 @@ export default function ToolDetail() {
|
||||
{ id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: "Tool deleted" });
|
||||
toast({ title: t("detail.toastToolDeleted") });
|
||||
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
|
||||
@@ -297,7 +297,7 @@ export default function ToolDetail() {
|
||||
setLocation("/tools");
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({ title: "Failed to delete", description: err.data?.error || err.message, variant: "destructive" });
|
||||
toast({ title: t("detail.toastDeleteFailed"), description: err.data?.error || err.message, variant: "destructive" });
|
||||
setDeleteOpen(false);
|
||||
},
|
||||
},
|
||||
@@ -309,7 +309,7 @@ export default function ToolDetail() {
|
||||
<Layout>
|
||||
<div className="space-y-6 max-w-5xl mx-auto pb-10">
|
||||
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
|
||||
<Link href="/tools"><ArrowLeft className="w-4 h-4 mr-2" /> Back to browse</Link>
|
||||
<Link href="/tools"><ArrowLeft className="w-4 h-4 mr-2" /> {t("detail.backToBrowse")}</Link>
|
||||
</Button>
|
||||
|
||||
{/* Header Section */}
|
||||
@@ -408,7 +408,7 @@ export default function ToolDetail() {
|
||||
|
||||
{tool.features && tool.features.length > 0 && (
|
||||
<div className="pt-6 border-t">
|
||||
<h3 className="text-lg font-semibold mb-3">Key Features</h3>
|
||||
<h3 className="text-lg font-semibold mb-3">{t("detail.keyFeatures")}</h3>
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{tool.features.map((feature, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
@@ -423,17 +423,17 @@ export default function ToolDetail() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-10">Tool not found.</div>
|
||||
<div className="text-center py-10">{t("detail.toolNotFound")}</div>
|
||||
)}
|
||||
|
||||
{/* Similar Tools Section */}
|
||||
{tool && (
|
||||
<div className="space-y-4 mt-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xl font-bold">Similar Tools</h3>
|
||||
<h3 className="text-xl font-bold">{t("detail.similarTools")}</h3>
|
||||
{isAdmin && (
|
||||
<Button variant="outline" size="sm" onClick={() => setLinkDialogOpen(true)} className="gap-2">
|
||||
<LinkIcon className="w-4 h-4" /> Link Tool
|
||||
<LinkIcon className="w-4 h-4" /> {t("detail.linkTool")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -501,7 +501,7 @@ export default function ToolDetail() {
|
||||
</>
|
||||
)}
|
||||
<span>·</span>
|
||||
<span>Score: {item.score}</span>
|
||||
<span>{t("detail.score")}: {item.score}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -510,7 +510,7 @@ export default function ToolDetail() {
|
||||
))}
|
||||
</div>
|
||||
) : similarData && similarData.manual.length === 0 && similarData.auto.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4">No similar tools found.</p>
|
||||
<p className="text-sm text-muted-foreground py-4">{t("detail.noSimilarTools")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
@@ -519,44 +519,44 @@ export default function ToolDetail() {
|
||||
<Dialog open={linkDialogOpen} onOpenChange={setLinkDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Link Similar Tool</DialogTitle>
|
||||
<DialogDescription>Manually link this tool to another tool.</DialogDescription>
|
||||
<DialogTitle>{t("detail.linkSimilarTool")}</DialogTitle>
|
||||
<DialogDescription>{t("detail.linkSimilarToolSub")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Tool ID</label>
|
||||
<label className="text-sm font-medium">{t("detail.toolId")}</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Enter target tool ID"
|
||||
placeholder={t("detail.toolIdPlaceholder")}
|
||||
value={linkToolId}
|
||||
onChange={(e) => setLinkToolId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Relation Type</label>
|
||||
<label className="text-sm font-medium">{t("detail.relationType")}</label>
|
||||
<Select value={linkType} onValueChange={setLinkType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="similar">Similar</SelectItem>
|
||||
<SelectItem value="replaces">Replaces</SelectItem>
|
||||
<SelectItem value="superseded_by">Superseded By</SelectItem>
|
||||
<SelectItem value="similar">{t("detail.relationSimilar")}</SelectItem>
|
||||
<SelectItem value="replaces">{t("detail.relationReplaces")}</SelectItem>
|
||||
<SelectItem value="superseded_by">{t("detail.relationSupersededBy")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Notes (Optional)</label>
|
||||
<label className="text-sm font-medium">{t("detail.notesOptional")}</label>
|
||||
<Textarea
|
||||
placeholder="Why are these tools related?"
|
||||
placeholder={t("detail.notesPlaceholder")}
|
||||
value={linkNotes}
|
||||
onChange={(e) => setLinkNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setLinkDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleCreateRelation} disabled={!linkToolId}>Create Link</Button>
|
||||
<Button variant="outline" onClick={() => setLinkDialogOpen(false)}>{t("common.cancel")}</Button>
|
||||
<Button onClick={handleCreateRelation} disabled={!linkToolId}>{t("detail.createLink")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -587,7 +587,7 @@ export default function ToolDetail() {
|
||||
{c.billingPeriod && <span className="text-[10px] text-muted-foreground uppercase">{c.billingPeriod}</span>}
|
||||
</div>
|
||||
<div className="text-lg font-bold">
|
||||
{c.cost != null ? `${c.cost} ${c.currency ?? ""}` : "Free"}
|
||||
{c.cost != null ? `${c.cost} ${c.currency ?? ""}` : t("detail.licenseFree")}
|
||||
</div>
|
||||
{c.notes && <p className="text-xs text-muted-foreground mt-1 italic">{c.notes}</p>}
|
||||
</div>
|
||||
@@ -607,7 +607,7 @@ export default function ToolDetail() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground py-4">No cost information added yet.</p>
|
||||
<p className="text-sm text-muted-foreground py-4">{t("detail.noCostInfo")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -620,41 +620,41 @@ export default function ToolDetail() {
|
||||
{editCost ? t("detail.edit") + " " + t("detail.costs") : t("detail.addCost")}
|
||||
<GuideHelp guide="kosten" label={t("detail.costs")} />
|
||||
</DialogTitle>
|
||||
<DialogDescription>Manage license cost information for this tool.</DialogDescription>
|
||||
<DialogDescription>{t("detail.costDialogSub")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">License Type</label>
|
||||
<label className="text-sm font-medium">{t("detail.licenseType")}</label>
|
||||
<Select value={costLicenseType} onValueChange={setCostLicenseType}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="free">Free</SelectItem>
|
||||
<SelectItem value="subscription">Subscription</SelectItem>
|
||||
<SelectItem value="one_time">One-Time</SelectItem>
|
||||
<SelectItem value="usage_based">Usage-Based</SelectItem>
|
||||
<SelectItem value="free">{t("detail.licenseFree")}</SelectItem>
|
||||
<SelectItem value="subscription">{t("detail.licenseSubscription")}</SelectItem>
|
||||
<SelectItem value="one_time">{t("detail.licenseOneTime")}</SelectItem>
|
||||
<SelectItem value="usage_based">{t("detail.licenseUsageBased")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{costLicenseType === "subscription" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Billing Period</label>
|
||||
<label className="text-sm font-medium">{t("detail.billingPeriod")}</label>
|
||||
<Select value={costBillingPeriod} onValueChange={setCostBillingPeriod}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="monthly">Monthly</SelectItem>
|
||||
<SelectItem value="quarterly">Quarterly</SelectItem>
|
||||
<SelectItem value="yearly">Yearly</SelectItem>
|
||||
<SelectItem value="monthly">{t("detail.billingMonthly")}</SelectItem>
|
||||
<SelectItem value="quarterly">{t("detail.billingQuarterly")}</SelectItem>
|
||||
<SelectItem value="yearly">{t("detail.billingYearly")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Cost</label>
|
||||
<label className="text-sm font-medium">{t("detail.costLabel")}</label>
|
||||
<Input type="number" step="0.01" placeholder="0.00" value={costAmount} onChange={(e) => setCostAmount(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Currency</label>
|
||||
<label className="text-sm font-medium">{t("detail.currency")}</label>
|
||||
<Select value={costCurrency} onValueChange={setCostCurrency}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -667,13 +667,13 @@ export default function ToolDetail() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Notes</label>
|
||||
<Textarea placeholder="Billing details, contract info..." value={costNotes} onChange={(e) => setCostNotes(e.target.value)} />
|
||||
<label className="text-sm font-medium">{t("detail.costNotes")}</label>
|
||||
<Textarea placeholder={t("detail.costNotesPlaceholder")} value={costNotes} onChange={(e) => setCostNotes(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCostDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSaveCost}>Save</Button>
|
||||
<Button variant="outline" onClick={() => setCostDialogOpen(false)}>{t("common.cancel")}</Button>
|
||||
<Button onClick={handleSaveCost}>{t("common.save")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -744,9 +744,9 @@ export default function ToolDetail() {
|
||||
<YAxis domain={[0, 5]} tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<Tooltip labelFormatter={(_, payload) => (payload?.[0] ? format(new Date(payload[0].payload.date), "dd.MM.yyyy HH:mm") : "")} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line type="monotone" dataKey="combined" name="Combined" stroke="hsl(var(--primary))" strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="usefulness" name="Usefulness" stroke="hsl(var(--success))" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
|
||||
<Line type="monotone" dataKey="usability" name="Usability" stroke="hsl(var(--warning))" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
|
||||
<Line type="monotone" dataKey="combined" name={t("detail.score")} stroke="hsl(var(--primary))" strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="usefulness" name={t("detail.usefulness")} stroke="hsl(var(--success))" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
|
||||
<Line type="monotone" dataKey="usability" name={t("detail.usability")} stroke="hsl(var(--warning))" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -773,7 +773,7 @@ export default function ToolDetail() {
|
||||
{t("detail.addReview")}
|
||||
<GuideHelp guide="bewerten" label={t("detail.addReview")} />
|
||||
</CardTitle>
|
||||
<CardDescription>Share your experience with {tool.name}</CardDescription>
|
||||
<CardDescription>{t("detail.shareExperience", { name: tool.name })}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
@@ -829,12 +829,12 @@ export default function ToolDetail() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Comment (Optional)
|
||||
<FieldHelp schema="RatingInput" field="comment">Comment</FieldHelp>
|
||||
{t("detail.commentOptional")}
|
||||
<FieldHelp schema="RatingInput" field="comment">{t("detail.commentLabel")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="What do you think about this tool?"
|
||||
placeholder={t("detail.commentPlaceholder")}
|
||||
className="resize-none min-h-[100px]"
|
||||
{...field}
|
||||
/>
|
||||
@@ -850,11 +850,11 @@ export default function ToolDetail() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Name (Optional)
|
||||
<FieldHelp schema="RatingInput" field="reviewerName">Name</FieldHelp>
|
||||
{t("detail.nameOptional")}
|
||||
<FieldHelp schema="RatingInput" field="reviewerName">{t("detail.nameLabel")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Anonymous" {...field} />
|
||||
<Input placeholder={t("detail.anonymousPlaceholder")} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -868,7 +868,7 @@ export default function ToolDetail() {
|
||||
onClick={() => setIsReviewFormOpen(false)}
|
||||
disabled={createRating.isPending}
|
||||
>
|
||||
Cancel
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={createRating.isPending}>
|
||||
{createRating.isPending ? t("common.loading") : t("detail.submit")}
|
||||
@@ -891,7 +891,7 @@ export default function ToolDetail() {
|
||||
<CardContent className="p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<span className="font-semibold">{rating.reviewerName || "Anonymous Engineer"}</span>
|
||||
<span className="font-semibold">{rating.reviewerName || t("detail.anonymousEngineer")}</span>
|
||||
<span className="text-muted-foreground text-sm ml-2">
|
||||
{format(new Date(rating.createdAt), "MMM d, yyyy")}
|
||||
</span>
|
||||
@@ -921,7 +921,7 @@ export default function ToolDetail() {
|
||||
<div className="text-center py-12 bg-muted/30 border border-dashed rounded-xl">
|
||||
<Star className="w-12 h-12 text-muted-foreground/30 mx-auto mb-3" />
|
||||
<h4 className="text-lg font-medium">{t("detail.noReviews")}</h4>
|
||||
<p className="text-muted-foreground mt-1">Be the first to share your thoughts on this tool.</p>
|
||||
<p className="text-muted-foreground mt-1">{t("detail.beFirstToReview")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -32,28 +32,34 @@ import { TagInput } from "@/components/tag-input";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { FieldHelp } from "@/components/field-help";
|
||||
import { GuideHelp } from "@/components/guide-help";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const toolSchema = z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
description: z.string().min(10, "Description must be at least 10 characters"),
|
||||
category: z.string().min(2, "Category is required"),
|
||||
websiteUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
|
||||
iconUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
|
||||
features: z.array(z.object({ value: z.string() })).optional(),
|
||||
tags: z.array(z.object({ value: z.string() })).optional(),
|
||||
});
|
||||
type ToolFormValues = z.infer<ReturnType<typeof buildToolSchema>>;
|
||||
|
||||
type ToolFormValues = z.infer<typeof toolSchema>;
|
||||
function buildToolSchema(t: (key: string) => string) {
|
||||
return z.object({
|
||||
name: z.string().min(2, t("toolForm.nameMin")),
|
||||
description: z.string().min(10, t("toolForm.descriptionMin")),
|
||||
category: z.string().min(2, t("toolForm.categoryRequired")),
|
||||
websiteUrl: z.string().url(t("toolForm.invalidUrl")).optional().or(z.literal("")),
|
||||
iconUrl: z.string().url(t("toolForm.invalidUrl")).optional().or(z.literal("")),
|
||||
features: z.array(z.object({ value: z.string() })).optional(),
|
||||
tags: z.array(z.object({ value: z.string() })).optional(),
|
||||
});
|
||||
}
|
||||
|
||||
export default function ToolEdit() {
|
||||
const [match, params] = useRoute("/tools/:id/edit");
|
||||
const [, setLocation] = useLocation();
|
||||
const id = parseInt(params?.id || "0", 10);
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const updateTool = useUpdateTool();
|
||||
const { isAuthenticated, isAdmin } = useAuth();
|
||||
|
||||
const toolSchema = buildToolSchema(t);
|
||||
|
||||
const { data: tool, isLoading } = useGetTool(id, {
|
||||
query: { enabled: !!id, queryKey: getGetToolQueryKey(id) },
|
||||
});
|
||||
@@ -108,7 +114,7 @@ export default function ToolEdit() {
|
||||
{ id, data: payload },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: "Tool updated", description: "Changes saved successfully." });
|
||||
toast({ title: t("toolForm.toastUpdated"), description: t("toolForm.toastUpdatedSub") });
|
||||
queryClient.invalidateQueries({ queryKey: getGetToolQueryKey(id) });
|
||||
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
||||
@@ -120,8 +126,8 @@ export default function ToolEdit() {
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: "Failed to update tool",
|
||||
description: err.data?.error || err.message || "An unexpected error occurred.",
|
||||
title: t("toolForm.toastUpdateFailed"),
|
||||
description: err.data?.error || err.message || t("detail.unexpectedError"),
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
@@ -138,13 +144,13 @@ export default function ToolEdit() {
|
||||
<div className="max-w-3xl mx-auto space-y-6 pb-10">
|
||||
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
|
||||
<Link href={`/tools/${id}`}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> Back to tool
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> {t("toolForm.backToTool")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Edit Tool</h1>
|
||||
<p className="text-muted-foreground">Update tool details and metadata.</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("toolForm.editTitle")}</h1>
|
||||
<p className="text-muted-foreground">{t("toolForm.editSubtitle")}</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -160,10 +166,10 @@ export default function ToolEdit() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Pencil className="w-5 h-5 text-primary" />
|
||||
Tool Details
|
||||
<GuideHelp guide="tool-bearbeiten" label="Tool Details" />
|
||||
{t("toolForm.toolDetails")}
|
||||
<GuideHelp guide="tool-bearbeiten" label={t("toolForm.toolDetails")} />
|
||||
</CardTitle>
|
||||
<CardDescription>Modify the tool information below.</CardDescription>
|
||||
<CardDescription>{t("toolForm.toolDetailsEditSub")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
@@ -175,11 +181,11 @@ export default function ToolEdit() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Name
|
||||
<FieldHelp schema="ToolInput" field="name">Name</FieldHelp>
|
||||
{t("toolForm.name")}
|
||||
<FieldHelp schema="ToolInput" field="name">{t("toolForm.name")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Tool name" {...field} />
|
||||
<Input placeholder={t("toolForm.name")} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -191,8 +197,8 @@ export default function ToolEdit() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Category
|
||||
<FieldHelp schema="ToolInput" field="category">Category</FieldHelp>
|
||||
{t("toolForm.category")}
|
||||
<FieldHelp schema="ToolInput" field="category">{t("toolForm.category")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<CategoryCombobox value={field.value} onChange={field.onChange} />
|
||||
@@ -209,11 +215,11 @@ export default function ToolEdit() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Website URL (Optional)
|
||||
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
|
||||
{t("toolForm.websiteUrlOptional")}
|
||||
<FieldHelp schema="ToolInput" field="websiteUrl">{t("toolForm.websiteUrl")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://..." type="url" {...field} />
|
||||
<Input placeholder={t("toolForm.urlPlaceholder")} type="url" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -226,8 +232,8 @@ export default function ToolEdit() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Icon / Logo URL (Optional)
|
||||
<FieldHelp schema="ToolInput" field="iconUrl">Icon / Logo URL</FieldHelp>
|
||||
{t("toolForm.iconUrlOptional")}
|
||||
<FieldHelp schema="ToolInput" field="iconUrl">{t("toolForm.iconUrl")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -235,16 +241,16 @@ export default function ToolEdit() {
|
||||
{field.value ? (
|
||||
<img
|
||||
src={field.value}
|
||||
alt="icon preview"
|
||||
alt={t("toolForm.iconPreview")}
|
||||
className="w-full h-full object-contain p-0.5"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">img</span>
|
||||
<span className="text-xs text-muted-foreground">{t("toolForm.name").charAt(0)}</span>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
placeholder="https://example.com/logo.png"
|
||||
placeholder={t("toolForm.logoPlaceholder")}
|
||||
type="url"
|
||||
{...field}
|
||||
className="flex-1"
|
||||
@@ -262,12 +268,12 @@ export default function ToolEdit() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Description
|
||||
<FieldHelp schema="ToolInput" field="description">Description</FieldHelp>
|
||||
{t("toolForm.description")}
|
||||
<FieldHelp schema="ToolInput" field="description">{t("toolForm.description")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="What does this tool do?"
|
||||
placeholder={t("toolForm.descriptionPlaceholderEdit")}
|
||||
className="min-h-[120px] resize-none"
|
||||
{...field}
|
||||
/>
|
||||
@@ -281,13 +287,13 @@ export default function ToolEdit() {
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||
Features
|
||||
<FieldHelp schema="ToolInput" field="features">Features</FieldHelp>
|
||||
{t("toolForm.features")}
|
||||
<FieldHelp schema="ToolInput" field="features">{t("toolForm.features")}</FieldHelp>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">Key capabilities of this tool. Existing features from other tools are selectable.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("toolForm.featuresEditSub")}</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => appendFeature({ value: "" })}>
|
||||
<Plus className="w-4 h-4 mr-2" /> Add Feature
|
||||
<Plus className="w-4 h-4 mr-2" /> {t("toolForm.addFeature")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
@@ -302,7 +308,7 @@ export default function ToolEdit() {
|
||||
<FeatureInput
|
||||
value={field.value ?? ""}
|
||||
onChange={field.onChange}
|
||||
placeholder="e.g. Real-time collaboration"
|
||||
placeholder={t("toolForm.featurePlaceholder")}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
@@ -319,7 +325,7 @@ export default function ToolEdit() {
|
||||
/>
|
||||
))}
|
||||
{featureFields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground italic">No features added.</p>
|
||||
<p className="text-sm text-muted-foreground italic">{t("toolForm.noFeatures")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -328,13 +334,13 @@ export default function ToolEdit() {
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||
Tags
|
||||
<FieldHelp schema="ToolInput" field="tags">Tags</FieldHelp>
|
||||
{t("toolForm.tags")}
|
||||
<FieldHelp schema="ToolInput" field="tags">{t("toolForm.tags")}</FieldHelp>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">Keywords for this tool. Existing tags from other tools are selectable.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("toolForm.tagsEditSub")}</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => appendTag({ value: "" })}>
|
||||
<Plus className="w-4 h-4 mr-2" /> Add Tag
|
||||
<Plus className="w-4 h-4 mr-2" /> {t("toolForm.addTag")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -347,7 +353,7 @@ export default function ToolEdit() {
|
||||
<FormItem className="flex items-center space-y-0 relative w-[200px]">
|
||||
<FormControl>
|
||||
<TagInput
|
||||
placeholder="Tag"
|
||||
placeholder={t("toolForm.tag")}
|
||||
className="pr-8 h-9 text-sm"
|
||||
onChange={field.onChange}
|
||||
value={field.value ?? ""}
|
||||
@@ -371,10 +377,10 @@ export default function ToolEdit() {
|
||||
|
||||
<div className="pt-6 border-t flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" asChild>
|
||||
<Link href={`/tools/${id}`}>Cancel</Link>
|
||||
<Link href={`/tools/${id}`}>{t("common.cancel")}</Link>
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateTool.isPending || !isAuthenticated}>
|
||||
{updateTool.isPending ? "Saving…" : "Save Changes"}
|
||||
{updateTool.isPending ? t("toolForm.saving") : t("toolForm.saveChanges")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -20,26 +20,32 @@ import { TagInput } from "@/components/tag-input";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { FieldHelp } from "@/components/field-help";
|
||||
import { GuideHelp } from "@/components/guide-help";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const toolSchema = z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
description: z.string().min(10, "Description must be at least 10 characters"),
|
||||
category: z.string().min(2, "Category is required"),
|
||||
websiteUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
|
||||
iconUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
|
||||
features: z.array(z.object({ value: z.string() })).optional(),
|
||||
tags: z.array(z.object({ value: z.string() })).optional(),
|
||||
});
|
||||
type ToolFormValues = z.infer<ReturnType<typeof buildToolSchema>>;
|
||||
|
||||
type ToolFormValues = z.infer<typeof toolSchema>;
|
||||
function buildToolSchema(t: (key: string) => string) {
|
||||
return z.object({
|
||||
name: z.string().min(2, t("toolForm.nameMin")),
|
||||
description: z.string().min(10, t("toolForm.descriptionMin")),
|
||||
category: z.string().min(2, t("toolForm.categoryRequired")),
|
||||
websiteUrl: z.string().url(t("toolForm.invalidUrl")).optional().or(z.literal("")),
|
||||
iconUrl: z.string().url(t("toolForm.invalidUrl")).optional().or(z.literal("")),
|
||||
features: z.array(z.object({ value: z.string() })).optional(),
|
||||
tags: z.array(z.object({ value: z.string() })).optional(),
|
||||
});
|
||||
}
|
||||
|
||||
export default function ToolNew() {
|
||||
const [location, setLocation] = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const createTool = useCreateTool();
|
||||
const { isAuthenticated, isLoading: authLoading, login } = useAuth();
|
||||
|
||||
const toolSchema = buildToolSchema(t);
|
||||
|
||||
const form = useForm<ToolFormValues>({
|
||||
resolver: zodResolver(toolSchema),
|
||||
defaultValues: {
|
||||
@@ -76,7 +82,7 @@ export default function ToolNew() {
|
||||
{ data: payload },
|
||||
{
|
||||
onSuccess: (newTool) => {
|
||||
toast({ title: "Tool added successfully", description: "Your tool is now available for review." });
|
||||
toast({ title: t("toolForm.toastAdded"), description: t("toolForm.toastAddedSub") });
|
||||
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
|
||||
@@ -86,7 +92,7 @@ export default function ToolNew() {
|
||||
setLocation(`/tools/${newTool.id}`);
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Failed to add tool", description: "An unexpected error occurred.", variant: "destructive" });
|
||||
toast({ title: t("toolForm.toastAddFailed"), description: t("detail.unexpectedError"), variant: "destructive" });
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -97,24 +103,24 @@ export default function ToolNew() {
|
||||
<div className="max-w-3xl mx-auto space-y-6 pb-10">
|
||||
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
|
||||
<Link href="/tools">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> Back to browse
|
||||
<ArrowLeft className="w-4 h-4 mr-2" /> {t("toolForm.backToBrowse")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Add a New Tool</h1>
|
||||
<p className="text-muted-foreground">Submit a tool you use to let the community rate and review it.</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("toolForm.addTitle")}</h1>
|
||||
<p className="text-muted-foreground">{t("toolForm.addSubtitle")}</p>
|
||||
</div>
|
||||
|
||||
{!authLoading && !isAuthenticated && (
|
||||
<div className="flex items-center gap-4 rounded-md border border-primary/20 bg-primary/5 px-4 py-3">
|
||||
<LogIn className="w-5 h-5 text-primary shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Sign in required</p>
|
||||
<p className="text-xs text-muted-foreground">You must be signed in to submit a tool.</p>
|
||||
<p className="text-sm font-medium">{t("toolForm.signInRequired")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("toolForm.signInRequiredSub")}</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => login(location)} data-testid="button-login-prompt">
|
||||
Sign in
|
||||
{t("auth.signIn")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -123,10 +129,10 @@ export default function ToolNew() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Wrench className="w-5 h-5 text-primary" />
|
||||
Tool Details
|
||||
<GuideHelp guide="tool-anlegen" label="Tool Details" />
|
||||
{t("toolForm.toolDetails")}
|
||||
<GuideHelp guide="tool-anlegen" label={t("toolForm.toolDetails")} />
|
||||
</CardTitle>
|
||||
<CardDescription>Provide the basic information about the tool.</CardDescription>
|
||||
<CardDescription>{t("toolForm.toolDetailsNewSub")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
@@ -138,11 +144,11 @@ export default function ToolNew() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Name
|
||||
<FieldHelp schema="ToolInput" field="name">Name</FieldHelp>
|
||||
{t("toolForm.name")}
|
||||
<FieldHelp schema="ToolInput" field="name">{t("toolForm.name")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. React, Next.js, Postgres" {...field} data-testid="input-tool-name" />
|
||||
<Input placeholder={t("toolForm.namePlaceholder")} {...field} data-testid="input-tool-name" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -155,8 +161,8 @@ export default function ToolNew() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Category
|
||||
<FieldHelp schema="ToolInput" field="category">Category</FieldHelp>
|
||||
{t("toolForm.category")}
|
||||
<FieldHelp schema="ToolInput" field="category">{t("toolForm.category")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<CategoryCombobox
|
||||
@@ -176,11 +182,11 @@ export default function ToolNew() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Website URL (Optional)
|
||||
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
|
||||
{t("toolForm.websiteUrlOptional")}
|
||||
<FieldHelp schema="ToolInput" field="websiteUrl">{t("toolForm.websiteUrl")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://..." type="url" {...field} data-testid="input-tool-url" />
|
||||
<Input placeholder={t("toolForm.urlPlaceholder")} type="url" {...field} data-testid="input-tool-url" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -193,8 +199,8 @@ export default function ToolNew() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Icon / Logo URL (Optional)
|
||||
<FieldHelp schema="ToolInput" field="iconUrl">Icon / Logo URL</FieldHelp>
|
||||
{t("toolForm.iconUrlOptional")}
|
||||
<FieldHelp schema="ToolInput" field="iconUrl">{t("toolForm.iconUrl")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -202,16 +208,16 @@ export default function ToolNew() {
|
||||
{field.value ? (
|
||||
<img
|
||||
src={field.value}
|
||||
alt="icon preview"
|
||||
alt={t("toolForm.iconPreview")}
|
||||
className="w-full h-full object-contain p-0.5"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">img</span>
|
||||
<span className="text-xs text-muted-foreground">{t("toolForm.name").charAt(0)}</span>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
placeholder="https://example.com/logo.png"
|
||||
placeholder={t("toolForm.logoPlaceholder")}
|
||||
type="url"
|
||||
{...field}
|
||||
data-testid="input-tool-icon-url"
|
||||
@@ -230,12 +236,12 @@ export default function ToolNew() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="inline-flex items-center gap-1.5">
|
||||
Description
|
||||
<FieldHelp schema="ToolInput" field="description">Description</FieldHelp>
|
||||
{t("toolForm.description")}
|
||||
<FieldHelp schema="ToolInput" field="description">{t("toolForm.description")}</FieldHelp>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="What does this tool do? Why do people use it?"
|
||||
placeholder={t("toolForm.descriptionPlaceholderNew")}
|
||||
className="min-h-[120px] resize-none"
|
||||
{...field}
|
||||
data-testid="input-tool-description"
|
||||
@@ -250,10 +256,10 @@ export default function ToolNew() {
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||
Features
|
||||
<FieldHelp schema="ToolInput" field="features">Features</FieldHelp>
|
||||
{t("toolForm.features")}
|
||||
<FieldHelp schema="ToolInput" field="features">{t("toolForm.features")}</FieldHelp>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">List key capabilities. Existing features from other tools are selectable.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("toolForm.featuresNewSub")}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -262,7 +268,7 @@ export default function ToolNew() {
|
||||
onClick={() => appendFeature({ value: "" })}
|
||||
data-testid="button-add-feature"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Add Feature
|
||||
<Plus className="w-4 h-4 mr-2" /> {t("toolForm.addFeature")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -278,7 +284,7 @@ export default function ToolNew() {
|
||||
<FeatureInput
|
||||
value={field.value ?? ""}
|
||||
onChange={field.onChange}
|
||||
placeholder="e.g. Real-time collaboration"
|
||||
placeholder={t("toolForm.featurePlaceholder")}
|
||||
data-testid={`input-feature-${index}`}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -297,7 +303,7 @@ export default function ToolNew() {
|
||||
/>
|
||||
))}
|
||||
{featureFields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground italic">No features added.</p>
|
||||
<p className="text-sm text-muted-foreground italic">{t("toolForm.noFeatures")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -306,10 +312,10 @@ export default function ToolNew() {
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||
Tags
|
||||
<FieldHelp schema="ToolInput" field="tags">Tags</FieldHelp>
|
||||
{t("toolForm.tags")}
|
||||
<FieldHelp schema="ToolInput" field="tags">{t("toolForm.tags")}</FieldHelp>
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">Keywords to help find this tool. Existing tags from other tools are selectable.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("toolForm.tagsHelp")}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -318,7 +324,7 @@ export default function ToolNew() {
|
||||
onClick={() => appendTag({ value: "" })}
|
||||
data-testid="button-add-tag"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> Add Tag
|
||||
<Plus className="w-4 h-4 mr-2" /> {t("toolForm.addTag")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -332,7 +338,7 @@ export default function ToolNew() {
|
||||
<FormItem className="flex items-center space-y-0 relative w-[200px]">
|
||||
<FormControl>
|
||||
<TagInput
|
||||
placeholder="Tag"
|
||||
placeholder={t("toolForm.tagPlaceholder")}
|
||||
className="pr-8 h-9 text-sm"
|
||||
onChange={field.onChange}
|
||||
value={field.value ?? ""}
|
||||
|
||||
@@ -362,7 +362,7 @@ export default function ToolsBrowse() {
|
||||
<button
|
||||
onClick={() => setFeatures(features.filter((x) => x !== f))}
|
||||
className="rounded-sm hover:bg-muted p-0.5"
|
||||
aria-label={`Remove feature ${f}`}
|
||||
aria-label={t("filter.removeFeature", { feature: f })}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -374,14 +374,14 @@ export default function ToolsBrowse() {
|
||||
<button
|
||||
onClick={() => setMinRating(null)}
|
||||
className="rounded-sm hover:bg-muted p-0.5"
|
||||
aria-label="Remove min rating"
|
||||
aria-label={t("filter.removeMinRating")}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
<Button variant="link" size="sm" className="px-1 text-muted-foreground" onClick={clearFilters}>
|
||||
Clear all
|
||||
{t("common.clearAll")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -521,15 +521,15 @@ export default function ToolsBrowse() {
|
||||
<AlertDialog open={compareUpsellOpen} onOpenChange={setCompareUpsellOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Compare is a Premium feature</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("browse.comparePremiumTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Comparing tools side-by-side is available to Premium and Enterprise users. Upgrade your plan to unlock it.
|
||||
{t("browse.comparePremiumSub")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Not now</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t("browse.notNow")}</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<a href="/admin?tab=plan">Upgrade to Premium</a>
|
||||
<a href="/admin?tab=plan">{t("browse.upgradePremium")}</a>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
getListAllTagsQueryKey,
|
||||
getGetTopToolsQueryKey,
|
||||
getGetAnalyticsSummaryQueryKey,
|
||||
type Tool,
|
||||
} from "@workspace/api-client-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -91,12 +90,12 @@ export default function Trash() {
|
||||
{ data: { ids } },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
toast({ title: "Tools restored", description: `${res.restored ?? ids.length} tool(s) restored.` });
|
||||
toast({ title: t("trash.toastRestored"), description: t("trash.toastRestoredSub", { count: res.restored ?? ids.length }) });
|
||||
setSelected(new Set());
|
||||
invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({ title: "Failed to restore", description: err.data?.error ?? err.message, variant: "destructive" });
|
||||
toast({ title: t("trash.toastRestoreFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -107,13 +106,13 @@ export default function Trash() {
|
||||
{ data: { ids } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: "Tools deleted", description: `${ids.length} tool(s) permanently removed.` });
|
||||
toast({ title: t("trash.toastDeleted"), description: t("trash.toastDeletedSub", { count: ids.length }) });
|
||||
setSelected(new Set());
|
||||
setConfirmDelete(false);
|
||||
invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({ title: "Failed to delete", description: err.data?.error ?? err.message, variant: "destructive" });
|
||||
toast({ title: t("trash.toastDeleteFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -124,13 +123,13 @@ export default function Trash() {
|
||||
undefined,
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
toast({ title: "Trash emptied", description: `${res.deleted ?? 0} tool(s) permanently removed.` });
|
||||
toast({ title: t("trash.toastEmptied"), description: t("trash.toastEmptiedSub", { count: res.deleted ?? 0 }) });
|
||||
setSelected(new Set());
|
||||
setConfirmEmpty(false);
|
||||
invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({ title: "Failed to empty trash", description: err.data?.error ?? err.message, variant: "destructive" });
|
||||
toast({ title: t("trash.toastEmptyFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -227,48 +226,48 @@ export default function Trash() {
|
||||
<Checkbox
|
||||
checked={selected.size === trashed.length && trashed.length > 0}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label="Select all"
|
||||
aria-label={t("trash.selectAll")}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>{t("trash.name")}</TableHead>
|
||||
<TableHead>{t("trash.category")}</TableHead>
|
||||
<TableHead>{t("trash.deletedAt")}</TableHead>
|
||||
<TableHead>{t("trash.deletedBy")}</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
<TableHead className="text-right">{t("trash.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{trashed.map((t: Tool) => (
|
||||
<TableRow key={t.id} className={selected.has(t.id) ? "bg-muted/40" : undefined}>
|
||||
{trashed.map((tool) => (
|
||||
<TableRow key={tool.id} className={selected.has(tool.id) ? "bg-muted/40" : undefined}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selected.has(t.id)}
|
||||
onCheckedChange={() => toggle(t.id)}
|
||||
aria-label={`Select ${t.name}`}
|
||||
checked={selected.has(tool.id)}
|
||||
onCheckedChange={() => toggle(tool.id)}
|
||||
aria-label={t("trash.selectName", { name: tool.name })}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{t.name}</TableCell>
|
||||
<TableCell className="font-medium">{tool.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{t.category}</Badge>
|
||||
<Badge variant="outline">{tool.category}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{t.deletedAt ? format(new Date(t.deletedAt), "dd.MM.yyyy HH:mm") : "—"}
|
||||
{tool.deletedAt ? format(new Date(tool.deletedAt), "dd.MM.yyyy HH:mm") : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{t.deletedBy ?? "—"}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{tool.deletedBy ?? "—"}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleRestore([t.id])} disabled={restore.isPending}>
|
||||
<RotateCcw className="w-3.5 h-3.5" /> Restore
|
||||
<Button variant="ghost" size="sm" onClick={() => handleRestore([tool.id])} disabled={restore.isPending}>
|
||||
<RotateCcw className="w-3.5 h-3.5" /> {t("trash.restoreAction")}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => handleDeletePermanent([t.id])}
|
||||
onClick={() => handleDeletePermanent([tool.id])}
|
||||
disabled={deletePermanent.isPending}
|
||||
>
|
||||
<TrashIcon className="w-3.5 h-3.5" /> Delete
|
||||
<TrashIcon className="w-3.5 h-3.5" /> {t("trash.deleteAction")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -285,13 +284,13 @@ export default function Trash() {
|
||||
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete {selectedIds.length} tool(s) permanently?</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("trash.deleteConfirmTitle", { count: selectedIds.length })}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This permanently removes the selected tools along with all their ratings, costs and relations. This cannot be undone.
|
||||
{t("trash.deleteConfirmSub")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => handleDeletePermanent(selectedIds)}
|
||||
@@ -305,13 +304,13 @@ export default function Trash() {
|
||||
<AlertDialog open={confirmEmpty} onOpenChange={setConfirmEmpty}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Empty the trash?</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("trash.emptyConfirmTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This permanently removes all {trashed.length} tool(s) in the trash along with their ratings, costs and relations. This cannot be undone.
|
||||
{t("trash.emptyConfirmSub", { count: trashed.length })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={handleEmpty}
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function Watchlist() {
|
||||
{t("watchlist.emptySub")}
|
||||
</p>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="/tools">Browse tools</a>
|
||||
<a href="/tools">{t("common.browseTools")}</a>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user