diff --git a/artifacts/toolrate/src/components/admin-tools-tab.tsx b/artifacts/toolrate/src/components/admin-tools-tab.tsx index 19217bb..af0badb 100644 --- a/artifacts/toolrate/src/components/admin-tools-tab.tsx +++ b/artifacts/toolrate/src/components/admin-tools-tab.tsx @@ -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

Admin access required.

; + return

{t("adminTools.accessRequired")}

; } const selectedIds = [...selected]; @@ -104,9 +105,9 @@ export function AdminToolsTab() {
- All Tools + {t("adminTools.allTools")} - {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 })}
@@ -114,7 +115,7 @@ export function AdminToolsTab() { setSearchInput(e.target.value)} /> @@ -125,7 +126,7 @@ export function AdminToolsTab() { disabled={selectedIds.length === 0 || trash.isPending} onClick={() => setConfirmTrash(true)} > - Move to trash ({selectedIds.length}) + {t("adminTools.moveToTrash", { count: selectedIds.length })}
@@ -135,7 +136,7 @@ export function AdminToolsTab() { {[1, 2, 3].map((i) => )} ) : allTools.length === 0 ? ( -

{search ? "No tools match your search." : "No tools yet."}

+

{search ? t("adminTools.noMatch") : t("adminTools.noToolsYet")}

) : ( @@ -144,58 +145,58 @@ export function AdminToolsTab() { 0} onCheckedChange={toggleAll} - aria-label="Select all" + aria-label={t("adminTools.selectAll")} /> - Name - Category - Rating - Created by - Created at - Actions + {t("adminTools.name")} + {t("adminTools.category")} + {t("adminTools.rating")} + {t("adminTools.createdBy")} + {t("adminTools.createdAt")} + {t("adminTools.actions")} - {allTools.map((t: ToolWithStats) => ( - + {allTools.map((tool) => ( + 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 })} /> - {t.name} + {tool.name} - {t.category} + {tool.category} - {t.ratingCount > 0 ? ( + {tool.ratingCount > 0 ? ( - {t.avgCombined != null ? t.avgCombined.toFixed(1) : "—"} ({t.ratingCount}) + {tool.avgCombined != null ? tool.avgCombined.toFixed(1) : "—"} ({tool.ratingCount}) ) : ( "—" )} - {t.createdBy ?? "—"} + {tool.createdBy ?? "—"} - {format(new Date(t.createdAt), "dd.MM.yyyy")} + {format(new Date(tool.createdAt), "dd.MM.yyyy")}
@@ -74,7 +78,7 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ { setInputValue(v); @@ -84,10 +88,10 @@ export function CategoryCombobox({ value, onChange, placeholder = "Select or typ /> {filtered.length === 0 && !showCreateOption && ( - No categories found. + {t("category.noCategories")} )} {filtered.length > 0 && ( - + {filtered.map((cat) => ( + select(inputValue.trim())} data-testid="item-category-create-new" > - + Create + {t("category.create")} “{inputValue.trim()}” diff --git a/artifacts/toolrate/src/components/theme-toggle.tsx b/artifacts/toolrate/src/components/theme-toggle.tsx index 2b14045..db1b471 100644 --- a/artifacts/toolrate/src/components/theme-toggle.tsx +++ b/artifacts/toolrate/src/components/theme-toggle.tsx @@ -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 ? : } @@ -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" > - Theme + {t("theme.title")} setTheme(v as Theme)}> - Light + {t("theme.light")} - Dark + {t("theme.dark")} - System + {t("theme.system")} diff --git a/artifacts/toolrate/src/components/tool-preview-card.tsx b/artifacts/toolrate/src/components/tool-preview-card.tsx index 1b4db18..a15d1d8 100644 --- a/artifacts/toolrate/src/components/tool-preview-card.tsx +++ b/artifacts/toolrate/src/components/tool-preview-card.tsx @@ -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 (
@@ -22,7 +24,7 @@ export function ToolPreviewCard({ tool }: { tool: ToolWithStats }) {
- {tool.ratingCount} review{tool.ratingCount === 1 ? "" : "s"} + {t("browse.reviewCount", { count: tool.ratingCount })} {tool.avgCombined != null ? tool.avgCombined.toFixed(1) : "–"}/5 @@ -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 - -
+ {t("common.viewDetails")} +
); } diff --git a/artifacts/toolrate/src/i18n/locales/de.json b/artifacts/toolrate/src/i18n/locales/de.json index 2c3ddb4..827949f 100644 --- a/artifacts/toolrate/src/i18n/locales/de.json +++ b/artifacts/toolrate/src/i18n/locales/de.json @@ -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" } -} \ No newline at end of file +} diff --git a/artifacts/toolrate/src/i18n/locales/en.json b/artifacts/toolrate/src/i18n/locales/en.json index daff754..8c6e2a3 100644 --- a/artifacts/toolrate/src/i18n/locales/en.json +++ b/artifacts/toolrate/src/i18n/locales/en.json @@ -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" } -} \ No newline at end of file +} diff --git a/artifacts/toolrate/src/pages/admin.tsx b/artifacts/toolrate/src/pages/admin.tsx index 315d8cc..7528a42 100644 --- a/artifacts/toolrate/src/pages/admin.tsx +++ b/artifacts/toolrate/src/pages/admin.tsx @@ -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() {
-

Admin Access Required

-

You need admin rights to view this page.

- +

{t("admin.accessRequired")}

+

{t("admin.accessRequiredSub")}

+
); @@ -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() {
-

Admin Panel

-

Manage users and review system changes.

+

{t("admin.panel")}

+

{t("admin.panelSub")}

@@ -175,16 +177,16 @@ export default function Admin() { - Users + {t("admin.tabUsers")} - Tools + {t("admin.tabTools")} - Audit Log + {t("admin.tabAudit")} - System + {t("admin.tabSystem")} @@ -192,11 +194,11 @@ export default function Admin() {
- Local Users - Manage accounts for local authentication. + {t("admin.localUsers")} + {t("admin.localUsersSub")}
@@ -248,7 +250,7 @@ export default function Admin() {
))} {(!users || users.length === 0) && ( -

No users yet.

+

{t("admin.noUsersYet")}

)}
)} @@ -263,8 +265,8 @@ export default function Admin() { - Audit Log - All create, update and delete operations tracked by the system. + {t("admin.auditLog")} + {t("admin.auditLogSub")} {loadingLogs ? ( @@ -289,8 +291,7 @@ export default function Admin() {
- by {log.username} - {log.changes && ( + {t("admin.by")} {log.username} {log.changes && ( {log.changes.length > 120 ? log.changes.slice(0, 120) + "…" : log.changes} @@ -299,7 +300,7 @@ export default function Admin() {
))} {(!auditLogs || auditLogs.length === 0) && ( -

No audit entries yet.

+

{t("admin.noAuditEntries")}

)} )} @@ -310,17 +311,17 @@ export default function Admin() { - System - Build information of the currently live deployment. + {t("admin.system")} + {t("admin.systemSub")}
- Version + {t("admin.version")} {versionInfo?.version || "dev"}
@@ -359,51 +360,51 @@ export default function Admin() { - Create New User + {t("admin.createNewUser")}
- - setNewUsername(e.target.value)} placeholder="username" /> + + setNewUsername(e.target.value)} placeholder={t("admin.usernamePlaceholder")} />
- - setNewPassword(e.target.value)} placeholder="min. 6 characters" /> + + setNewPassword(e.target.value)} placeholder={t("admin.minPasswordPlaceholder")} />
- - setNewEmail(e.target.value)} placeholder="user@example.com" /> + + setNewEmail(e.target.value)} placeholder={t("admin.emailPlaceholder")} />
- +
- +
- +
@@ -412,11 +413,11 @@ export default function Admin() { { if (!open) { setEditUser(null); setEditPassword(""); } }}> - Edit User — {editUser?.username} + {t("admin.editUser", { username: editUser?.username })}
- +
- +
{editUser?.authProvider !== "oidc" ? (
- + setEditPassword(e.target.value)} - placeholder="min. 6 characters" + placeholder={t("admin.minPasswordPlaceholder")} data-testid="input-set-password" /> -

Resets the user's password immediately.

+

{t("admin.resetsPassword")}

) : (
- Password is managed by the identity provider (Keycloak). Reset it there. + {t("admin.idpManaged")}
)}
- + {editUser?.authProvider !== "oidc" && ( )} @@ -487,15 +488,15 @@ export default function Admin() { !open && setDeleteConfirm(null)}> - Delete User + {t("admin.deleteUser")}

- Are you sure you want to delete {deleteConfirm?.username}? This cannot be undone. + {t("admin.deleteUserConfirm", { username: deleteConfirm?.username })}

- +
diff --git a/artifacts/toolrate/src/pages/analytics.tsx b/artifacts/toolrate/src/pages/analytics.tsx index 884de54..0382c26 100644 --- a/artifacts/toolrate/src/pages/analytics.tsx +++ b/artifacts/toolrate/src/pages/analytics.tsx @@ -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() {

- Platform Analytics - + {t("analytics.title")} +

-

Macro-level insights into tool performance and community engagement.

+

{t("analytics.subtitle")}

{/* Top KPI Cards */} @@ -61,7 +63,7 @@ export default function Analytics() {
- Total Tools Indexed + {t("analytics.totalToolsIndexed")}
{loadingSummary ? : ( @@ -73,7 +75,7 @@ export default function Analytics() {
- Total Ratings Cast + {t("analytics.totalRatingsCast")}
{loadingSummary ? : ( @@ -85,7 +87,7 @@ export default function Analytics() {
- Active Categories + {t("analytics.activeCategories")}
{loadingSummary ? : ( @@ -97,7 +99,7 @@ export default function Analytics() {
- Avg Global Score + {t("analytics.avgGlobalScore")}
{loadingSummary ? : ( @@ -112,8 +114,8 @@ export default function Analytics() { {/* Top Tools Chart */} - Top 8 Tools by Combined Score - Highest rated tools across the platform + {t("analytics.top8Tools")} + {t("analytics.top8ToolsSub")} {loadingTopTools ? ( @@ -140,8 +142,8 @@ export default function Analytics() { {/* Category Breakdown */} - Tools by Category - Distribution of tools across categories + {t("analytics.toolsByCategory")} + {t("analytics.toolsByCategorySub")} {loadingCategories ? ( @@ -153,7 +155,7 @@ export default function Analytics() { - + @@ -165,8 +167,8 @@ export default function Analytics() { {/* Rating Distributions */} - Global Rating Distribution - How users are voting across all tools + {t("analytics.globalRatingDistribution")} + {t("analytics.globalRatingDistributionSub")} {loadingDistribution ? ( @@ -174,7 +176,7 @@ export default function Analytics() { ) : (
- Usefulness + {t("detail.usefulness")}
@@ -188,7 +190,7 @@ export default function Analytics() {
- Usability + {t("detail.usability")}
diff --git a/artifacts/toolrate/src/pages/compare.tsx b/artifacts/toolrate/src/pages/compare.tsx index 36f671c..5b69307 100644 --- a/artifacts/toolrate/src/pages/compare.tsx +++ b/artifacts/toolrate/src/pages/compare.tsx @@ -145,7 +145,7 @@ export default function Compare() { ))} - Usefulness + {t("detail.usefulness")} {list.map((t) => ( {fmt(t.avgUsefulness)}/5 @@ -153,7 +153,7 @@ export default function Compare() { ))} - Usability + {t("detail.usability")} {list.map((t) => ( {fmt(t.avgUsability)}/5 @@ -177,7 +177,7 @@ export default function Compare() { ))} - Features + {t("filter.features")} {list.map((t) => (
@@ -190,7 +190,7 @@ export default function Compare() { ))} - Tags + {t("filter.tags")} {list.map((t) => (
diff --git a/artifacts/toolrate/src/pages/docs.tsx b/artifacts/toolrate/src/pages/docs.tsx index 4c622e2..1f446d1 100644 --- a/artifacts/toolrate/src/pages/docs.tsx +++ b/artifacts/toolrate/src/pages/docs.tsx @@ -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[] }) {
{f.required ? ( - required + {t("docs.required")} ) : ( )} @@ -611,8 +636,8 @@ function EndpointTagView({ tag }: { tag: TagGroup }) { - - + + @@ -625,7 +650,7 @@ function EndpointTagView({ tag }: { tag: TagGroup }) {
NameIn{t("docs.name")}{t("docs.in")} {t("docs.type")} {t("docs.required")} {t("docs.description")}{p.in} - {p.required ? req : "–"} + {p.required ? {t("docs.required")} : "–"} {p.description} @@ -642,7 +667,7 @@ function EndpointTagView({ tag }: { tag: TagGroup }) { {ep.requestBody && (

- Request Body {ep.requestBody.required && required} + {t("docs.requestBody")} {ep.requestBody.required && {t("docs.required")}}

@@ -679,6 +704,7 @@ function EndpointTagView({ tag }: { tag: TagGroup }) { function ReleasesView({ versions }: { versions: ReleaseDoc[] }) { const [, setLocation] = useLocation(); const { t } = useTranslation(); + const locale = useDocsLocale(); return (
@@ -697,7 +723,7 @@ function ReleasesView({ versions }: { versions: ReleaseDoc[] }) {
- {v.title} + {localizedTitle(v.title, v.titleEn, locale)} {v.version}
{v.date && ( @@ -707,7 +733,7 @@ function ReleasesView({ versions }: { versions: ReleaseDoc[] }) { )}
- {v.hasReference && API-Referenz} + {v.hasReference && {t("docs.reference")}} ))}
@@ -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 (
@@ -733,16 +761,20 @@ function ReleaseNoteView({ version }: { version: string }) {
- +
); } -function HandbookView({ slug }: { slug: string }) { +function HandbookView({ slug, pages }: { slug: string; pages: HandbookPage[] | null }) { const version = useDocsVersion(); - const handbookFile = `${slug}.md`; - return ; + 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 ; } // --------------------------------------------------------------------------- @@ -750,7 +782,9 @@ function HandbookView({ slug }: { slug: string }) { // --------------------------------------------------------------------------- function useDocsSearch(query: string) { - const { data, error } = useJson(query ? `${DOCS_BASE}/search.json` : null); + const locale = useDocsLocale(); + const indexFile = locale === "en" ? "search.en.json" : "search.json"; + const { data, error } = useJson(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 ? ( - + ) : ( - + ); } else if (section === "home") { content = handbook && handbook.length > 0 ? ( - + ) : releases && releases.length > 0 ? ( ) : (

{t("docs.noDocs")}

); } else if (section === "handbook" && param) { - content = ; + content = ; } 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() { ); } else if (section === "releases" && param) { - content = ; + content = r.version === param) ?? null} />; } else if (section === "releases") { content = releases ? : ; } else { @@ -1014,6 +1048,7 @@ export default function Docs() {
+
diff --git a/artifacts/toolrate/src/pages/redundancy.tsx b/artifacts/toolrate/src/pages/redundancy.tsx index 5beb3ef..14b067a 100644 --- a/artifacts/toolrate/src/pages/redundancy.tsx +++ b/artifacts/toolrate/src/pages/redundancy.tsx @@ -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(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("/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() {
-

Tool Analysis & Recommendations

+

{t("redundancy.title")}

- Automatic redundancy detection with cost and rating comparison. Admin can manually confirm which tool is the better choice. + {t("redundancy.subtitle")}

{loading ? ( @@ -61,12 +63,12 @@ export default function RedundancyPage() {

{group.category}

-

{group.tools.length} tools, {group.pairs.length} comparisons

+

{t("redundancy.toolsComparisons", { tools: group.tools.length, pairs: group.pairs.length })}

{group.totalMonthlyCost > 0 && ( - {group.totalMonthlyCost.toFixed(2)}/mo total + {group.totalMonthlyCost.toFixed(2)}{t("redundancy.totalMonthly")} )}
@@ -82,12 +84,12 @@ export default function RedundancyPage() { {tool.name} {tool.costs?.length > 0 && tool.totalMonthly > 0 && ( - {tool.totalMonthly.toFixed(2)}/mo + {tool.totalMonthly.toFixed(2)}{t("redundancy.perMonth")} )}
- {tool.ratingCount} reviews + {tool.ratingCount} {t("redundancy.reviews")} {tool.avgCombined != null && ( <> · @@ -104,7 +106,7 @@ export default function RedundancyPage() { ))}
- {tool.features.length} features + {tool.features.length} {t("redundancy.features")} @@ -114,7 +116,7 @@ export default function RedundancyPage() { {group.pairs.length > 0 && (
-

Comparisons & Recommendations

+

{t("redundancy.comparisonsTitle")}

{group.pairs.map((pair: any, i: number) => ( @@ -126,11 +128,11 @@ export default function RedundancyPage() { {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")}` : ""}
-
vs
+
{t("redundancy.vs")}
{pair.overlap}% @@ -142,7 +144,7 @@ export default function RedundancyPage() { {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")}` : ""}
@@ -188,7 +190,7 @@ export default function RedundancyPage() { ))} ) : ( -

No tools found.

+

{t("redundancy.noTools")}

)} diff --git a/artifacts/toolrate/src/pages/tool-detail.tsx b/artifacts/toolrate/src/pages/tool-detail.tsx index f3448b5..6567d1d 100644 --- a/artifacts/toolrate/src/pages/tool-detail.tsx +++ b/artifacts/toolrate/src/pages/tool-detail.tsx @@ -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(`/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(`/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(`/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(`/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 (
-

Invalid Tool ID

+

{t("detail.invalidToolId")}

@@ -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() {
{/* Header Section */} @@ -408,7 +408,7 @@ export default function ToolDetail() { {tool.features && tool.features.length > 0 && (
-

Key Features

+

{t("detail.keyFeatures")}

    {tool.features.map((feature, i) => (
  • @@ -423,17 +423,17 @@ export default function ToolDetail() { )}
) : ( -
Tool not found.
+
{t("detail.toolNotFound")}
)} {/* Similar Tools Section */} {tool && (
-

Similar Tools

+

{t("detail.similarTools")}

{isAdmin && ( )}
@@ -501,7 +501,7 @@ export default function ToolDetail() { )} · - Score: {item.score} + {t("detail.score")}: {item.score}
@@ -510,7 +510,7 @@ export default function ToolDetail() { ))} ) : similarData && similarData.manual.length === 0 && similarData.auto.length === 0 ? ( -

No similar tools found.

+

{t("detail.noSimilarTools")}

) : null} )} @@ -519,44 +519,44 @@ export default function ToolDetail() { - Link Similar Tool - Manually link this tool to another tool. + {t("detail.linkSimilarTool")} + {t("detail.linkSimilarToolSub")}
- + setLinkToolId(e.target.value)} />
- +
- +