fix: compact filter popover; feat: i18n (de/en)
Build & Push Docker Image / build (push) Successful in 2m31s

This commit is contained in:
opencode
2026-08-02 13:42:53 +02:00
parent 95bf49509c
commit 2f38889726
19 changed files with 662 additions and 160 deletions
+2
View File
@@ -56,6 +56,7 @@
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"framer-motion": "catalog:", "framer-motion": "catalog:",
"i18next": "^26.3.6",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "catalog:", "lucide-react": "catalog:",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@@ -63,6 +64,7 @@
"react-day-picker": "^9.11.1", "react-day-picker": "^9.11.1",
"react-dom": "catalog:", "react-dom": "catalog:",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.55.0",
"react-i18next": "^17.0.11",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2", "recharts": "^2.15.2",
+12 -8
View File
@@ -1,5 +1,7 @@
import { Switch, Route, Router as WouterRouter } from "wouter"; import { Switch, Route, Router as WouterRouter } from "wouter";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nextProvider } from "react-i18next";
import i18n from "@/i18n";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
@@ -49,14 +51,16 @@ function Router() {
function App() { function App() {
return ( return (
<QueryClientProvider client={queryClient}> <I18nextProvider i18n={i18n}>
<TooltipProvider> <QueryClientProvider client={queryClient}>
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}> <TooltipProvider>
<Router /> <WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}>
</WouterRouter> <Router />
<Toaster /> </WouterRouter>
</TooltipProvider> <Toaster />
</QueryClientProvider> </TooltipProvider>
</QueryClientProvider>
</I18nextProvider>
); );
} }
@@ -11,6 +11,7 @@ import {
CommandSeparator, CommandSeparator,
} from "@/components/ui/command"; } from "@/components/ui/command";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useTranslation } from "react-i18next";
import { import {
Compass, Compass,
PlusCircle, PlusCircle,
@@ -23,6 +24,7 @@ import {
export function CommandPalette() { export function CommandPalette() {
const [, navigate] = useLocation(); const [, navigate] = useLocation();
const { t } = useTranslation();
const { isAuthenticated, isAdmin, hasFeature } = useAuth(); const { isAuthenticated, isAdmin, hasFeature } = useAuth();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -65,7 +67,7 @@ export function CommandPalette() {
return ( return (
<CommandDialog open={open} onOpenChange={setOpen}> <CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput <CommandInput
placeholder="Search tools or jump to…" placeholder={`${t("common.search")}`}
value={search} value={search}
onValueChange={(v) => { onValueChange={(v) => {
setSearch(v); setSearch(v);
@@ -78,27 +80,27 @@ export function CommandPalette() {
</CommandEmpty> </CommandEmpty>
<CommandGroup heading="Navigate"> <CommandGroup heading="Navigate">
<CommandItem onSelect={() => go("/tools")}> <CommandItem onSelect={() => go("/tools")}>
<Compass className="mr-2 h-4 w-4" /> Browse Tools <Compass className="mr-2 h-4 w-4" /> {t("nav.browseTools")}
</CommandItem> </CommandItem>
<CommandItem onSelect={() => go("/tools/new")}> <CommandItem onSelect={() => go("/tools/new")}>
<PlusCircle className="mr-2 h-4 w-4" /> Add a Tool <PlusCircle className="mr-2 h-4 w-4" /> {t("nav.addTool")}
</CommandItem> </CommandItem>
<CommandItem onSelect={() => go("/analytics")}> <CommandItem onSelect={() => go("/analytics")}>
<BarChart3 className="mr-2 h-4 w-4" /> Analytics <BarChart3 className="mr-2 h-4 w-4" /> {t("nav.analytics")}
</CommandItem> </CommandItem>
{showWatchlist && ( {showWatchlist && (
<CommandItem onSelect={() => go("/watchlist")}> <CommandItem onSelect={() => go("/watchlist")}>
<Star className="mr-2 h-4 w-4" /> My Watchlist <Star className="mr-2 h-4 w-4" /> {t("nav.watchlist")}
</CommandItem> </CommandItem>
)} )}
{showTrash && ( {showTrash && (
<CommandItem onSelect={() => go("/trash")}> <CommandItem onSelect={() => go("/trash")}>
<Trash2 className="mr-2 h-4 w-4" /> Trash <Trash2 className="mr-2 h-4 w-4" /> {t("nav.trash")}
</CommandItem> </CommandItem>
)} )}
{isAdmin && ( {isAdmin && (
<CommandItem onSelect={() => go("/admin")}> <CommandItem onSelect={() => go("/admin")}>
<ShieldCheck className="mr-2 h-4 w-4" /> Admin <ShieldCheck className="mr-2 h-4 w-4" /> {t("nav.admin")}
</CommandItem> </CommandItem>
)} )}
</CommandGroup> </CommandGroup>
@@ -2,6 +2,7 @@ import { X, Scale, Lock } from "lucide-react";
import type { ToolWithStats } from "@workspace/api-client-react"; import type { ToolWithStats } from "@workspace/api-client-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
export function CompareBar({ export function CompareBar({
tools, tools,
@@ -18,6 +19,7 @@ export function CompareBar({
onCompare: () => void; onCompare: () => void;
onUpgrade: () => void; onUpgrade: () => void;
}) { }) {
const { t } = useTranslation();
if (tools.length === 0) return null; if (tools.length === 0) return null;
return ( return (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 w-[min(96vw,42rem)]"> <div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 w-[min(96vw,42rem)]">
@@ -49,17 +51,17 @@ export function CompareBar({
onClick={onClear} onClick={onClear}
className="text-xs text-muted-foreground hover:text-foreground whitespace-nowrap px-1" className="text-xs text-muted-foreground hover:text-foreground whitespace-nowrap px-1"
> >
Clear {t("common.clear")}
</button> </button>
</div> </div>
{canCompare ? ( {canCompare ? (
<Button size="sm" onClick={onCompare} disabled={tools.length < 2}> <Button size="sm" onClick={onCompare} disabled={tools.length < 2}>
Compare ({tools.length}) {t("browse.compareCount", { count: tools.length })}
</Button> </Button>
) : ( ) : (
<Button size="sm" variant="outline" onClick={onUpgrade} title="Compare is a Premium feature"> <Button size="sm" variant="outline" onClick={onUpgrade} title={t("browse.comparePremiumTitle")}>
<Lock className="w-3.5 h-3.5 mr-1.5" /> <Lock className="w-3.5 h-3.5 mr-1.5" />
Compare {t("browse.compare")}
</Button> </Button>
)} )}
</div> </div>
@@ -6,6 +6,8 @@ import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { SlidersHorizontal, X } from "lucide-react"; import { SlidersHorizontal, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
export function FilterPopover({ export function FilterPopover({
tags, tags,
@@ -24,6 +26,7 @@ export function FilterPopover({
}) { }) {
const { data: allTags } = useListAllTags(); const { data: allTags } = useListAllTags();
const { data: allFeatures } = useListAllFeatures(); const { data: allFeatures } = useListAllFeatures();
const { t } = useTranslation();
function toggle(list: string[], value: string): string[] { function toggle(list: string[], value: string): string[] {
return list.includes(value) ? list.filter((v) => v !== value) : [...list, value]; return list.includes(value) ? list.filter((v) => v !== value) : [...list, value];
@@ -34,7 +37,7 @@ export function FilterPopover({
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2" data-testid="button-filters"> <Button variant="outline" size="sm" className="gap-2" data-testid="button-filters">
<SlidersHorizontal className="w-4 h-4" /> <SlidersHorizontal className="w-4 h-4" />
Filters {t("browse.filters")}
{activeCount > 0 && ( {activeCount > 0 && (
<span className="rounded-full bg-primary text-primary-foreground text-[10px] font-medium px-1.5 py-0.5 min-w-[1.25rem] text-center"> <span className="rounded-full bg-primary text-primary-foreground text-[10px] font-medium px-1.5 py-0.5 min-w-[1.25rem] text-center">
{activeCount} {activeCount}
@@ -43,11 +46,11 @@ export function FilterPopover({
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[340px]" align="start"> <PopoverContent className="w-[340px]" align="start">
<div className="space-y-4"> <div className="space-y-3">
{allTags && allTags.length > 0 && ( {allTags && allTags.length > 0 && (
<div> <div className="pb-3 border-b border-border/60">
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Tags</h4> <h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">{t("filter.tags")}</h4>
<ScrollArea className="h-40"> <ScrollArea className="max-h-40">
<div className="space-y-1.5 pr-2"> <div className="space-y-1.5 pr-2">
{allTags.map((t) => ( {allTags.map((t) => (
<Label key={t} className="flex items-center gap-2 text-sm font-normal cursor-pointer"> <Label key={t} className="flex items-center gap-2 text-sm font-normal cursor-pointer">
@@ -63,9 +66,9 @@ export function FilterPopover({
</div> </div>
)} )}
{allFeatures && allFeatures.length > 0 && ( {allFeatures && allFeatures.length > 0 && (
<div> <div className="pb-3 border-b border-border/60">
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Features</h4> <h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">{t("filter.features")}</h4>
<ScrollArea className="h-40"> <ScrollArea className="max-h-40">
<div className="space-y-1.5 pr-2"> <div className="space-y-1.5 pr-2">
{allFeatures.map((f) => ( {allFeatures.map((f) => (
<Label key={f} className="flex items-center gap-2 text-sm font-normal cursor-pointer"> <Label key={f} className="flex items-center gap-2 text-sm font-normal cursor-pointer">
@@ -82,7 +85,7 @@ export function FilterPopover({
)} )}
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Min. rating</h4> <h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t("filter.minRating")}</h4>
{minRating != null && ( {minRating != null && (
<Button <Button
variant="ghost" variant="ghost"
@@ -105,12 +108,12 @@ export function FilterPopover({
aria-label="Minimum rating" aria-label="Minimum rating"
/> />
<span className="text-sm tabular-nums w-8 text-right text-muted-foreground"> <span className="text-sm tabular-nums w-8 text-right text-muted-foreground">
{minRating != null ? `${minRating.toFixed(1)}+` : "Any"} {minRating != null ? `${minRating.toFixed(1)}+` : t("filter.any")}
</span> </span>
</div> </div>
</div> </div>
<Button variant="outline" size="sm" className="w-full" onClick={onClear}> <Button variant="outline" size="sm" className="w-full" onClick={onClear}>
Clear filters {t("filter.clearFilters")}
</Button> </Button>
</div> </div>
</PopoverContent> </PopoverContent>
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-1.5" aria-label={t("common.language")} data-testid="button-language">
<Languages className="w-4 h-4" />
<span className="hidden sm:inline text-xs uppercase font-semibold">{current}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t("common.language")}</DropdownMenuLabel>
<DropdownMenuSeparator />
{SUPPORTED_LANGUAGES.map(({ code, label }) => (
<DropdownMenuItem key={code} onClick={() => change(code)} className="justify-between">
{label}
{current === code && <Check className="w-4 h-4 text-primary" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
+24 -17
View File
@@ -1,28 +1,31 @@
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, User, ShieldCheck, AlertTriangle, Trash2, Bookmark } from "lucide-react"; 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 { useAuth } from "@/hooks/use-auth";
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react"; import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
import { CommandPalette } from "@/components/command-palette"; import { CommandPalette } from "@/components/command-palette";
import { LanguageSwitcher } from "@/components/language-switcher";
export function Layout({ children }: { children: React.ReactNode }) { export function Layout({ children }: { children: React.ReactNode }) {
const [location] = useLocation(); const [location] = useLocation();
const { t } = useTranslation();
const { user, isLoading, isAuthenticated, isAdmin, isLocalMode, tier, hasFeature, login, logout } = useAuth(); const { user, isLoading, isAuthenticated, isAdmin, isLocalMode, tier, hasFeature, login, logout } = useAuth();
const { data: version } = useGetVersion({ const { data: version } = useGetVersion({
query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false }, query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false },
}); });
const links = [ const links = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard }, { href: "/", label: t("nav.home"), icon: LayoutDashboard },
{ href: "/tools", label: "Browse Tools", icon: Wrench }, { href: "/tools", label: t("nav.browseTools"), icon: Wrench },
{ href: "/tools/new", label: "Add Tool", icon: PlusCircle }, { href: "/tools/new", label: t("nav.addTool"), icon: PlusCircle },
{ href: "/analytics", label: "Analytics", icon: BarChart3 }, { href: "/analytics", label: t("nav.analytics"), icon: BarChart3 },
...(hasFeature("watchlist") ? [{ href: "/watchlist", label: "Watchlist", icon: Bookmark }] : []), ...(hasFeature("watchlist") ? [{ href: "/watchlist", label: t("nav.watchlist"), icon: Bookmark }] : []),
...(hasFeature("trash") ? [{ href: "/trash", label: "Trash", icon: Trash2 }] : []), ...(hasFeature("trash") ? [{ href: "/trash", label: t("nav.trash"), icon: Trash2 }] : []),
...(isAdmin ? [{ href: "/admin", label: "Admin", icon: ShieldCheck }] : []), ...(isAdmin ? [{ href: "/admin", label: t("nav.admin"), icon: ShieldCheck }] : []),
...(isAdmin ? [{ href: "/admin/redundancy", label: "Redundancy", icon: AlertTriangle }] : []), ...(isAdmin ? [{ href: "/admin/redundancy", label: t("nav.redundancy"), icon: AlertTriangle }] : []),
]; ];
const activeLink = links.find( const activeLink = links.find(
@@ -30,11 +33,11 @@ export function Layout({ children }: { children: React.ReactNode }) {
); );
const mobileLinks = [ const mobileLinks = [
{ href: "/", label: "Home", icon: LayoutDashboard }, { href: "/", label: t("nav.home"), icon: LayoutDashboard },
{ href: "/tools", label: "Tools", icon: Wrench }, { href: "/tools", label: t("nav.browseTools"), icon: Wrench },
...(hasFeature("watchlist") ? [{ href: "/watchlist", label: "Watchlist", icon: Bookmark }] : []), ...(hasFeature("watchlist") ? [{ href: "/watchlist", label: t("nav.watchlist"), icon: Bookmark }] : []),
...(hasFeature("trash") ? [{ href: "/trash", label: "Trash", icon: Trash2 }] : []), ...(hasFeature("trash") ? [{ href: "/trash", label: t("nav.trash"), icon: Trash2 }] : []),
{ href: "/analytics", label: "Analytics", icon: BarChart3 }, { href: "/analytics", label: t("nav.analytics"), icon: BarChart3 },
].slice(0, 5); ].slice(0, 5);
return ( return (
@@ -94,7 +97,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
data-testid="button-logout" data-testid="button-logout"
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
Sign out {t("auth.signOut")}
</Button> </Button>
</div> </div>
) : ( ) : (
@@ -106,7 +109,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
data-testid="button-login" data-testid="button-login"
> >
<LogIn className="w-4 h-4" /> <LogIn className="w-4 h-4" />
{isLocalMode ? "Sign in" : "Sign in with Keycloak"} {isLocalMode ? t("auth.signIn") : t("auth.signInKeycloak")}
</Button> </Button>
)} )}
</div> </div>
@@ -135,7 +138,10 @@ export function Layout({ children }: { children: React.ReactNode }) {
<h1 className="text-sm font-semibold text-muted-foreground"> <h1 className="text-sm font-semibold text-muted-foreground">
{activeLink?.label ?? "toolr"} {activeLink?.label ?? "toolr"}
</h1> </h1>
<ThemeToggle /> <div className="flex items-center gap-1">
<LanguageSwitcher />
<ThemeToggle />
</div>
</header> </header>
<header className="md:hidden border-b p-4 flex items-center justify-between bg-card"> <header className="md:hidden border-b p-4 flex items-center justify-between bg-card">
<Link href="/" className="flex items-center gap-2 text-primary font-bold text-lg"> <Link href="/" className="flex items-center gap-2 text-primary font-bold text-lg">
@@ -143,6 +149,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
<span>toolr</span> <span>toolr</span>
</Link> </Link>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<LanguageSwitcher />
<ThemeToggle /> <ThemeToggle />
{!isLoading && ( {!isLoading && (
isAuthenticated ? ( isAuthenticated ? (
@@ -152,7 +159,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
) : ( ) : (
<Button variant="outline" size="sm" onClick={() => login(location)} data-testid="button-login-mobile"> <Button variant="outline" size="sm" onClick={() => login(location)} data-testid="button-login-mobile">
<LogIn className="w-4 h-4 mr-1" /> <LogIn className="w-4 h-4 mr-1" />
Sign in {t("auth.signIn")}
</Button> </Button>
) )
)} )}
+37
View File
@@ -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;
+168
View File
@@ -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 (AZ)",
"sortNameDesc": "Name (ZA)",
"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"
}
}
+168
View File
@@ -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 (AZ)",
"sortNameDesc": "Name (ZA)",
"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"
}
}
+18 -16
View File
@@ -15,12 +15,14 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { ShieldAlert, Trophy, Scale } from "lucide-react"; import { ShieldAlert, Trophy, Scale } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
function fmt(v: number | null | undefined): string { function fmt(v: number | null | undefined): string {
return v != null ? v.toFixed(1) : ""; return v != null ? v.toFixed(1) : "";
} }
export default function Compare() { export default function Compare() {
const { t } = useTranslation();
const { hasFeature, isLoading: authLoading } = useAuth(); const { hasFeature, isLoading: authLoading } = useAuth();
const search = useSearch(); const search = useSearch();
@@ -50,10 +52,10 @@ export default function Compare() {
<Layout> <Layout>
<div className="flex flex-col items-center justify-center py-20 gap-4"> <div className="flex flex-col items-center justify-center py-20 gap-4">
<ShieldAlert className="w-12 h-12 text-muted-foreground" /> <ShieldAlert className="w-12 h-12 text-muted-foreground" />
<h2 className="text-2xl font-bold">Compare requires a higher tier</h2> <h2 className="text-2xl font-bold">{t("compare.requiresPremium")}</h2>
<p className="text-muted-foreground">Compare tools side-by-side is available to Premium and Enterprise users.</p> <p className="text-muted-foreground">{t("compare.requiresPremiumSub")}</p>
<Button variant="outline" asChild> <Button variant="outline" asChild>
<a href="/tools">Back to tools</a> <a href="/tools">{t("common.backToTools")}</a>
</Button> </Button>
</div> </div>
</Layout> </Layout>
@@ -77,10 +79,10 @@ export default function Compare() {
<Layout> <Layout>
<div className="flex flex-col items-center justify-center py-20 gap-4"> <div className="flex flex-col items-center justify-center py-20 gap-4">
<Scale className="w-12 h-12 text-muted-foreground" /> <Scale className="w-12 h-12 text-muted-foreground" />
<h2 className="text-2xl font-bold">Nothing to compare</h2> <h2 className="text-2xl font-bold">{t("compare.nothingToCompare")}</h2>
<p className="text-muted-foreground">Select at least one tool to compare and come back here.</p> <p className="text-muted-foreground">{t("compare.nothingToCompareSub")}</p>
<Button variant="outline" asChild> <Button variant="outline" asChild>
<a href="/tools">Browse tools</a> <a href="/tools">{t("common.browseTools")}</a>
</Button> </Button>
</div> </div>
</Layout> </Layout>
@@ -99,20 +101,20 @@ export default function Compare() {
<Layout> <Layout>
<div className="space-y-6 pb-10"> <div className="space-y-6 pb-10">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Compare Tools</h1> <h1 className="text-3xl font-bold tracking-tight mb-2">{t("compare.title")}</h1>
<p className="text-muted-foreground">Comparing {list.length} tool(s) side-by-side.</p> <p className="text-muted-foreground">{t("compare.subtitle", { count: list.length })}</p>
</div> </div>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Side-by-side comparison</CardTitle> <CardTitle>{t("compare.sideBySide")}</CardTitle>
<CardDescription>Best value in each row is highlighted.</CardDescription> <CardDescription>{t("compare.bestValue")}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="overflow-x-auto"> <CardContent className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-44">Attribute</TableHead> <TableHead className="w-44">{t("compare.attribute")}</TableHead>
{list.map((t) => ( {list.map((t) => (
<TableHead key={t.id} className="min-w-[12rem]"> <TableHead key={t.id} className="min-w-[12rem]">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -127,7 +129,7 @@ export default function Compare() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<TableRow> <TableRow>
<TableCell className="font-medium">Rating</TableCell> <TableCell className="font-medium">{t("compare.rating")}</TableCell>
{list.map((t) => ( {list.map((t) => (
<TableCell key={t.id} className={cn(isBest(t.avgCombined, bestRating) && "bg-primary/5")}> <TableCell key={t.id} className={cn(isBest(t.avgCombined, bestRating) && "bg-primary/5")}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -155,7 +157,7 @@ export default function Compare() {
))} ))}
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell className="font-medium">Reviews</TableCell> <TableCell className="font-medium">{t("compare.reviews")}</TableCell>
{list.map((t) => ( {list.map((t) => (
<TableCell key={t.id} className={cn(isBest(t.ratingCount, bestReviews) && "bg-primary/5")}> <TableCell key={t.id} className={cn(isBest(t.ratingCount, bestReviews) && "bg-primary/5")}>
<span className="tabular-nums">{t.ratingCount}</span> <span className="tabular-nums">{t.ratingCount}</span>
@@ -163,7 +165,7 @@ export default function Compare() {
))} ))}
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell className="font-medium">Description</TableCell> <TableCell className="font-medium">{t("compare.description")}</TableCell>
{list.map((t) => ( {list.map((t) => (
<TableCell key={t.id} className="text-sm text-muted-foreground align-top"> <TableCell key={t.id} className="text-sm text-muted-foreground align-top">
{t.description} {t.description}
@@ -197,7 +199,7 @@ export default function Compare() {
))} ))}
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell className="font-medium">Last updated</TableCell> <TableCell className="font-medium">{t("compare.lastUpdated")}</TableCell>
{list.map((t) => ( {list.map((t) => (
<TableCell key={t.id} className="text-sm text-muted-foreground"> <TableCell key={t.id} className="text-sm text-muted-foreground">
{new Date(t.updatedAt).toLocaleDateString()} {new Date(t.updatedAt).toLocaleDateString()}
@@ -210,7 +212,7 @@ export default function Compare() {
</Card> </Card>
<Button variant="outline" asChild> <Button variant="outline" asChild>
<Link to="/tools">Back to browse</Link> <Link to="/tools">{t("compare.backToBrowse")}</Link>
</Button> </Button>
</div> </div>
</Layout> </Layout>
+19 -17
View File
@@ -11,8 +11,10 @@ import { ToolCard } from "@/components/tool-card";
import { Star, Wrench, MessageSquare, ArrowRight } from "lucide-react"; import { Star, Wrench, MessageSquare, ArrowRight } from "lucide-react";
import { Link } from "wouter"; import { Link } from "wouter";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
export default function Home() { export default function Home() {
const { t } = useTranslation();
const { data: summary, isLoading: loadingSummary } = useGetAnalyticsSummary(); const { data: summary, isLoading: loadingSummary } = useGetAnalyticsSummary();
const { data: topTools, isLoading: loadingTopTools } = useGetTopTools({ limit: 4, metric: GetTopToolsMetric.combined }); const { data: topTools, isLoading: loadingTopTools } = useGetTopTools({ limit: 4, metric: GetTopToolsMetric.combined });
const { data: recentTools, isLoading: loadingRecent } = useListTools({ sort: "newest" }); const { data: recentTools, isLoading: loadingRecent } = useListTools({ sort: "newest" });
@@ -21,8 +23,8 @@ export default function Home() {
<Layout> <Layout>
<div className="space-y-8 pb-8"> <div className="space-y-8 pb-8">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Welcome to toolr</h1> <h1 className="text-3xl font-bold tracking-tight mb-2">{t("home.welcome")}</h1>
<p className="text-muted-foreground">The community hub where engineers honestly rate the tools they use daily.</p> <p className="text-muted-foreground">{t("home.tagline")}</p>
</div> </div>
{/* Stats Banner */} {/* Stats Banner */}
@@ -33,7 +35,7 @@ export default function Home() {
<Wrench className="w-6 h-6" /> <Wrench className="w-6 h-6" />
</div> </div>
<div> <div>
<p className="text-sm font-medium text-muted-foreground mb-1">Total Tools</p> <p className="text-sm font-medium text-muted-foreground mb-1">{t("home.totalTools")}</p>
{loadingSummary ? ( {loadingSummary ? (
<Skeleton className="h-8 w-16" /> <Skeleton className="h-8 w-16" />
) : ( ) : (
@@ -49,7 +51,7 @@ export default function Home() {
<MessageSquare className="w-6 h-6" /> <MessageSquare className="w-6 h-6" />
</div> </div>
<div> <div>
<p className="text-sm font-medium text-muted-foreground mb-1">Total Ratings</p> <p className="text-sm font-medium text-muted-foreground mb-1">{t("home.totalRatings")}</p>
{loadingSummary ? ( {loadingSummary ? (
<Skeleton className="h-8 w-16" /> <Skeleton className="h-8 w-16" />
) : ( ) : (
@@ -65,7 +67,7 @@ export default function Home() {
<Star className="w-6 h-6" /> <Star className="w-6 h-6" />
</div> </div>
<div> <div>
<p className="text-sm font-medium text-muted-foreground mb-1">Avg Rating</p> <p className="text-sm font-medium text-muted-foreground mb-1">{t("home.avgRating")}</p>
{loadingSummary ? ( {loadingSummary ? (
<Skeleton className="h-8 w-16" /> <Skeleton className="h-8 w-16" />
) : ( ) : (
@@ -80,12 +82,12 @@ export default function Home() {
<section> <section>
<div className="flex justify-between items-end mb-4"> <div className="flex justify-between items-end mb-4">
<div> <div>
<h2 className="text-2xl font-bold tracking-tight mb-1">Top Rated Tools</h2> <h2 className="text-2xl font-bold tracking-tight mb-1">{t("home.topRated")}</h2>
<p className="text-muted-foreground text-sm">Highest combined usefulness and usability.</p> <p className="text-muted-foreground text-sm">{t("home.topRatedSub")}</p>
</div> </div>
<Button variant="ghost" className="text-primary hidden sm:flex" asChild> <Button variant="ghost" className="text-primary hidden sm:flex" asChild>
<Link href="/tools?sort=top_rated"> <Link href="/tools?sort=top_rated">
View all <ArrowRight className="w-4 h-4 ml-2" /> {t("common.viewAll")} <ArrowRight className="w-4 h-4 ml-2" />
</Link> </Link>
</Button> </Button>
</div> </div>
@@ -106,10 +108,10 @@ export default function Home() {
<Card className="bg-muted/30 border-dashed"> <Card className="bg-muted/30 border-dashed">
<CardContent className="flex flex-col items-center justify-center p-12 text-center"> <CardContent className="flex flex-col items-center justify-center p-12 text-center">
<Star className="w-12 h-12 text-muted-foreground/30 mb-4" /> <Star className="w-12 h-12 text-muted-foreground/30 mb-4" />
<h3 className="text-lg font-medium">No ratings yet</h3> <h3 className="text-lg font-medium">{t("home.noRatingsYet")}</h3>
<p className="text-muted-foreground max-w-sm mt-1 mb-4">Be the first to rate a tool and help the community.</p> <p className="text-muted-foreground max-w-sm mt-1 mb-4">{t("home.noRatingsSub")}</p>
<Button asChild> <Button asChild>
<Link href="/tools">Browse Tools</Link> <Link href="/tools">{t("common.browseTools")}</Link>
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -120,12 +122,12 @@ export default function Home() {
<section> <section>
<div className="flex justify-between items-end mb-4"> <div className="flex justify-between items-end mb-4">
<div> <div>
<h2 className="text-2xl font-bold tracking-tight mb-1">Recently Added</h2> <h2 className="text-2xl font-bold tracking-tight mb-1">{t("home.recentlyAdded")}</h2>
<p className="text-muted-foreground text-sm">The latest additions to the catalog.</p> <p className="text-muted-foreground text-sm">{t("home.recentlyAddedSub")}</p>
</div> </div>
<Button variant="ghost" className="text-primary hidden sm:flex" asChild> <Button variant="ghost" className="text-primary hidden sm:flex" asChild>
<Link href="/tools?sort=newest"> <Link href="/tools?sort=newest">
View all <ArrowRight className="w-4 h-4 ml-2" /> {t("common.viewAll")} <ArrowRight className="w-4 h-4 ml-2" />
</Link> </Link>
</Button> </Button>
</div> </div>
@@ -146,10 +148,10 @@ export default function Home() {
<Card className="bg-muted/30 border-dashed"> <Card className="bg-muted/30 border-dashed">
<CardContent className="flex flex-col items-center justify-center p-12 text-center"> <CardContent className="flex flex-col items-center justify-center p-12 text-center">
<Wrench className="w-12 h-12 text-muted-foreground/30 mb-4" /> <Wrench className="w-12 h-12 text-muted-foreground/30 mb-4" />
<h3 className="text-lg font-medium">No tools found</h3> <h3 className="text-lg font-medium">{t("home.noTools")}</h3>
<p className="text-muted-foreground max-w-sm mt-1 mb-4">There are no tools in the database yet.</p> <p className="text-muted-foreground max-w-sm mt-1 mb-4">{t("home.noToolsSub")}</p>
<Button asChild> <Button asChild>
<Link href="/tools/new">Add a Tool</Link> <Link href="/tools/new">{t("browse.addTool")}</Link>
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
+10 -8
View File
@@ -8,8 +8,10 @@ import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
import { Wrench, AlertCircle } from "lucide-react"; import { Wrench, AlertCircle } from "lucide-react";
import { useTranslation } from "react-i18next";
export default function Login() { export default function Login() {
const { t } = useTranslation();
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
const search = useSearch(); const search = useSearch();
const params = new URLSearchParams(search); const params = new URLSearchParams(search);
@@ -33,7 +35,7 @@ export default function Login() {
setLocation(returnTo); setLocation(returnTo);
}, },
onError: (err) => { 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() {
<Wrench className="w-7 h-7" /> <Wrench className="w-7 h-7" />
<span>toolr</span> <span>toolr</span>
</Link> </Link>
<p className="text-muted-foreground text-sm">Sign in to your account</p> <p className="text-muted-foreground text-sm">{t("auth.loginSubtitle")}</p>
</div> </div>
<Card> <Card>
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<CardTitle className="text-lg">Sign in</CardTitle> <CardTitle className="text-lg">{t("auth.signIn")}</CardTitle>
<CardDescription>Enter your credentials to continue</CardDescription> <CardDescription>{t("auth.loginDescription")}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="username">Username</Label> <Label htmlFor="username">{t("auth.username")}</Label>
<Input <Input
id="username" id="username"
type="text" type="text"
@@ -73,7 +75,7 @@ export default function Login() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">Password</Label> <Label htmlFor="password">{t("auth.password")}</Label>
<Input <Input
id="password" id="password"
type="password" type="password"
@@ -95,7 +97,7 @@ export default function Login() {
className="w-full" className="w-full"
disabled={localLogin.isPending} disabled={localLogin.isPending}
> >
{localLogin.isPending ? "Signing in..." : "Sign in"} {localLogin.isPending ? t("common.loading") : t("auth.signIn")}
</Button> </Button>
</form> </form>
</CardContent> </CardContent>
@@ -103,7 +105,7 @@ export default function Login() {
<div className="text-center"> <div className="text-center">
<Link href="/" className="text-sm text-muted-foreground hover:text-primary transition-colors"> <Link href="/" className="text-sm text-muted-foreground hover:text-primary transition-colors">
Back to Home {t("notFound.backHome")}
</Link> </Link>
</div> </div>
</div> </div>
+4 -2
View File
@@ -2,8 +2,10 @@ import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Wrench, ArrowLeft } from "lucide-react"; import { Wrench, ArrowLeft } from "lucide-react";
import { Link } from "wouter"; import { Link } from "wouter";
import { useTranslation } from "react-i18next";
export default function NotFound() { export default function NotFound() {
const { t } = useTranslation();
return ( return (
<div className="min-h-screen w-full flex items-center justify-center bg-background"> <div className="min-h-screen w-full flex items-center justify-center bg-background">
<Card className="w-full max-w-md mx-4"> <Card className="w-full max-w-md mx-4">
@@ -13,10 +15,10 @@ export default function NotFound() {
<span>toolr</span> <span>toolr</span>
</Link> </Link>
<h1 className="text-6xl font-bold text-muted-foreground/30 mb-2">404</h1> <h1 className="text-6xl font-bold text-muted-foreground/30 mb-2">404</h1>
<p className="text-muted-foreground mb-6">This page doesn't exist.</p> <p className="text-muted-foreground mb-6">{t("notFound.text")}</p>
<Link href="/"> <Link href="/">
<Button variant="outline" className="gap-2"> <Button variant="outline" className="gap-2">
<ArrowLeft className="w-4 h-4" /> Back to Home <ArrowLeft className="w-4 h-4" /> {t("notFound.backHome")}
</Button> </Button>
</Link> </Link>
</CardContent> </CardContent>
+25 -23
View File
@@ -46,6 +46,7 @@ import { Link } from "wouter";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useWatchlist } from "@/hooks/use-watchlist"; import { useWatchlist } from "@/hooks/use-watchlist";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { useDeleteTool, getListToolsQueryKey } from "@workspace/api-client-react"; import { useDeleteTool, getListToolsQueryKey } from "@workspace/api-client-react";
import { import {
AlertDialog, AlertDialog,
@@ -79,6 +80,7 @@ export default function ToolDetail() {
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const { user, isAdmin, hasFeature } = useAuth(); const { user, isAdmin, hasFeature } = useAuth();
const { t } = useTranslation();
const { isWatched, toggle: toggleWatchlist, canWatchlist } = useWatchlist(); const { isWatched, toggle: toggleWatchlist, canWatchlist } = useWatchlist();
const canManageCosts = hasFeature("costs"); const canManageCosts = hasFeature("costs");
const hasTrash = hasFeature("trash"); const hasTrash = hasFeature("trash");
@@ -355,7 +357,7 @@ export default function ToolDetail() {
<span className="text-muted-foreground self-end mb-1">/ 5</span> <span className="text-muted-foreground self-end mb-1">/ 5</span>
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Based on {tool.ratingCount} reviews {t("detail.basedOnReview", { count: tool.ratingCount })}
</div> </div>
{canWatchlist && ( {canWatchlist && (
@@ -365,14 +367,14 @@ export default function ToolDetail() {
onClick={() => toggleWatchlist(Number(id))} onClick={() => toggleWatchlist(Number(id))}
> >
<Bookmark className={cn("w-4 h-4", isWatched(id) && "fill-current")} /> <Bookmark className={cn("w-4 h-4", isWatched(id) && "fill-current")} />
{isWatched(id) ? "Saved to watchlist" : "Save to watchlist"} {isWatched(id) ? t("detail.savedToWatchlist") : t("detail.saveToWatchlist")}
</Button> </Button>
)} )}
{tool.websiteUrl && ( {tool.websiteUrl && (
<Button asChild className="w-full mt-2" variant="outline"> <Button asChild className="w-full mt-2" variant="outline">
<a href={tool.websiteUrl} target="_blank" rel="noopener noreferrer"> <a href={tool.websiteUrl} target="_blank" rel="noopener noreferrer">
Visit Website <ExternalLink className="w-4 h-4 ml-2" /> {t("detail.visitWebsite")} <ExternalLink className="w-4 h-4 ml-2" />
</a> </a>
</Button> </Button>
)} )}
@@ -381,7 +383,7 @@ export default function ToolDetail() {
<div className="flex gap-2 w-full mt-1"> <div className="flex gap-2 w-full mt-1">
<Button asChild variant="outline" size="sm" className="flex-1 gap-2"> <Button asChild variant="outline" size="sm" className="flex-1 gap-2">
<Link href={`/tools/${id}/edit`}> <Link href={`/tools/${id}/edit`}>
<Pencil className="w-3.5 h-3.5" /> Edit <Pencil className="w-3.5 h-3.5" /> {t("detail.edit")}
</Link> </Link>
</Button> </Button>
<Button <Button
@@ -390,7 +392,7 @@ export default function ToolDetail() {
className="flex-1 gap-2 text-destructive hover:bg-destructive hover:text-destructive-foreground" className="flex-1 gap-2 text-destructive hover:bg-destructive hover:text-destructive-foreground"
onClick={() => setDeleteOpen(true)} onClick={() => setDeleteOpen(true)}
> >
<Trash2 className="w-3.5 h-3.5" /> Delete <Trash2 className="w-3.5 h-3.5" /> {t("detail.delete")}
</Button> </Button>
</div> </div>
)} )}
@@ -556,10 +558,10 @@ export default function ToolDetail() {
{tool && ( {tool && (
<div className="space-y-4 mt-8"> <div className="space-y-4 mt-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-xl font-bold">Costs</h3> <h3 className="text-xl font-bold">{t("detail.costs")}</h3>
{canManageCosts && ( {canManageCosts && (
<Button variant="outline" size="sm" onClick={() => { resetCostForm(); setCostDialogOpen(true); }} className="gap-2"> <Button variant="outline" size="sm" onClick={() => { resetCostForm(); setCostDialogOpen(true); }} className="gap-2">
<Plus className="w-4 h-4" /> Add Cost <Plus className="w-4 h-4" /> {t("detail.addCost")}
</Button> </Button>
)} )}
</div> </div>
@@ -607,7 +609,7 @@ export default function ToolDetail() {
<Dialog open={costDialogOpen} onOpenChange={(o) => { if (!o) setCostDialogOpen(false); }}> <Dialog open={costDialogOpen} onOpenChange={(o) => { if (!o) setCostDialogOpen(false); }}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{editCost ? "Edit Cost" : "Add Cost"}</DialogTitle> <DialogTitle>{editCost ? t("detail.edit") + " " + t("detail.costs") : t("detail.addCost")}</DialogTitle>
<DialogDescription>Manage license cost information for this tool.</DialogDescription> <DialogDescription>Manage license cost information for this tool.</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
@@ -674,19 +676,19 @@ export default function ToolDetail() {
<div className="space-y-6"> <div className="space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Rating Breakdown</CardTitle> <CardTitle>{t("detail.ratingBreakdown")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div> <div>
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<span className="font-medium">Usefulness</span> <span className="font-medium">{t("detail.usefulness")}</span>
<span className="font-bold">{tool.avgUsefulness ? tool.avgUsefulness.toFixed(1) : "0.0"}</span> <span className="font-bold">{tool.avgUsefulness ? tool.avgUsefulness.toFixed(1) : "0.0"}</span>
</div> </div>
<Progress value={((tool.avgUsefulness || 0) / 5) * 100} className="h-2" /> <Progress value={((tool.avgUsefulness || 0) / 5) * 100} className="h-2" />
</div> </div>
<div> <div>
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<span className="font-medium">Usability</span> <span className="font-medium">{t("detail.usability")}</span>
<span className="font-bold">{tool.avgUsability ? tool.avgUsability.toFixed(1) : "0.0"}</span> <span className="font-bold">{tool.avgUsability ? tool.avgUsability.toFixed(1) : "0.0"}</span>
</div> </div>
<Progress value={((tool.avgUsability || 0) / 5) * 100} className="h-2" /> <Progress value={((tool.avgUsability || 0) / 5) * 100} className="h-2" />
@@ -696,7 +698,7 @@ export default function ToolDetail() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Score Distribution</CardTitle> <CardTitle>{t("detail.scoreDistribution")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loadingDistribution ? ( {loadingDistribution ? (
@@ -718,7 +720,7 @@ export default function ToolDetail() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Score Trend</CardTitle> <CardTitle>{t("detail.scoreTrend")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loadingRatingHistory ? ( {loadingRatingHistory ? (
@@ -739,7 +741,7 @@ export default function ToolDetail() {
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground">Not enough ratings yet to show a trend.</p> <p className="text-sm text-muted-foreground">{t("detail.notEnoughRatings")}</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -748,16 +750,16 @@ export default function ToolDetail() {
{/* Right Column: Reviews */} {/* Right Column: Reviews */}
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h3 className="text-2xl font-bold">Reviews</h3> <h3 className="text-2xl font-bold">{t("detail.reviews")}</h3>
{!isReviewFormOpen && user && ( {!isReviewFormOpen && user && (
<Button onClick={() => setIsReviewFormOpen(true)}>Write a Review</Button> <Button onClick={() => setIsReviewFormOpen(true)}>{t("detail.addReview")}</Button>
)} )}
</div> </div>
{isReviewFormOpen && ( {isReviewFormOpen && (
<Card className="border-primary shadow-sm"> <Card className="border-primary shadow-sm">
<CardHeader> <CardHeader>
<CardTitle>Write a Review</CardTitle> <CardTitle>{t("detail.addReview")}</CardTitle>
<CardDescription>Share your experience with {tool.name}</CardDescription> <CardDescription>Share your experience with {tool.name}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -769,7 +771,7 @@ export default function ToolDetail() {
name="usefulness" name="usefulness"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Usefulness</FormLabel> <FormLabel>{t("detail.usefulness")}</FormLabel>
<div className="py-2"> <div className="py-2">
<RatingStars <RatingStars
value={field.value} value={field.value}
@@ -787,7 +789,7 @@ export default function ToolDetail() {
name="usability" name="usability"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Usability</FormLabel> <FormLabel>{t("detail.usability")}</FormLabel>
<div className="py-2"> <div className="py-2">
<RatingStars <RatingStars
value={field.value} value={field.value}
@@ -844,7 +846,7 @@ export default function ToolDetail() {
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={createRating.isPending}> <Button type="submit" disabled={createRating.isPending}>
{createRating.isPending ? "Submitting..." : "Submit Review"} {createRating.isPending ? t("common.loading") : t("detail.submit")}
</Button> </Button>
</div> </div>
</form> </form>
@@ -873,11 +875,11 @@ export default function ToolDetail() {
<div className="flex gap-6 mb-4"> <div className="flex gap-6 mb-4">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">Usefulness</span> <span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">{t("detail.usefulness")}</span>
<RatingStars value={rating.usefulness} size="sm" /> <RatingStars value={rating.usefulness} size="sm" />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">Usability</span> <span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">{t("detail.usability")}</span>
<RatingStars value={rating.usability} size="sm" /> <RatingStars value={rating.usability} size="sm" />
</div> </div>
</div> </div>
@@ -893,7 +895,7 @@ export default function ToolDetail() {
) : ( ) : (
<div className="text-center py-12 bg-muted/30 border border-dashed rounded-xl"> <div className="text-center py-12 bg-muted/30 border border-dashed rounded-xl">
<Star className="w-12 h-12 text-muted-foreground/30 mx-auto mb-3" /> <Star className="w-12 h-12 text-muted-foreground/30 mx-auto mb-3" />
<h4 className="text-lg font-medium">No reviews yet</h4> <h4 className="text-lg font-medium">{t("detail.noReviews")}</h4>
<p className="text-muted-foreground mt-1">Be the first to share your thoughts on this tool.</p> <p className="text-muted-foreground mt-1">Be the first to share your thoughts on this tool.</p>
</div> </div>
)} )}
+25 -24
View File
@@ -19,6 +19,7 @@ import { useBrowsePreferences, isViewMode, isDensity } from "@/hooks/use-browse-
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useWatchlist } from "@/hooks/use-watchlist"; import { useWatchlist } from "@/hooks/use-watchlist";
import { CompareBar } from "@/components/compare-bar"; import { CompareBar } from "@/components/compare-bar";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -30,13 +31,13 @@ import { cn } from "@/lib/utils";
const SORT_VALUES = new Set<string>(Object.values(ListToolsSort)); const SORT_VALUES = new Set<string>(Object.values(ListToolsSort));
const SORT_LABELS: Record<string, string> = { const SORT_KEYS: Record<string, string> = {
[ListToolsSort.newest]: "Newest", [ListToolsSort.newest]: "browse.sortNewest",
[ListToolsSort.top_rated]: "Top Rated", [ListToolsSort.top_rated]: "browse.sortTopRated",
[ListToolsSort.most_reviewed]: "Most Reviewed", [ListToolsSort.most_reviewed]: "browse.sortMostReviewed",
[ListToolsSort.name_asc]: "Name (AZ)", [ListToolsSort.name_asc]: "browse.sortNameAsc",
[ListToolsSort.name_desc]: "Name (ZA)", [ListToolsSort.name_desc]: "browse.sortNameDesc",
[ListToolsSort.recently_updated]: "Recently Updated", [ListToolsSort.recently_updated]: "browse.sortRecentlyUpdated",
}; };
function SortHeader({ function SortHeader({
@@ -77,6 +78,7 @@ function SortHeader({
export default function ToolsBrowse() { export default function ToolsBrowse() {
const [, navigate] = useLocation(); const [, navigate] = useLocation();
const urlSearch = useSearch(); const urlSearch = useSearch();
const { t } = useTranslation();
const initialParams = useMemo(() => new URLSearchParams(urlSearch), [urlSearch]); const initialParams = useMemo(() => new URLSearchParams(urlSearch), [urlSearch]);
const initialSearch = initialParams.get("search") ?? ""; const initialSearch = initialParams.get("search") ?? "";
@@ -228,11 +230,11 @@ export default function ToolsBrowse() {
<div className="space-y-6 pb-8"> <div className="space-y-6 pb-8">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4"> <div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Browse Tools</h1> <h1 className="text-3xl font-bold tracking-tight mb-2">{t("nav.browseTools")}</h1>
<p className="text-muted-foreground">Discover and evaluate the best tools for your stack.</p> <p className="text-muted-foreground">{t("browse.subtitle")}</p>
</div> </div>
<Button asChild> <Button asChild>
<Link href="/tools/new">Add a Tool</Link> <Link href="/tools/new">{t("browse.addTool")}</Link>
</Button> </Button>
</div> </div>
@@ -242,7 +244,7 @@ export default function ToolsBrowse() {
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input <Input
ref={searchInputRef} ref={searchInputRef}
placeholder='Search tools… (press "/" to focus)' placeholder={`${t("common.search")}… (press "/" to focus)`}
className="pl-9 w-full" className="pl-9 w-full"
value={searchInput} value={searchInput}
onChange={(e) => setSearchInput(e.target.value)} onChange={(e) => setSearchInput(e.target.value)}
@@ -256,7 +258,7 @@ export default function ToolsBrowse() {
<SelectValue placeholder="All Categories" /> <SelectValue placeholder="All Categories" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All Categories</SelectItem> <SelectItem value="all">{t("browse.allCategories")}</SelectItem>
{!loadingCategories && {!loadingCategories &&
categories?.map((c) => ( categories?.map((c) => (
<SelectItem key={c} value={c}> <SelectItem key={c} value={c}>
@@ -273,7 +275,7 @@ export default function ToolsBrowse() {
<SelectContent> <SelectContent>
{Object.values(ListToolsSort).map((v) => ( {Object.values(ListToolsSort).map((v) => (
<SelectItem key={v} value={v}> <SelectItem key={v} value={v}>
{SORT_LABELS[v]} {t(SORT_KEYS[v])}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -329,7 +331,7 @@ export default function ToolsBrowse() {
)} )}
{sort !== ListToolsSort.newest && ( {sort !== ListToolsSort.newest && (
<Badge variant="secondary" className="gap-1 pl-2.5 pr-1.5 py-1"> <Badge variant="secondary" className="gap-1 pl-2.5 pr-1.5 py-1">
{SORT_LABELS[sort]} {t(SORT_KEYS[sort])}
<button <button
onClick={() => setSort(ListToolsSort.newest)} onClick={() => setSort(ListToolsSort.newest)}
className="rounded-sm hover:bg-muted p-0.5" className="rounded-sm hover:bg-muted p-0.5"
@@ -385,7 +387,7 @@ export default function ToolsBrowse() {
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center text-sm text-muted-foreground"> <div className="flex items-center text-sm text-muted-foreground">
<SlidersHorizontal className="w-4 h-4 mr-2" /> <SlidersHorizontal className="w-4 h-4 mr-2" />
{loadingTools ? "Loading tools…" : `Showing ${allTools.length} tool${allTools.length === 1 ? "" : "s"}`} {loadingTools ? t("common.loading") : t("browse.showingTool", { count: allTools.length })}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<DensityToggle value={density} onValueChange={setDensity} /> <DensityToggle value={density} onValueChange={setDensity} />
@@ -433,20 +435,20 @@ export default function ToolsBrowse() {
<div className={cn("grid items-center gap-3 border-b bg-muted sticky top-0 z-10 px-4", TABLE_GRID)}> <div className={cn("grid items-center gap-3 border-b bg-muted sticky top-0 z-10 px-4", TABLE_GRID)}>
<div className="py-2.5"> <div className="py-2.5">
<SortHeader active={sortActive(ListToolsSort.name_asc) || sortActive(ListToolsSort.name_desc)} direction={nameDir} onClick={toggleNameSort}> <SortHeader active={sortActive(ListToolsSort.name_asc) || sortActive(ListToolsSort.name_desc)} direction={nameDir} onClick={toggleNameSort}>
Tool {t("browse.tool")}
</SortHeader> </SortHeader>
</div> </div>
<div className="py-2.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <div className="py-2.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Category {t("browse.category")}
</div> </div>
<div className="py-2.5"> <div className="py-2.5">
<SortHeader active={sortActive(ListToolsSort.top_rated)} onClick={() => setSort(ListToolsSort.top_rated)} align="right"> <SortHeader active={sortActive(ListToolsSort.top_rated)} onClick={() => setSort(ListToolsSort.top_rated)} align="right">
Rating {t("browse.rating")}
</SortHeader> </SortHeader>
</div> </div>
<div className="py-2.5"> <div className="py-2.5">
<SortHeader active={sortActive(ListToolsSort.most_reviewed)} onClick={() => setSort(ListToolsSort.most_reviewed)} align="right"> <SortHeader active={sortActive(ListToolsSort.most_reviewed)} onClick={() => setSort(ListToolsSort.most_reviewed)} align="right">
Reviews {t("browse.reviews")}
</SortHeader> </SortHeader>
</div> </div>
</div> </div>
@@ -486,19 +488,18 @@ export default function ToolsBrowse() {
<div className="bg-muted p-4 rounded-full mb-4"> <div className="bg-muted p-4 rounded-full mb-4">
<Wrench className="w-8 h-8 text-muted-foreground" /> <Wrench className="w-8 h-8 text-muted-foreground" />
</div> </div>
<h3 className="text-xl font-medium mb-2">No tools found</h3> <h3 className="text-xl font-medium mb-2">{t("browse.noToolsFound")}</h3>
<p className="text-muted-foreground max-w-md mx-auto mb-6"> <p className="text-muted-foreground max-w-md mx-auto mb-6">
We couldn't find any tools matching your current filters. Try adjusting your search criteria or add a {t("browse.noToolsMatch")}
new tool.
</p> </p>
<div className="flex gap-4"> <div className="flex gap-4">
{hasFilters && ( {hasFilters && (
<Button variant="outline" onClick={clearFilters}> <Button variant="outline" onClick={clearFilters}>
Clear Filters {t("filter.clearFilters")}
</Button> </Button>
)} )}
<Button asChild> <Button asChild>
<Link href="/tools/new">Add Tool</Link> <Link href="/tools/new">{t("browse.addTool")}</Link>
</Button> </Button>
</div> </div>
</div> </div>
+18 -16
View File
@@ -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 { 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 { Trash2, RotateCcw, Search, ShieldAlert, Trash as TrashIcon, RefreshCcw } from "lucide-react";
import { format } from "date-fns"; import { format } from "date-fns";
import { useTranslation } from "react-i18next";
export default function Trash() { export default function Trash() {
const { t } = useTranslation();
const { user, isAdmin, hasFeature, isLoading: authLoading } = useAuth(); const { user, isAdmin, hasFeature, isLoading: authLoading } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -139,10 +141,10 @@ export default function Trash() {
<Layout> <Layout>
<div className="flex flex-col items-center justify-center py-20 gap-4"> <div className="flex flex-col items-center justify-center py-20 gap-4">
<ShieldAlert className="w-12 h-12 text-muted-foreground" /> <ShieldAlert className="w-12 h-12 text-muted-foreground" />
<h2 className="text-2xl font-bold">Trash requires a higher tier</h2> <h2 className="text-2xl font-bold">{t("trash.requiresPremium")}</h2>
<p className="text-muted-foreground">The trash is available to Premium and Enterprise users.</p> <p className="text-muted-foreground">{t("trash.requiresPremiumSub")}</p>
<Button variant="outline" asChild> <Button variant="outline" asChild>
<a href="/tools">Back to tools</a> <a href="/tools">{t("common.backToTools")}</a>
</Button> </Button>
</div> </div>
</Layout> </Layout>
@@ -155,16 +157,16 @@ export default function Trash() {
<Layout> <Layout>
<div className="space-y-6 pb-10"> <div className="space-y-6 pb-10">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Trash</h1> <h1 className="text-3xl font-bold tracking-tight mb-2">{t("trash.title")}</h1>
<p className="text-muted-foreground">Deleted tools are kept here until they are restored or permanently removed.</p> <p className="text-muted-foreground">{t("trash.subtitle")}</p>
</div> </div>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between gap-4 flex-wrap"> <CardHeader className="flex flex-row items-center justify-between gap-4 flex-wrap">
<div className="space-y-1.5"> <div className="space-y-1.5">
<CardTitle>Trashed Tools</CardTitle> <CardTitle>{t("trash.trashedTools")}</CardTitle>
<CardDescription> <CardDescription>
{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 })}
</CardDescription> </CardDescription>
</div> </div>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
@@ -172,7 +174,7 @@ export default function Trash() {
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" /> <Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input <Input
className="pl-8 w-56" className="pl-8 w-56"
placeholder="Search trash…" placeholder={`${t("common.search")}`}
value={searchInput} value={searchInput}
onChange={(e) => setSearchInput(e.target.value)} onChange={(e) => setSearchInput(e.target.value)}
/> />
@@ -183,7 +185,7 @@ export default function Trash() {
disabled={selectedIds.length === 0 || restore.isPending} disabled={selectedIds.length === 0 || restore.isPending}
onClick={() => handleRestore(selectedIds)} onClick={() => handleRestore(selectedIds)}
> >
<RotateCcw className="w-4 h-4 mr-2" /> Restore ({selectedIds.length}) <RotateCcw className="w-4 h-4 mr-2" /> {t("trash.restore")} ({selectedIds.length})
</Button> </Button>
{isAdmin && ( {isAdmin && (
<> <>
@@ -193,7 +195,7 @@ export default function Trash() {
disabled={selectedIds.length === 0 || deletePermanent.isPending} disabled={selectedIds.length === 0 || deletePermanent.isPending}
onClick={() => setConfirmDelete(true)} onClick={() => setConfirmDelete(true)}
> >
<TrashIcon className="w-4 h-4 mr-2" /> Delete permanently ({selectedIds.length}) <TrashIcon className="w-4 h-4 mr-2" /> {t("trash.deletePermanently")} ({selectedIds.length})
</Button> </Button>
<Button <Button
size="sm" size="sm"
@@ -201,7 +203,7 @@ export default function Trash() {
disabled={trashed.length === 0 || empty.isPending} disabled={trashed.length === 0 || empty.isPending}
onClick={() => setConfirmEmpty(true)} onClick={() => setConfirmEmpty(true)}
> >
<Trash2 className="w-4 h-4 mr-2" /> Empty trash <Trash2 className="w-4 h-4 mr-2" /> {t("trash.emptyTrash")}
</Button> </Button>
</> </>
)} )}
@@ -215,7 +217,7 @@ export default function Trash() {
) : trashed.length === 0 ? ( ) : trashed.length === 0 ? (
<div className="text-sm text-muted-foreground py-8 text-center flex flex-col items-center gap-2"> <div className="text-sm text-muted-foreground py-8 text-center flex flex-col items-center gap-2">
<RefreshCcw className="w-6 h-6 text-muted-foreground/50" /> <RefreshCcw className="w-6 h-6 text-muted-foreground/50" />
{search ? "No tools match your search." : "The trash is empty."} {search ? t("trash.noMatch") : t("trash.empty")}
</div> </div>
) : ( ) : (
<Table> <Table>
@@ -230,8 +232,8 @@ export default function Trash() {
</TableHead> </TableHead>
<TableHead>Name</TableHead> <TableHead>Name</TableHead>
<TableHead>Category</TableHead> <TableHead>Category</TableHead>
<TableHead>Deleted at</TableHead> <TableHead>{t("trash.deletedAt")}</TableHead>
<TableHead>Deleted by</TableHead> <TableHead>{t("trash.deletedBy")}</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -294,7 +296,7 @@ export default function Trash() {
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => handleDeletePermanent(selectedIds)} onClick={() => handleDeletePermanent(selectedIds)}
> >
Delete permanently {t("trash.deletePermanently")}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -314,7 +316,7 @@ export default function Trash() {
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleEmpty} onClick={handleEmpty}
> >
Empty trash {t("trash.emptyTrash")}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
+9 -7
View File
@@ -9,8 +9,10 @@ import { ToolCard } from "@/components/tool-card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { ShieldAlert, Bookmark } from "lucide-react"; import { ShieldAlert, Bookmark } from "lucide-react";
import { useTranslation } from "react-i18next";
export default function Watchlist() { export default function Watchlist() {
const { t } = useTranslation();
const { hasFeature, isLoading: authLoading } = useAuth(); const { hasFeature, isLoading: authLoading } = useAuth();
const { toggle } = useWatchlist(); const { toggle } = useWatchlist();
const canWatchlist = hasFeature("watchlist"); const canWatchlist = hasFeature("watchlist");
@@ -24,10 +26,10 @@ export default function Watchlist() {
<Layout> <Layout>
<div className="flex flex-col items-center justify-center py-20 gap-4"> <div className="flex flex-col items-center justify-center py-20 gap-4">
<ShieldAlert className="w-12 h-12 text-muted-foreground" /> <ShieldAlert className="w-12 h-12 text-muted-foreground" />
<h2 className="text-2xl font-bold">Watchlist requires a higher tier</h2> <h2 className="text-2xl font-bold">{t("watchlist.requiresPremium")}</h2>
<p className="text-muted-foreground">Saving tools to your watchlist is available to Premium and Enterprise users.</p> <p className="text-muted-foreground">{t("watchlist.requiresPremiumSub")}</p>
<Button variant="outline" asChild> <Button variant="outline" asChild>
<a href="/tools">Back to tools</a> <a href="/tools">{t("common.backToTools")}</a>
</Button> </Button>
</div> </div>
</Layout> </Layout>
@@ -40,8 +42,8 @@ export default function Watchlist() {
<Layout> <Layout>
<div className="space-y-6 pb-10"> <div className="space-y-6 pb-10">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Watchlist</h1> <h1 className="text-3xl font-bold tracking-tight mb-2">{t("watchlist.title")}</h1>
<p className="text-muted-foreground">Tools you saved for later, with live scores.</p> <p className="text-muted-foreground">{t("watchlist.subtitle")}</p>
</div> </div>
{loading ? ( {loading ? (
@@ -51,9 +53,9 @@ export default function Watchlist() {
) : list.length === 0 ? ( ) : list.length === 0 ? (
<div className="bg-muted/30 border border-dashed rounded-xl py-24 flex flex-col items-center justify-center text-center gap-3"> <div className="bg-muted/30 border border-dashed rounded-xl py-24 flex flex-col items-center justify-center text-center gap-3">
<Bookmark className="w-8 h-8 text-muted-foreground" /> <Bookmark className="w-8 h-8 text-muted-foreground" />
<h3 className="text-xl font-medium">Your watchlist is empty</h3> <h3 className="text-xl font-medium">{t("watchlist.empty")}</h3>
<p className="text-muted-foreground max-w-md mx-auto"> <p className="text-muted-foreground max-w-md mx-auto">
Browse tools and click the bookmark to save them here. {t("watchlist.emptySub")}
</p> </p>
<Button variant="outline" asChild> <Button variant="outline" asChild>
<a href="/tools">Browse tools</a> <a href="/tools">Browse tools</a>
+50
View File
@@ -577,6 +577,9 @@ importers:
framer-motion: framer-motion:
specifier: 'catalog:' specifier: 'catalog:'
version: 12.38.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 12.38.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
i18next:
specifier: ^26.3.6
version: 26.3.6(typescript@5.9.3)
input-otp: input-otp:
specifier: ^1.4.2 specifier: ^1.4.2
version: 1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -598,6 +601,9 @@ importers:
react-hook-form: react-hook-form:
specifier: ^7.55.0 specifier: ^7.55.0
version: 7.75.0(react@19.1.0) version: 7.75.0(react@19.1.0)
react-i18next:
specifier: ^17.0.11
version: 17.0.11(i18next@26.3.6(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.3)
react-icons: react-icons:
specifier: ^5.4.0 specifier: ^5.4.0
version: 5.6.0(react@19.1.0) version: 5.6.0(react@19.1.0)
@@ -2383,6 +2389,9 @@ packages:
help-me@5.0.0: help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
html-parse-stringify@4.0.1:
resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==}
http-errors@2.0.1: http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -2391,6 +2400,14 @@ packages:
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
engines: {node: '>=18.18.0'} engines: {node: '>=18.18.0'}
i18next@26.3.6:
resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==}
peerDependencies:
typescript: ^5 || ^6 || ^7
peerDependenciesMeta:
typescript:
optional: true
iconv-lite@0.7.2: iconv-lite@0.7.2:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2851,6 +2868,22 @@ packages:
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19 react: ^16.8.0 || ^17 || ^18 || ^19
react-i18next@17.0.11:
resolution: {integrity: sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==}
peerDependencies:
i18next: '>= 26.2.0'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
typescript: ^5 || ^6 || ^7
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
typescript:
optional: true
react-icons@5.6.0: react-icons@5.6.0:
resolution: {integrity: sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==} resolution: {integrity: sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==}
peerDependencies: peerDependencies:
@@ -5080,6 +5113,8 @@ snapshots:
help-me@5.0.0: {} help-me@5.0.0: {}
html-parse-stringify@4.0.1: {}
http-errors@2.0.1: http-errors@2.0.1:
dependencies: dependencies:
depd: 2.0.0 depd: 2.0.0
@@ -5090,6 +5125,10 @@ snapshots:
human-signals@8.0.1: {} human-signals@8.0.1: {}
i18next@26.3.6(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
iconv-lite@0.7.2: iconv-lite@0.7.2:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
@@ -5521,6 +5560,17 @@ snapshots:
dependencies: dependencies:
react: 19.1.0 react: 19.1.0
react-i18next@17.0.11(i18next@26.3.6(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.29.2
html-parse-stringify: 4.0.1
i18next: 26.3.6(typescript@5.9.3)
react: 19.1.0
use-sync-external-store: 1.6.0(react@19.1.0)
optionalDependencies:
react-dom: 19.1.0(react@19.1.0)
typescript: 5.9.3
react-icons@5.6.0(react@19.1.0): react-icons@5.6.0(react@19.1.0):
dependencies: dependencies:
react: 19.1.0 react: 19.1.0