From 2f38889726a4e965d3fa4d7a0072146267c4541c Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 2 Aug 2026 13:42:53 +0200 Subject: [PATCH] fix: compact filter popover; feat: i18n (de/en) --- artifacts/toolrate/package.json | 2 + artifacts/toolrate/src/App.tsx | 20 ++- .../src/components/command-palette.tsx | 16 +- .../toolrate/src/components/compare-bar.tsx | 10 +- .../src/components/filter-popover.tsx | 25 +-- .../src/components/language-switcher.tsx | 44 +++++ artifacts/toolrate/src/components/layout.tsx | 41 +++-- artifacts/toolrate/src/i18n/index.ts | 37 ++++ artifacts/toolrate/src/i18n/locales/de.json | 168 ++++++++++++++++++ artifacts/toolrate/src/i18n/locales/en.json | 168 ++++++++++++++++++ artifacts/toolrate/src/pages/compare.tsx | 34 ++-- artifacts/toolrate/src/pages/home.tsx | 36 ++-- artifacts/toolrate/src/pages/login.tsx | 18 +- artifacts/toolrate/src/pages/not-found.tsx | 6 +- artifacts/toolrate/src/pages/tool-detail.tsx | 48 ++--- artifacts/toolrate/src/pages/tools-browse.tsx | 49 ++--- artifacts/toolrate/src/pages/trash.tsx | 34 ++-- artifacts/toolrate/src/pages/watchlist.tsx | 16 +- pnpm-lock.yaml | 50 ++++++ 19 files changed, 662 insertions(+), 160 deletions(-) create mode 100644 artifacts/toolrate/src/components/language-switcher.tsx create mode 100644 artifacts/toolrate/src/i18n/index.ts create mode 100644 artifacts/toolrate/src/i18n/locales/de.json create mode 100644 artifacts/toolrate/src/i18n/locales/en.json diff --git a/artifacts/toolrate/package.json b/artifacts/toolrate/package.json index 33c9bdf..90045d3 100644 --- a/artifacts/toolrate/package.json +++ b/artifacts/toolrate/package.json @@ -56,6 +56,7 @@ "date-fns": "^3.6.0", "embla-carousel-react": "^8.6.0", "framer-motion": "catalog:", + "i18next": "^26.3.6", "input-otp": "^1.4.2", "lucide-react": "catalog:", "next-themes": "^0.4.6", @@ -63,6 +64,7 @@ "react-day-picker": "^9.11.1", "react-dom": "catalog:", "react-hook-form": "^7.55.0", + "react-i18next": "^17.0.11", "react-icons": "^5.4.0", "react-resizable-panels": "^2.1.7", "recharts": "^2.15.2", diff --git a/artifacts/toolrate/src/App.tsx b/artifacts/toolrate/src/App.tsx index 0699a0f..92babec 100644 --- a/artifacts/toolrate/src/App.tsx +++ b/artifacts/toolrate/src/App.tsx @@ -1,5 +1,7 @@ import { Switch, Route, Router as WouterRouter } from "wouter"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { I18nextProvider } from "react-i18next"; +import i18n from "@/i18n"; import { Toaster } from "@/components/ui/toaster"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -49,14 +51,16 @@ function Router() { function App() { return ( - - - - - - - - + + + + + + + + + + ); } diff --git a/artifacts/toolrate/src/components/command-palette.tsx b/artifacts/toolrate/src/components/command-palette.tsx index 9f37650..ef3ff15 100644 --- a/artifacts/toolrate/src/components/command-palette.tsx +++ b/artifacts/toolrate/src/components/command-palette.tsx @@ -11,6 +11,7 @@ import { CommandSeparator, } from "@/components/ui/command"; import { useAuth } from "@/hooks/use-auth"; +import { useTranslation } from "react-i18next"; import { Compass, PlusCircle, @@ -23,6 +24,7 @@ import { export function CommandPalette() { const [, navigate] = useLocation(); + const { t } = useTranslation(); const { isAuthenticated, isAdmin, hasFeature } = useAuth(); const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); @@ -65,7 +67,7 @@ export function CommandPalette() { return ( { setSearch(v); @@ -78,27 +80,27 @@ export function CommandPalette() { go("/tools")}> - Browse Tools + {t("nav.browseTools")} go("/tools/new")}> - Add a Tool + {t("nav.addTool")} go("/analytics")}> - Analytics + {t("nav.analytics")} {showWatchlist && ( go("/watchlist")}> - My Watchlist + {t("nav.watchlist")} )} {showTrash && ( go("/trash")}> - Trash + {t("nav.trash")} )} {isAdmin && ( go("/admin")}> - Admin + {t("nav.admin")} )} diff --git a/artifacts/toolrate/src/components/compare-bar.tsx b/artifacts/toolrate/src/components/compare-bar.tsx index 0f1641e..1221601 100644 --- a/artifacts/toolrate/src/components/compare-bar.tsx +++ b/artifacts/toolrate/src/components/compare-bar.tsx @@ -2,6 +2,7 @@ import { X, Scale, Lock } from "lucide-react"; import type { ToolWithStats } from "@workspace/api-client-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; +import { useTranslation } from "react-i18next"; export function CompareBar({ tools, @@ -18,6 +19,7 @@ export function CompareBar({ onCompare: () => void; onUpgrade: () => void; }) { + const { t } = useTranslation(); if (tools.length === 0) return null; return (
@@ -49,17 +51,17 @@ export function CompareBar({ onClick={onClear} className="text-xs text-muted-foreground hover:text-foreground whitespace-nowrap px-1" > - Clear + {t("common.clear")}
{canCompare ? ( ) : ( - )} diff --git a/artifacts/toolrate/src/components/filter-popover.tsx b/artifacts/toolrate/src/components/filter-popover.tsx index 31f0992..d49a579 100644 --- a/artifacts/toolrate/src/components/filter-popover.tsx +++ b/artifacts/toolrate/src/components/filter-popover.tsx @@ -6,6 +6,8 @@ import { Label } from "@/components/ui/label"; import { Slider } from "@/components/ui/slider"; import { ScrollArea } from "@/components/ui/scroll-area"; import { SlidersHorizontal, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; export function FilterPopover({ tags, @@ -24,6 +26,7 @@ export function FilterPopover({ }) { const { data: allTags } = useListAllTags(); const { data: allFeatures } = useListAllFeatures(); + const { t } = useTranslation(); function toggle(list: string[], value: string): string[] { return list.includes(value) ? list.filter((v) => v !== value) : [...list, value]; @@ -34,7 +37,7 @@ export function FilterPopover({ -
+
{allTags && allTags.length > 0 && ( -
-

Tags

- +
+

{t("filter.tags")}

+
{allTags.map((t) => (
)} {allFeatures && allFeatures.length > 0 && ( -
-

Features

- +
+

{t("filter.features")}

+
{allFeatures.map((f) => (
diff --git a/artifacts/toolrate/src/components/language-switcher.tsx b/artifacts/toolrate/src/components/language-switcher.tsx new file mode 100644 index 0000000..06d9679 --- /dev/null +++ b/artifacts/toolrate/src/components/language-switcher.tsx @@ -0,0 +1,44 @@ +import { useTranslation } from "react-i18next"; +import { Languages, Check } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { SUPPORTED_LANGUAGES, setStoredLanguage, type LanguageCode } from "@/i18n"; +import { cn } from "@/lib/utils"; + +export function LanguageSwitcher() { + const { i18n, t } = useTranslation(); + const current = (i18n.language?.split("-")[0] ?? "en") as LanguageCode; + + function change(lang: LanguageCode) { + setStoredLanguage(lang); + void i18n.changeLanguage(lang); + } + + return ( + + + + + + {t("common.language")} + + {SUPPORTED_LANGUAGES.map(({ code, label }) => ( + change(code)} className="justify-between"> + {label} + {current === code && } + + ))} + + + ); +} diff --git a/artifacts/toolrate/src/components/layout.tsx b/artifacts/toolrate/src/components/layout.tsx index a247371..7678a1a 100644 --- a/artifacts/toolrate/src/components/layout.tsx +++ b/artifacts/toolrate/src/components/layout.tsx @@ -1,28 +1,31 @@ import { Link, useLocation } from "wouter"; import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, User, ShieldCheck, AlertTriangle, Trash2, Bookmark } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { useAuth } from "@/hooks/use-auth"; import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { ThemeToggle } from "@/components/theme-toggle"; import { CommandPalette } from "@/components/command-palette"; +import { LanguageSwitcher } from "@/components/language-switcher"; export function Layout({ children }: { children: React.ReactNode }) { const [location] = useLocation(); + const { t } = useTranslation(); const { user, isLoading, isAuthenticated, isAdmin, isLocalMode, tier, hasFeature, login, logout } = useAuth(); const { data: version } = useGetVersion({ query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false }, }); const links = [ - { href: "/", label: "Dashboard", icon: LayoutDashboard }, - { href: "/tools", label: "Browse Tools", icon: Wrench }, - { href: "/tools/new", label: "Add Tool", icon: PlusCircle }, - { href: "/analytics", label: "Analytics", icon: BarChart3 }, - ...(hasFeature("watchlist") ? [{ href: "/watchlist", label: "Watchlist", icon: Bookmark }] : []), - ...(hasFeature("trash") ? [{ href: "/trash", label: "Trash", icon: Trash2 }] : []), - ...(isAdmin ? [{ href: "/admin", label: "Admin", icon: ShieldCheck }] : []), - ...(isAdmin ? [{ href: "/admin/redundancy", label: "Redundancy", icon: AlertTriangle }] : []), + { href: "/", label: t("nav.home"), icon: LayoutDashboard }, + { href: "/tools", label: t("nav.browseTools"), icon: Wrench }, + { href: "/tools/new", label: t("nav.addTool"), icon: PlusCircle }, + { href: "/analytics", label: t("nav.analytics"), icon: BarChart3 }, + ...(hasFeature("watchlist") ? [{ href: "/watchlist", label: t("nav.watchlist"), icon: Bookmark }] : []), + ...(hasFeature("trash") ? [{ href: "/trash", label: t("nav.trash"), icon: Trash2 }] : []), + ...(isAdmin ? [{ href: "/admin", label: t("nav.admin"), icon: ShieldCheck }] : []), + ...(isAdmin ? [{ href: "/admin/redundancy", label: t("nav.redundancy"), icon: AlertTriangle }] : []), ]; const activeLink = links.find( @@ -30,11 +33,11 @@ export function Layout({ children }: { children: React.ReactNode }) { ); const mobileLinks = [ - { href: "/", label: "Home", icon: LayoutDashboard }, - { href: "/tools", label: "Tools", icon: Wrench }, - ...(hasFeature("watchlist") ? [{ href: "/watchlist", label: "Watchlist", icon: Bookmark }] : []), - ...(hasFeature("trash") ? [{ href: "/trash", label: "Trash", icon: Trash2 }] : []), - { href: "/analytics", label: "Analytics", icon: BarChart3 }, + { href: "/", label: t("nav.home"), icon: LayoutDashboard }, + { href: "/tools", label: t("nav.browseTools"), icon: Wrench }, + ...(hasFeature("watchlist") ? [{ href: "/watchlist", label: t("nav.watchlist"), icon: Bookmark }] : []), + ...(hasFeature("trash") ? [{ href: "/trash", label: t("nav.trash"), icon: Trash2 }] : []), + { href: "/analytics", label: t("nav.analytics"), icon: BarChart3 }, ].slice(0, 5); return ( @@ -94,7 +97,7 @@ export function Layout({ children }: { children: React.ReactNode }) { data-testid="button-logout" > - Sign out + {t("auth.signOut")}
) : ( @@ -106,7 +109,7 @@ export function Layout({ children }: { children: React.ReactNode }) { data-testid="button-login" > - {isLocalMode ? "Sign in" : "Sign in with Keycloak"} + {isLocalMode ? t("auth.signIn") : t("auth.signInKeycloak")} )}
@@ -135,7 +138,10 @@ export function Layout({ children }: { children: React.ReactNode }) {

{activeLink?.label ?? "toolr"}

- +
+ + +
@@ -143,6 +149,7 @@ export function Layout({ children }: { children: React.ReactNode }) { toolr
+ {!isLoading && ( isAuthenticated ? ( @@ -152,7 +159,7 @@ export function Layout({ children }: { children: React.ReactNode }) { ) : ( ) )} diff --git a/artifacts/toolrate/src/i18n/index.ts b/artifacts/toolrate/src/i18n/index.ts new file mode 100644 index 0000000..207426b --- /dev/null +++ b/artifacts/toolrate/src/i18n/index.ts @@ -0,0 +1,37 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import en from "./locales/en.json"; +import de from "./locales/de.json"; + +export const SUPPORTED_LANGUAGES = [ + { code: "en", label: "English" }, + { code: "de", label: "Deutsch" }, +] as const; + +export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]["code"]; + +const STORAGE_KEY = "toolr-language"; + +export function getStoredLanguage(): LanguageCode { + if (typeof window === "undefined") return "en"; + const stored = window.localStorage.getItem(STORAGE_KEY); + if (stored === "en" || stored === "de") return stored; + const nav = window.navigator.language?.toLowerCase() ?? ""; + return nav.startsWith("de") ? "de" : "en"; +} + +export function setStoredLanguage(lang: LanguageCode) { + window.localStorage.setItem(STORAGE_KEY, lang); +} + +void i18n.use(initReactI18next).init({ + resources: { + en: { translation: en }, + de: { translation: de }, + }, + lng: getStoredLanguage(), + fallbackLng: "en", + interpolation: { escapeValue: false }, +}); + +export default i18n; diff --git a/artifacts/toolrate/src/i18n/locales/de.json b/artifacts/toolrate/src/i18n/locales/de.json new file mode 100644 index 0000000..77a9de8 --- /dev/null +++ b/artifacts/toolrate/src/i18n/locales/de.json @@ -0,0 +1,168 @@ +{ + "app": { + "name": "toolr", + "tagline": "Tool-Bewertungsplattform" + }, + "nav": { + "home": "Start", + "browseTools": "Tools durchsuchen", + "addTool": "Tool hinzufügen", + "analytics": "Analysen", + "watchlist": "Merkliste", + "trash": "Papierkorb", + "admin": "Admin", + "redundancy": "Redundanz" + }, + "auth": { + "signIn": "Anmelden", + "signInKeycloak": "Mit Keycloak anmelden", + "signOut": "Abmelden", + "username": "Benutzername", + "password": "Passwort", + "login": "Anmelden", + "invalidCredentials": "Ungültiger Benutzername oder ungültiges Passwort", + "notAuthenticated": "Nicht angemeldet", + "loginSubtitle": "Melde dich bei deinem Konto an", + "loginDescription": "Gib deine Zugangsdaten ein, um fortzufahren" + }, + "common": { + "cancel": "Abbrechen", + "save": "Speichern", + "delete": "Löschen", + "edit": "Bearbeiten", + "clear": "Leeren", + "clearAll": "Alle löschen", + "search": "Suchen", + "add": "Hinzufügen", + "back": "Zurück", + "backToTools": "Zurück zu den Tools", + "browseTools": "Tools durchsuchen", + "viewAll": "Alle anzeigen", + "loading": "Wird geladen…", + "all": "Alle", + "none": "Keine", + "language": "Sprache" + }, + "home": { + "welcome": "Willkommen bei toolr", + "tagline": "Die Community-Plattform, auf der Ingenieure die Tools, die sie täglich nutzen, ehrlich bewerten.", + "totalTools": "Tools gesamt", + "totalRatings": "Bewertungen gesamt", + "avgRating": "Ø Bewertung", + "topRated": "Bestbewertete Tools", + "topRatedSub": "Höchste kombinierte Nützlichkeit und Benutzerfreundlichkeit.", + "recentlyAdded": "Zuletzt hinzugefügt", + "recentlyAddedSub": "Die neuesten Ergänzungen im Katalog.", + "noRatingsYet": "Noch keine Bewertungen", + "noRatingsSub": "Bewerte als Erster ein Tool und hilf der Community.", + "noTools": "Keine Tools gefunden", + "noToolsSub": "Es sind noch keine Tools in der Datenbank vorhanden." + }, + "browse": { + "filters": "Filter", + "subtitle": "Entdecke und bewerte die besten Tools für deinen Stack.", + "allCategories": "Alle Kategorien", + "showingTool_one": "{{count}} Tool wird angezeigt", + "showingTool_other": "{{count}} Tools werden angezeigt", + "tool": "Tool", + "category": "Kategorie", + "rating": "Bewertung", + "reviews": "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", + "compare": "Vergleichen", + "compareCount": "Vergleichen ({{count}})", + "addToCompare": "Zum Vergleich hinzufügen", + "removeFromCompare": "Aus dem Vergleich entfernen", + "comparePremiumTitle": "Vergleichen ist ein Premium-Feature", + "comparePremiumSub": "Das Vergleichen von Tools nebeneinander ist für Premium- und Enterprise-Nutzer verfügbar. Erweitere deinen Plan, um es freizuschalten.", + "notNow": "Später", + "upgradePremium": "Auf Premium upgraden", + "sortNewest": "Neueste", + "sortTopRated": "Bestbewertet", + "sortMostReviewed": "Meiste Bewertungen", + "sortNameAsc": "Name (A–Z)", + "sortNameDesc": "Name (Z–A)", + "sortRecentlyUpdated": "Zuletzt aktualisiert" + }, + "filter": { + "tags": "Tags", + "features": "Funktionen", + "minRating": "Mindestbewertung", + "any": "Beliebig", + "clearFilters": "Filter zurücksetzen" + }, + "detail": { + "ratingBreakdown": "Bewertungsaufschlüsselung", + "usefulness": "Nützlichkeit", + "usability": "Benutzerfreundlichkeit", + "scoreDistribution": "Punkteverteilung", + "scoreTrend": "Punkteverlauf", + "notEnoughRatings": "Noch nicht genügend Bewertungen für einen Verlauf.", + "reviews": "Bewertungen", + "basedOnReview_one": "Basierend auf {{count}} Bewertung", + "basedOnReview_other": "Basierend auf {{count}} Bewertungen", + "saveToWatchlist": "Zur Merkliste hinzufügen", + "savedToWatchlist": "Zur Merkliste hinzugefügt", + "removeFromWatchlist": "Von der Merkliste entfernen", + "visitWebsite": "Website besuchen", + "edit": "Bearbeiten", + "delete": "Löschen", + "noReviews": "Noch keine Bewertungen.", + "addReview": "Bewertung hinzufügen", + "yourRating": "Deine Bewertung", + "submit": "Bewertung absenden", + "relatedTools": "Ähnliche Tools", + "costs": "Kosten", + "addCost": "Kosten hinzufügen", + "recentRatings": "Letzte Bewertungen" + }, + "compare": { + "title": "Tools vergleichen", + "subtitle": "{{count}} Tool(s) werden nebeneinander verglichen.", + "sideBySide": "Vergleich nebeneinander", + "bestValue": "Der beste Wert jeder Zeile ist hervorgehoben.", + "attribute": "Eigenschaft", + "rating": "Bewertung", + "reviews": "Bewertungen", + "description": "Beschreibung", + "lastUpdated": "Zuletzt aktualisiert", + "requiresPremium": "Vergleichen erfordert einen höheren Tarif", + "requiresPremiumSub": "Das Vergleichen von Tools nebeneinander ist für Premium- und Enterprise-Nutzer verfügbar.", + "nothingToCompare": "Nichts zu vergleichen", + "nothingToCompareSub": "Wähle mindestens ein Tool aus und kehre hierher zurück.", + "backToBrowse": "Zurück zur Übersicht" + }, + "watchlist": { + "title": "Merkliste", + "subtitle": "Tools, die du für später gespeichert hast, mit Live-Punkten.", + "requiresPremium": "Die Merkliste erfordert einen höheren Tarif", + "requiresPremiumSub": "Das Speichern von Tools in deiner Merkliste ist für Premium- und Enterprise-Nutzer verfügbar.", + "empty": "Deine Merkliste ist leer", + "emptySub": "Durchsuche die Tools und klicke auf das Lesezeichen, um sie hier zu speichern.", + "remove": "Von der Merkliste entfernen" + }, + "trash": { + "title": "Papierkorb", + "subtitle": "Gelöschte Tools werden hier aufbewahrt, bis sie wiederhergestellt oder endgültig entfernt werden.", + "requiresPremium": "Der Papierkorb erfordert einen höheren Tarif", + "requiresPremiumSub": "Der Papierkorb ist für Premium- und Enterprise-Nutzer verfügbar.", + "empty": "Der Papierkorb ist leer.", + "noMatch": "Keine Tools entsprechen deiner Suche.", + "trashedTools": "Gelöschte Tools", + "toolsInTrash_one": "{{count}} Tool im Papierkorb", + "toolsInTrash_other": "{{count}} Tools im Papierkorb", + "selected_one": "{{count}} ausgewählt", + "selected_other": "{{count}} ausgewählt", + "restore": "Wiederherstellen", + "deletePermanently": "Endgültig löschen", + "emptyTrash": "Papierkorb leeren", + "deletedAt": "Gelöscht am", + "deletedBy": "Gelöscht von" + }, + "notFound": { + "text": "Diese Seite existiert nicht.", + "backHome": "Zurück zur Startseite" + } +} \ No newline at end of file diff --git a/artifacts/toolrate/src/i18n/locales/en.json b/artifacts/toolrate/src/i18n/locales/en.json new file mode 100644 index 0000000..db5f533 --- /dev/null +++ b/artifacts/toolrate/src/i18n/locales/en.json @@ -0,0 +1,168 @@ +{ + "app": { + "name": "toolr", + "tagline": "Tool-Bewertungsplattform" + }, + "nav": { + "home": "Home", + "browseTools": "Browse Tools", + "addTool": "Add Tool", + "analytics": "Analytics", + "watchlist": "Watchlist", + "trash": "Trash", + "admin": "Admin", + "redundancy": "Redundancy" + }, + "auth": { + "signIn": "Sign in", + "signInKeycloak": "Sign in with Keycloak", + "signOut": "Sign out", + "username": "Username", + "password": "Password", + "login": "Login", + "invalidCredentials": "Invalid username or password", + "notAuthenticated": "Not authenticated", + "loginSubtitle": "Sign in to your account", + "loginDescription": "Enter your credentials to continue" + }, + "common": { + "cancel": "Cancel", + "save": "Save", + "delete": "Delete", + "edit": "Edit", + "clear": "Clear", + "clearAll": "Clear all", + "search": "Search", + "add": "Add", + "back": "Back", + "backToTools": "Back to tools", + "browseTools": "Browse tools", + "viewAll": "View all", + "loading": "Loading…", + "all": "All", + "none": "None", + "language": "Language" + }, + "home": { + "welcome": "Welcome to toolr", + "tagline": "The community hub where engineers honestly rate the tools they use daily.", + "totalTools": "Total Tools", + "totalRatings": "Total Ratings", + "avgRating": "Avg Rating", + "topRated": "Top Rated Tools", + "topRatedSub": "Highest combined usefulness and usability.", + "recentlyAdded": "Recently Added", + "recentlyAddedSub": "The latest additions to the catalog.", + "noRatingsYet": "No ratings yet", + "noRatingsSub": "Be the first to rate a tool and help the community.", + "noTools": "No tools found", + "noToolsSub": "There are no tools in the database yet." + }, + "browse": { + "filters": "Filters", + "subtitle": "Discover and evaluate the best tools for your stack.", + "allCategories": "All Categories", + "showingTool_one": "Showing {{count}} tool", + "showingTool_other": "Showing {{count}} tools", + "tool": "Tool", + "category": "Category", + "rating": "Rating", + "reviews": "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", + "compare": "Compare", + "compareCount": "Compare ({{count}})", + "addToCompare": "Add to compare", + "removeFromCompare": "Remove from compare", + "comparePremiumTitle": "Compare is a Premium feature", + "comparePremiumSub": "Comparing tools side-by-side is available to Premium and Enterprise users. Upgrade your plan to unlock it.", + "notNow": "Not now", + "upgradePremium": "Upgrade to Premium", + "sortNewest": "Newest", + "sortTopRated": "Top Rated", + "sortMostReviewed": "Most Reviewed", + "sortNameAsc": "Name (A–Z)", + "sortNameDesc": "Name (Z–A)", + "sortRecentlyUpdated": "Recently Updated" + }, + "filter": { + "tags": "Tags", + "features": "Features", + "minRating": "Min. rating", + "any": "Any", + "clearFilters": "Clear filters" + }, + "detail": { + "ratingBreakdown": "Rating Breakdown", + "usefulness": "Usefulness", + "usability": "Usability", + "scoreDistribution": "Score Distribution", + "scoreTrend": "Score Trend", + "notEnoughRatings": "Not enough ratings yet to show a trend.", + "reviews": "Reviews", + "basedOnReview_one": "Based on {{count}} review", + "basedOnReview_other": "Based on {{count}} reviews", + "saveToWatchlist": "Save to watchlist", + "savedToWatchlist": "Saved to watchlist", + "removeFromWatchlist": "Remove from watchlist", + "visitWebsite": "Visit Website", + "edit": "Edit", + "delete": "Delete", + "noReviews": "No reviews yet.", + "addReview": "Add Review", + "yourRating": "Your Rating", + "submit": "Submit Rating", + "relatedTools": "Related Tools", + "costs": "Costs", + "addCost": "Add Cost", + "recentRatings": "Recent Ratings" + }, + "compare": { + "title": "Compare Tools", + "subtitle": "Comparing {{count}} tool(s) side-by-side.", + "sideBySide": "Side-by-side comparison", + "bestValue": "Best value in each row is highlighted.", + "attribute": "Attribute", + "rating": "Rating", + "reviews": "Reviews", + "description": "Description", + "lastUpdated": "Last updated", + "requiresPremium": "Compare requires a higher tier", + "requiresPremiumSub": "Compare tools side-by-side is available to Premium and Enterprise users.", + "nothingToCompare": "Nothing to compare", + "nothingToCompareSub": "Select at least one tool to compare and come back here.", + "backToBrowse": "Back to browse" + }, + "watchlist": { + "title": "Watchlist", + "subtitle": "Tools you saved for later, with live scores.", + "requiresPremium": "Watchlist requires a higher tier", + "requiresPremiumSub": "Saving tools to your watchlist is available to Premium and Enterprise users.", + "empty": "Your watchlist is empty", + "emptySub": "Browse tools and click the bookmark to save them here.", + "remove": "Remove from watchlist" + }, + "trash": { + "title": "Trash", + "subtitle": "Deleted tools are kept here until they are restored or permanently removed.", + "requiresPremium": "Trash requires a higher tier", + "requiresPremiumSub": "The trash is available to Premium and Enterprise users.", + "empty": "The trash is empty.", + "noMatch": "No tools match your search.", + "trashedTools": "Trashed Tools", + "toolsInTrash_one": "{{count}} tool in trash", + "toolsInTrash_other": "{{count}} tools in trash", + "selected_one": "{{count}} selected", + "selected_other": "{{count}} selected", + "restore": "Restore", + "deletePermanently": "Delete permanently", + "emptyTrash": "Empty trash", + "deletedAt": "Deleted at", + "deletedBy": "Deleted by" + }, + "notFound": { + "text": "This page doesn't exist.", + "backHome": "Back to Home" + } +} \ No newline at end of file diff --git a/artifacts/toolrate/src/pages/compare.tsx b/artifacts/toolrate/src/pages/compare.tsx index eab88ad..e359b70 100644 --- a/artifacts/toolrate/src/pages/compare.tsx +++ b/artifacts/toolrate/src/pages/compare.tsx @@ -15,12 +15,14 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { ShieldAlert, Trophy, Scale } from "lucide-react"; import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; function fmt(v: number | null | undefined): string { return v != null ? v.toFixed(1) : "–"; } export default function Compare() { + const { t } = useTranslation(); const { hasFeature, isLoading: authLoading } = useAuth(); const search = useSearch(); @@ -50,10 +52,10 @@ export default function Compare() {
-

Compare requires a higher tier

-

Compare tools side-by-side is available to Premium and Enterprise users.

+

{t("compare.requiresPremium")}

+

{t("compare.requiresPremiumSub")}

@@ -77,10 +79,10 @@ export default function Compare() {
-

Nothing to compare

-

Select at least one tool to compare and come back here.

+

{t("compare.nothingToCompare")}

+

{t("compare.nothingToCompareSub")}

@@ -99,20 +101,20 @@ export default function Compare() {
-

Compare Tools

-

Comparing {list.length} tool(s) side-by-side.

+

{t("compare.title")}

+

{t("compare.subtitle", { count: list.length })}

- Side-by-side comparison - Best value in each row is highlighted. + {t("compare.sideBySide")} + {t("compare.bestValue")} - Attribute + {t("compare.attribute")} {list.map((t) => (
@@ -127,7 +129,7 @@ export default function Compare() { - Rating + {t("compare.rating")} {list.map((t) => (
@@ -155,7 +157,7 @@ export default function Compare() { ))} - Reviews + {t("compare.reviews")} {list.map((t) => ( {t.ratingCount} @@ -163,7 +165,7 @@ export default function Compare() { ))} - Description + {t("compare.description")} {list.map((t) => ( {t.description} @@ -197,7 +199,7 @@ export default function Compare() { ))} - Last updated + {t("compare.lastUpdated")} {list.map((t) => ( {new Date(t.updatedAt).toLocaleDateString()} @@ -210,7 +212,7 @@ export default function Compare() {
diff --git a/artifacts/toolrate/src/pages/home.tsx b/artifacts/toolrate/src/pages/home.tsx index 731cb87..a915849 100644 --- a/artifacts/toolrate/src/pages/home.tsx +++ b/artifacts/toolrate/src/pages/home.tsx @@ -11,8 +11,10 @@ import { ToolCard } from "@/components/tool-card"; import { Star, Wrench, MessageSquare, ArrowRight } from "lucide-react"; import { Link } from "wouter"; import { Button } from "@/components/ui/button"; +import { useTranslation } from "react-i18next"; export default function Home() { + const { t } = useTranslation(); const { data: summary, isLoading: loadingSummary } = useGetAnalyticsSummary(); const { data: topTools, isLoading: loadingTopTools } = useGetTopTools({ limit: 4, metric: GetTopToolsMetric.combined }); const { data: recentTools, isLoading: loadingRecent } = useListTools({ sort: "newest" }); @@ -21,8 +23,8 @@ export default function Home() {
-

Welcome to toolr

-

The community hub where engineers honestly rate the tools they use daily.

+

{t("home.welcome")}

+

{t("home.tagline")}

{/* Stats Banner */} @@ -33,7 +35,7 @@ export default function Home() {
-

Total Tools

+

{t("home.totalTools")}

{loadingSummary ? ( ) : ( @@ -49,7 +51,7 @@ export default function Home() {
-

Total Ratings

+

{t("home.totalRatings")}

{loadingSummary ? ( ) : ( @@ -65,7 +67,7 @@ export default function Home() {
-

Avg Rating

+

{t("home.avgRating")}

{loadingSummary ? ( ) : ( @@ -80,12 +82,12 @@ export default function Home() {
-

Top Rated Tools

-

Highest combined usefulness and usability.

+

{t("home.topRated")}

+

{t("home.topRatedSub")}

@@ -106,10 +108,10 @@ export default function Home() { -

No ratings yet

-

Be the first to rate a tool and help the community.

+

{t("home.noRatingsYet")}

+

{t("home.noRatingsSub")}

@@ -120,12 +122,12 @@ export default function Home() {
-

Recently Added

-

The latest additions to the catalog.

+

{t("home.recentlyAdded")}

+

{t("home.recentlyAddedSub")}

@@ -146,10 +148,10 @@ export default function Home() { -

No tools found

-

There are no tools in the database yet.

+

{t("home.noTools")}

+

{t("home.noToolsSub")}

diff --git a/artifacts/toolrate/src/pages/login.tsx b/artifacts/toolrate/src/pages/login.tsx index 453274a..20ad8bc 100644 --- a/artifacts/toolrate/src/pages/login.tsx +++ b/artifacts/toolrate/src/pages/login.tsx @@ -8,8 +8,10 @@ import { Label } from "@/components/ui/label"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { ThemeToggle } from "@/components/theme-toggle"; import { Wrench, AlertCircle } from "lucide-react"; +import { useTranslation } from "react-i18next"; export default function Login() { + const { t } = useTranslation(); const [, setLocation] = useLocation(); const search = useSearch(); const params = new URLSearchParams(search); @@ -33,7 +35,7 @@ export default function Login() { setLocation(returnTo); }, onError: (err) => { - setError(err.data?.error || err.message || "Invalid username or password"); + setError(err.data?.error || err.message || t("auth.invalidCredentials")); }, }, ); @@ -50,18 +52,18 @@ export default function Login() { toolr -

Sign in to your account

+

{t("auth.loginSubtitle")}

- Sign in - Enter your credentials to continue + {t("auth.signIn")} + {t("auth.loginDescription")}
- +
- + - {localLogin.isPending ? "Signing in..." : "Sign in"} + {localLogin.isPending ? t("common.loading") : t("auth.signIn")} @@ -103,7 +105,7 @@ export default function Login() {
- Back to Home + {t("notFound.backHome")}
diff --git a/artifacts/toolrate/src/pages/not-found.tsx b/artifacts/toolrate/src/pages/not-found.tsx index c9cd90f..db3ec49 100644 --- a/artifacts/toolrate/src/pages/not-found.tsx +++ b/artifacts/toolrate/src/pages/not-found.tsx @@ -2,8 +2,10 @@ import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Wrench, ArrowLeft } from "lucide-react"; import { Link } from "wouter"; +import { useTranslation } from "react-i18next"; export default function NotFound() { + const { t } = useTranslation(); return (
@@ -13,10 +15,10 @@ export default function NotFound() { toolr

404

-

This page doesn't exist.

+

{t("notFound.text")}

diff --git a/artifacts/toolrate/src/pages/tool-detail.tsx b/artifacts/toolrate/src/pages/tool-detail.tsx index 8568a98..d766623 100644 --- a/artifacts/toolrate/src/pages/tool-detail.tsx +++ b/artifacts/toolrate/src/pages/tool-detail.tsx @@ -46,6 +46,7 @@ import { Link } from "wouter"; import { useAuth } from "@/hooks/use-auth"; import { useWatchlist } from "@/hooks/use-watchlist"; import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; import { useDeleteTool, getListToolsQueryKey } from "@workspace/api-client-react"; import { AlertDialog, @@ -79,6 +80,7 @@ export default function ToolDetail() { const [deleteOpen, setDeleteOpen] = useState(false); const { user, isAdmin, hasFeature } = useAuth(); + const { t } = useTranslation(); const { isWatched, toggle: toggleWatchlist, canWatchlist } = useWatchlist(); const canManageCosts = hasFeature("costs"); const hasTrash = hasFeature("trash"); @@ -355,7 +357,7 @@ export default function ToolDetail() { / 5
- Based on {tool.ratingCount} reviews + {t("detail.basedOnReview", { count: tool.ratingCount })}
{canWatchlist && ( @@ -365,14 +367,14 @@ export default function ToolDetail() { onClick={() => toggleWatchlist(Number(id))} > - {isWatched(id) ? "Saved to watchlist" : "Save to watchlist"} + {isWatched(id) ? t("detail.savedToWatchlist") : t("detail.saveToWatchlist")} )} {tool.websiteUrl && ( )} @@ -381,7 +383,7 @@ export default function ToolDetail() {
)} @@ -556,10 +558,10 @@ export default function ToolDetail() { {tool && (
-

Costs

+

{t("detail.costs")}

{canManageCosts && ( )}
@@ -607,7 +609,7 @@ export default function ToolDetail() { { if (!o) setCostDialogOpen(false); }}> - {editCost ? "Edit Cost" : "Add Cost"} + {editCost ? t("detail.edit") + " " + t("detail.costs") : t("detail.addCost")} Manage license cost information for this tool.
@@ -674,19 +676,19 @@ export default function ToolDetail() {
- Rating Breakdown + {t("detail.ratingBreakdown")}
- Usefulness + {t("detail.usefulness")} {tool.avgUsefulness ? tool.avgUsefulness.toFixed(1) : "0.0"}
- Usability + {t("detail.usability")} {tool.avgUsability ? tool.avgUsability.toFixed(1) : "0.0"}
@@ -696,7 +698,7 @@ export default function ToolDetail() { - Score Distribution + {t("detail.scoreDistribution")} {loadingDistribution ? ( @@ -718,7 +720,7 @@ export default function ToolDetail() { - Score Trend + {t("detail.scoreTrend")} {loadingRatingHistory ? ( @@ -739,7 +741,7 @@ export default function ToolDetail() {
) : ( -

Not enough ratings yet to show a trend.

+

{t("detail.notEnoughRatings")}

)}
@@ -748,16 +750,16 @@ export default function ToolDetail() { {/* Right Column: Reviews */}
-

Reviews

+

{t("detail.reviews")}

{!isReviewFormOpen && user && ( - + )}
{isReviewFormOpen && ( - Write a Review + {t("detail.addReview")} Share your experience with {tool.name} @@ -769,7 +771,7 @@ export default function ToolDetail() { name="usefulness" render={({ field }) => ( - Usefulness + {t("detail.usefulness")}
( - Usability + {t("detail.usability")}
@@ -873,11 +875,11 @@ export default function ToolDetail() {
- Usefulness + {t("detail.usefulness")}
- Usability + {t("detail.usability")}
@@ -893,7 +895,7 @@ export default function ToolDetail() { ) : (
-

No reviews yet

+

{t("detail.noReviews")}

Be the first to share your thoughts on this tool.

)} diff --git a/artifacts/toolrate/src/pages/tools-browse.tsx b/artifacts/toolrate/src/pages/tools-browse.tsx index 0d1f4db..e8ad21a 100644 --- a/artifacts/toolrate/src/pages/tools-browse.tsx +++ b/artifacts/toolrate/src/pages/tools-browse.tsx @@ -19,6 +19,7 @@ import { useBrowsePreferences, isViewMode, isDensity } from "@/hooks/use-browse- import { useAuth } from "@/hooks/use-auth"; import { useWatchlist } from "@/hooks/use-watchlist"; import { CompareBar } from "@/components/compare-bar"; +import { useTranslation } from "react-i18next"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -30,13 +31,13 @@ import { cn } from "@/lib/utils"; const SORT_VALUES = new Set(Object.values(ListToolsSort)); -const SORT_LABELS: Record = { - [ListToolsSort.newest]: "Newest", - [ListToolsSort.top_rated]: "Top Rated", - [ListToolsSort.most_reviewed]: "Most Reviewed", - [ListToolsSort.name_asc]: "Name (A–Z)", - [ListToolsSort.name_desc]: "Name (Z–A)", - [ListToolsSort.recently_updated]: "Recently Updated", +const SORT_KEYS: Record = { + [ListToolsSort.newest]: "browse.sortNewest", + [ListToolsSort.top_rated]: "browse.sortTopRated", + [ListToolsSort.most_reviewed]: "browse.sortMostReviewed", + [ListToolsSort.name_asc]: "browse.sortNameAsc", + [ListToolsSort.name_desc]: "browse.sortNameDesc", + [ListToolsSort.recently_updated]: "browse.sortRecentlyUpdated", }; function SortHeader({ @@ -77,6 +78,7 @@ function SortHeader({ export default function ToolsBrowse() { const [, navigate] = useLocation(); const urlSearch = useSearch(); + const { t } = useTranslation(); const initialParams = useMemo(() => new URLSearchParams(urlSearch), [urlSearch]); const initialSearch = initialParams.get("search") ?? ""; @@ -228,11 +230,11 @@ export default function ToolsBrowse() {
-

Browse Tools

-

Discover and evaluate the best tools for your stack.

+

{t("nav.browseTools")}

+

{t("browse.subtitle")}

@@ -242,7 +244,7 @@ export default function ToolsBrowse() { setSearchInput(e.target.value)} @@ -256,7 +258,7 @@ export default function ToolsBrowse() { - All Categories + {t("browse.allCategories")} {!loadingCategories && categories?.map((c) => ( @@ -273,7 +275,7 @@ export default function ToolsBrowse() { {Object.values(ListToolsSort).map((v) => ( - {SORT_LABELS[v]} + {t(SORT_KEYS[v])} ))} @@ -329,7 +331,7 @@ export default function ToolsBrowse() { )} {sort !== ListToolsSort.newest && ( - {SORT_LABELS[sort]} + {t(SORT_KEYS[sort])} )}
diff --git a/artifacts/toolrate/src/pages/trash.tsx b/artifacts/toolrate/src/pages/trash.tsx index 150f4bb..3cb887b 100644 --- a/artifacts/toolrate/src/pages/trash.tsx +++ b/artifacts/toolrate/src/pages/trash.tsx @@ -27,8 +27,10 @@ import { useToast } from "@/hooks/use-toast"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; import { Trash2, RotateCcw, Search, ShieldAlert, Trash as TrashIcon, RefreshCcw } from "lucide-react"; import { format } from "date-fns"; +import { useTranslation } from "react-i18next"; export default function Trash() { + const { t } = useTranslation(); const { user, isAdmin, hasFeature, isLoading: authLoading } = useAuth(); const { toast } = useToast(); const queryClient = useQueryClient(); @@ -139,10 +141,10 @@ export default function Trash() {
-

Trash requires a higher tier

-

The trash is available to Premium and Enterprise users.

+

{t("trash.requiresPremium")}

+

{t("trash.requiresPremiumSub")}

@@ -155,16 +157,16 @@ export default function Trash() {
-

Trash

-

Deleted tools are kept here until they are restored or permanently removed.

+

{t("trash.title")}

+

{t("trash.subtitle")}

- Trashed Tools + {t("trash.trashedTools")} - {selectedIds.length > 0 ? `${selectedIds.length} selected` : `${trashed.length} tool(s) in trash`} + {selectedIds.length > 0 ? t("trash.selected", { count: selectedIds.length }) : t("trash.toolsInTrash", { count: trashed.length })}
@@ -172,7 +174,7 @@ export default function Trash() { setSearchInput(e.target.value)} /> @@ -183,7 +185,7 @@ export default function Trash() { disabled={selectedIds.length === 0 || restore.isPending} onClick={() => handleRestore(selectedIds)} > - Restore ({selectedIds.length}) + {t("trash.restore")} ({selectedIds.length}) {isAdmin && ( <> @@ -193,7 +195,7 @@ export default function Trash() { disabled={selectedIds.length === 0 || deletePermanent.isPending} onClick={() => setConfirmDelete(true)} > - Delete permanently ({selectedIds.length}) + {t("trash.deletePermanently")} ({selectedIds.length}) )} @@ -215,7 +217,7 @@ export default function Trash() { ) : trashed.length === 0 ? (
- {search ? "No tools match your search." : "The trash is empty."} + {search ? t("trash.noMatch") : t("trash.empty")}
) : (
@@ -230,8 +232,8 @@ export default function Trash() { NameCategory - Deleted at - Deleted by + {t("trash.deletedAt")} + {t("trash.deletedBy")}Actions @@ -294,7 +296,7 @@ export default function Trash() { className="bg-destructive text-destructive-foreground hover:bg-destructive/90" onClick={() => handleDeletePermanent(selectedIds)} > - Delete permanently + {t("trash.deletePermanently")} @@ -314,7 +316,7 @@ export default function Trash() { className="bg-destructive text-destructive-foreground hover:bg-destructive/90" onClick={handleEmpty} > - Empty trash + {t("trash.emptyTrash")} diff --git a/artifacts/toolrate/src/pages/watchlist.tsx b/artifacts/toolrate/src/pages/watchlist.tsx index 3931242..475e947 100644 --- a/artifacts/toolrate/src/pages/watchlist.tsx +++ b/artifacts/toolrate/src/pages/watchlist.tsx @@ -9,8 +9,10 @@ import { ToolCard } from "@/components/tool-card"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { ShieldAlert, Bookmark } from "lucide-react"; +import { useTranslation } from "react-i18next"; export default function Watchlist() { + const { t } = useTranslation(); const { hasFeature, isLoading: authLoading } = useAuth(); const { toggle } = useWatchlist(); const canWatchlist = hasFeature("watchlist"); @@ -24,10 +26,10 @@ export default function Watchlist() {
-

Watchlist requires a higher tier

-

Saving tools to your watchlist is available to Premium and Enterprise users.

+

{t("watchlist.requiresPremium")}

+

{t("watchlist.requiresPremiumSub")}

@@ -40,8 +42,8 @@ export default function Watchlist() {
-

Watchlist

-

Tools you saved for later, with live scores.

+

{t("watchlist.title")}

+

{t("watchlist.subtitle")}

{loading ? ( @@ -51,9 +53,9 @@ export default function Watchlist() { ) : list.length === 0 ? (
-

Your watchlist is empty

+

{t("watchlist.empty")}

- Browse tools and click the bookmark to save them here. + {t("watchlist.emptySub")}