fix: compact filter popover; feat: i18n (de/en)
Build & Push Docker Image / build (push) Successful in 2m31s
Build & Push Docker Image / build (push) Successful in 2m31s
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}>
|
||||
<Router />
|
||||
</WouterRouter>
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<WouterRouter base={import.meta.env.BASE_URL.replace(/\/$/, "")}>
|
||||
<Router />
|
||||
</WouterRouter>
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput
|
||||
placeholder="Search tools or jump to…"
|
||||
placeholder={`${t("common.search")}…`}
|
||||
value={search}
|
||||
onValueChange={(v) => {
|
||||
setSearch(v);
|
||||
@@ -78,27 +80,27 @@ export function CommandPalette() {
|
||||
</CommandEmpty>
|
||||
<CommandGroup heading="Navigate">
|
||||
<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 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 onSelect={() => go("/analytics")}>
|
||||
<BarChart3 className="mr-2 h-4 w-4" /> Analytics
|
||||
<BarChart3 className="mr-2 h-4 w-4" /> {t("nav.analytics")}
|
||||
</CommandItem>
|
||||
{showWatchlist && (
|
||||
<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>
|
||||
)}
|
||||
{showTrash && (
|
||||
<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>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<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>
|
||||
)}
|
||||
</CommandGroup>
|
||||
|
||||
@@ -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 (
|
||||
<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}
|
||||
className="text-xs text-muted-foreground hover:text-foreground whitespace-nowrap px-1"
|
||||
>
|
||||
Clear
|
||||
{t("common.clear")}
|
||||
</button>
|
||||
</div>
|
||||
{canCompare ? (
|
||||
<Button size="sm" onClick={onCompare} disabled={tools.length < 2}>
|
||||
Compare ({tools.length})
|
||||
{t("browse.compareCount", { count: tools.length })}
|
||||
</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" />
|
||||
Compare
|
||||
{t("browse.compare")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2" data-testid="button-filters">
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
Filters
|
||||
{t("browse.filters")}
|
||||
{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">
|
||||
{activeCount}
|
||||
@@ -43,11 +46,11 @@ export function FilterPopover({
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[340px]" align="start">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{allTags && allTags.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Tags</h4>
|
||||
<ScrollArea className="h-40">
|
||||
<div className="pb-3 border-b border-border/60">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">{t("filter.tags")}</h4>
|
||||
<ScrollArea className="max-h-40">
|
||||
<div className="space-y-1.5 pr-2">
|
||||
{allTags.map((t) => (
|
||||
<Label key={t} className="flex items-center gap-2 text-sm font-normal cursor-pointer">
|
||||
@@ -63,9 +66,9 @@ export function FilterPopover({
|
||||
</div>
|
||||
)}
|
||||
{allFeatures && allFeatures.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Features</h4>
|
||||
<ScrollArea className="h-40">
|
||||
<div className="pb-3 border-b border-border/60">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">{t("filter.features")}</h4>
|
||||
<ScrollArea className="max-h-40">
|
||||
<div className="space-y-1.5 pr-2">
|
||||
{allFeatures.map((f) => (
|
||||
<Label key={f} className="flex items-center gap-2 text-sm font-normal cursor-pointer">
|
||||
@@ -82,7 +85,7 @@ export function FilterPopover({
|
||||
)}
|
||||
<div>
|
||||
<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 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -105,12 +108,12 @@ export function FilterPopover({
|
||||
aria-label="Minimum rating"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="w-full" onClick={onClear}>
|
||||
Clear filters
|
||||
{t("filter.clearFilters")}
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Sign out
|
||||
{t("auth.signOut")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -106,7 +109,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
data-testid="button-login"
|
||||
>
|
||||
<LogIn className="w-4 h-4" />
|
||||
{isLocalMode ? "Sign in" : "Sign in with Keycloak"}
|
||||
{isLocalMode ? t("auth.signIn") : t("auth.signInKeycloak")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -135,7 +138,10 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
<h1 className="text-sm font-semibold text-muted-foreground">
|
||||
{activeLink?.label ?? "toolr"}
|
||||
</h1>
|
||||
<ThemeToggle />
|
||||
<div className="flex items-center gap-1">
|
||||
<LanguageSwitcher />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
<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">
|
||||
@@ -143,6 +149,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
<span>toolr</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-1">
|
||||
<LanguageSwitcher />
|
||||
<ThemeToggle />
|
||||
{!isLoading && (
|
||||
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">
|
||||
<LogIn className="w-4 h-4 mr-1" />
|
||||
Sign in
|
||||
{t("auth.signIn")}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
<Layout>
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
|
||||
<h2 className="text-2xl font-bold">Compare requires a higher tier</h2>
|
||||
<p className="text-muted-foreground">Compare tools side-by-side is available to Premium and Enterprise users.</p>
|
||||
<h2 className="text-2xl font-bold">{t("compare.requiresPremium")}</h2>
|
||||
<p className="text-muted-foreground">{t("compare.requiresPremiumSub")}</p>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="/tools">Back to tools</a>
|
||||
<a href="/tools">{t("common.backToTools")}</a>
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -77,10 +79,10 @@ export default function Compare() {
|
||||
<Layout>
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<Scale className="w-12 h-12 text-muted-foreground" />
|
||||
<h2 className="text-2xl font-bold">Nothing to compare</h2>
|
||||
<p className="text-muted-foreground">Select at least one tool to compare and come back here.</p>
|
||||
<h2 className="text-2xl font-bold">{t("compare.nothingToCompare")}</h2>
|
||||
<p className="text-muted-foreground">{t("compare.nothingToCompareSub")}</p>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="/tools">Browse tools</a>
|
||||
<a href="/tools">{t("common.browseTools")}</a>
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -99,20 +101,20 @@ export default function Compare() {
|
||||
<Layout>
|
||||
<div className="space-y-6 pb-10">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Compare Tools</h1>
|
||||
<p className="text-muted-foreground">Comparing {list.length} tool(s) side-by-side.</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("compare.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("compare.subtitle", { count: list.length })}</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Side-by-side comparison</CardTitle>
|
||||
<CardDescription>Best value in each row is highlighted.</CardDescription>
|
||||
<CardTitle>{t("compare.sideBySide")}</CardTitle>
|
||||
<CardDescription>{t("compare.bestValue")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-44">Attribute</TableHead>
|
||||
<TableHead className="w-44">{t("compare.attribute")}</TableHead>
|
||||
{list.map((t) => (
|
||||
<TableHead key={t.id} className="min-w-[12rem]">
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -127,7 +129,7 @@ export default function Compare() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Rating</TableCell>
|
||||
<TableCell className="font-medium">{t("compare.rating")}</TableCell>
|
||||
{list.map((t) => (
|
||||
<TableCell key={t.id} className={cn(isBest(t.avgCombined, bestRating) && "bg-primary/5")}>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -155,7 +157,7 @@ export default function Compare() {
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Reviews</TableCell>
|
||||
<TableCell className="font-medium">{t("compare.reviews")}</TableCell>
|
||||
{list.map((t) => (
|
||||
<TableCell key={t.id} className={cn(isBest(t.ratingCount, bestReviews) && "bg-primary/5")}>
|
||||
<span className="tabular-nums">{t.ratingCount}</span>
|
||||
@@ -163,7 +165,7 @@ export default function Compare() {
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Description</TableCell>
|
||||
<TableCell className="font-medium">{t("compare.description")}</TableCell>
|
||||
{list.map((t) => (
|
||||
<TableCell key={t.id} className="text-sm text-muted-foreground align-top">
|
||||
{t.description}
|
||||
@@ -197,7 +199,7 @@ export default function Compare() {
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Last updated</TableCell>
|
||||
<TableCell className="font-medium">{t("compare.lastUpdated")}</TableCell>
|
||||
{list.map((t) => (
|
||||
<TableCell key={t.id} className="text-sm text-muted-foreground">
|
||||
{new Date(t.updatedAt).toLocaleDateString()}
|
||||
@@ -210,7 +212,7 @@ export default function Compare() {
|
||||
</Card>
|
||||
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/tools">Back to browse</Link>
|
||||
<Link to="/tools">{t("compare.backToBrowse")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
@@ -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() {
|
||||
<Layout>
|
||||
<div className="space-y-8 pb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Welcome to toolr</h1>
|
||||
<p className="text-muted-foreground">The community hub where engineers honestly rate the tools they use daily.</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("home.welcome")}</h1>
|
||||
<p className="text-muted-foreground">{t("home.tagline")}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Banner */}
|
||||
@@ -33,7 +35,7 @@ export default function Home() {
|
||||
<Wrench className="w-6 h-6" />
|
||||
</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 ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
@@ -49,7 +51,7 @@ export default function Home() {
|
||||
<MessageSquare className="w-6 h-6" />
|
||||
</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 ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
@@ -65,7 +67,7 @@ export default function Home() {
|
||||
<Star className="w-6 h-6" />
|
||||
</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 ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
@@ -80,12 +82,12 @@ export default function Home() {
|
||||
<section>
|
||||
<div className="flex justify-between items-end mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight mb-1">Top Rated Tools</h2>
|
||||
<p className="text-muted-foreground text-sm">Highest combined usefulness and usability.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight mb-1">{t("home.topRated")}</h2>
|
||||
<p className="text-muted-foreground text-sm">{t("home.topRatedSub")}</p>
|
||||
</div>
|
||||
<Button variant="ghost" className="text-primary hidden sm:flex" asChild>
|
||||
<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>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -106,10 +108,10 @@ export default function Home() {
|
||||
<Card className="bg-muted/30 border-dashed">
|
||||
<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" />
|
||||
<h3 className="text-lg font-medium">No ratings yet</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>
|
||||
<h3 className="text-lg font-medium">{t("home.noRatingsYet")}</h3>
|
||||
<p className="text-muted-foreground max-w-sm mt-1 mb-4">{t("home.noRatingsSub")}</p>
|
||||
<Button asChild>
|
||||
<Link href="/tools">Browse Tools</Link>
|
||||
<Link href="/tools">{t("common.browseTools")}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -120,12 +122,12 @@ export default function Home() {
|
||||
<section>
|
||||
<div className="flex justify-between items-end mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight mb-1">Recently Added</h2>
|
||||
<p className="text-muted-foreground text-sm">The latest additions to the catalog.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight mb-1">{t("home.recentlyAdded")}</h2>
|
||||
<p className="text-muted-foreground text-sm">{t("home.recentlyAddedSub")}</p>
|
||||
</div>
|
||||
<Button variant="ghost" className="text-primary hidden sm:flex" asChild>
|
||||
<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>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -146,10 +148,10 @@ export default function Home() {
|
||||
<Card className="bg-muted/30 border-dashed">
|
||||
<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" />
|
||||
<h3 className="text-lg font-medium">No tools found</h3>
|
||||
<p className="text-muted-foreground max-w-sm mt-1 mb-4">There are no tools in the database yet.</p>
|
||||
<h3 className="text-lg font-medium">{t("home.noTools")}</h3>
|
||||
<p className="text-muted-foreground max-w-sm mt-1 mb-4">{t("home.noToolsSub")}</p>
|
||||
<Button asChild>
|
||||
<Link href="/tools/new">Add a Tool</Link>
|
||||
<Link href="/tools/new">{t("browse.addTool")}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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() {
|
||||
<Wrench className="w-7 h-7" />
|
||||
<span>toolr</span>
|
||||
</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>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-lg">Sign in</CardTitle>
|
||||
<CardDescription>Enter your credentials to continue</CardDescription>
|
||||
<CardTitle className="text-lg">{t("auth.signIn")}</CardTitle>
|
||||
<CardDescription>{t("auth.loginDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Label htmlFor="username">{t("auth.username")}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
@@ -73,7 +75,7 @@ export default function Login() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Label htmlFor="password">{t("auth.password")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
@@ -95,7 +97,7 @@ export default function Login() {
|
||||
className="w-full"
|
||||
disabled={localLogin.isPending}
|
||||
>
|
||||
{localLogin.isPending ? "Signing in..." : "Sign in"}
|
||||
{localLogin.isPending ? t("common.loading") : t("auth.signIn")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
@@ -103,7 +105,7 @@ export default function Login() {
|
||||
|
||||
<div className="text-center">
|
||||
<Link href="/" className="text-sm text-muted-foreground hover:text-primary transition-colors">
|
||||
Back to Home
|
||||
{t("notFound.backHome")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen w-full flex items-center justify-center bg-background">
|
||||
<Card className="w-full max-w-md mx-4">
|
||||
@@ -13,10 +15,10 @@ export default function NotFound() {
|
||||
<span>toolr</span>
|
||||
</Link>
|
||||
<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="/">
|
||||
<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>
|
||||
</Link>
|
||||
</CardContent>
|
||||
|
||||
@@ -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() {
|
||||
<span className="text-muted-foreground self-end mb-1">/ 5</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Based on {tool.ratingCount} reviews
|
||||
{t("detail.basedOnReview", { count: tool.ratingCount })}
|
||||
</div>
|
||||
|
||||
{canWatchlist && (
|
||||
@@ -365,14 +367,14 @@ export default function ToolDetail() {
|
||||
onClick={() => toggleWatchlist(Number(id))}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{tool.websiteUrl && (
|
||||
<Button asChild className="w-full mt-2" variant="outline">
|
||||
<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>
|
||||
</Button>
|
||||
)}
|
||||
@@ -381,7 +383,7 @@ export default function ToolDetail() {
|
||||
<div className="flex gap-2 w-full mt-1">
|
||||
<Button asChild variant="outline" size="sm" className="flex-1 gap-2">
|
||||
<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>
|
||||
</Button>
|
||||
<Button
|
||||
@@ -390,7 +392,7 @@ export default function ToolDetail() {
|
||||
className="flex-1 gap-2 text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" /> Delete
|
||||
<Trash2 className="w-3.5 h-3.5" /> {t("detail.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -556,10 +558,10 @@ export default function ToolDetail() {
|
||||
{tool && (
|
||||
<div className="space-y-4 mt-8">
|
||||
<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 && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
@@ -607,7 +609,7 @@ export default function ToolDetail() {
|
||||
<Dialog open={costDialogOpen} onOpenChange={(o) => { if (!o) setCostDialogOpen(false); }}>
|
||||
<DialogContent>
|
||||
<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>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
@@ -674,19 +676,19 @@ export default function ToolDetail() {
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Rating Breakdown</CardTitle>
|
||||
<CardTitle>{t("detail.ratingBreakdown")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<Progress value={((tool.avgUsefulness || 0) / 5) * 100} className="h-2" />
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<Progress value={((tool.avgUsability || 0) / 5) * 100} className="h-2" />
|
||||
@@ -696,7 +698,7 @@ export default function ToolDetail() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Score Distribution</CardTitle>
|
||||
<CardTitle>{t("detail.scoreDistribution")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingDistribution ? (
|
||||
@@ -718,7 +720,7 @@ export default function ToolDetail() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Score Trend</CardTitle>
|
||||
<CardTitle>{t("detail.scoreTrend")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingRatingHistory ? (
|
||||
@@ -739,7 +741,7 @@ export default function ToolDetail() {
|
||||
</ResponsiveContainer>
|
||||
</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>
|
||||
</Card>
|
||||
@@ -748,16 +750,16 @@ export default function ToolDetail() {
|
||||
{/* Right Column: Reviews */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<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 && (
|
||||
<Button onClick={() => setIsReviewFormOpen(true)}>Write a Review</Button>
|
||||
<Button onClick={() => setIsReviewFormOpen(true)}>{t("detail.addReview")}</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isReviewFormOpen && (
|
||||
<Card className="border-primary shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Write a Review</CardTitle>
|
||||
<CardTitle>{t("detail.addReview")}</CardTitle>
|
||||
<CardDescription>Share your experience with {tool.name}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -769,7 +771,7 @@ export default function ToolDetail() {
|
||||
name="usefulness"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Usefulness</FormLabel>
|
||||
<FormLabel>{t("detail.usefulness")}</FormLabel>
|
||||
<div className="py-2">
|
||||
<RatingStars
|
||||
value={field.value}
|
||||
@@ -787,7 +789,7 @@ export default function ToolDetail() {
|
||||
name="usability"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Usability</FormLabel>
|
||||
<FormLabel>{t("detail.usability")}</FormLabel>
|
||||
<div className="py-2">
|
||||
<RatingStars
|
||||
value={field.value}
|
||||
@@ -844,7 +846,7 @@ export default function ToolDetail() {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createRating.isPending}>
|
||||
{createRating.isPending ? "Submitting..." : "Submit Review"}
|
||||
{createRating.isPending ? t("common.loading") : t("detail.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -873,11 +875,11 @@ export default function ToolDetail() {
|
||||
|
||||
<div className="flex gap-6 mb-4">
|
||||
<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" />
|
||||
</div>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -893,7 +895,7 @@ export default function ToolDetail() {
|
||||
) : (
|
||||
<div className="text-center py-12 bg-muted/30 border border-dashed rounded-xl">
|
||||
<Star className="w-12 h-12 text-muted-foreground/30 mx-auto mb-3" />
|
||||
<h4 className="text-lg font-medium">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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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<string>(Object.values(ListToolsSort));
|
||||
|
||||
const SORT_LABELS: Record<string, string> = {
|
||||
[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<string, string> = {
|
||||
[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() {
|
||||
<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>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Browse Tools</h1>
|
||||
<p className="text-muted-foreground">Discover and evaluate the best tools for your stack.</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("nav.browseTools")}</h1>
|
||||
<p className="text-muted-foreground">{t("browse.subtitle")}</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/tools/new">Add a Tool</Link>
|
||||
<Link href="/tools/new">{t("browse.addTool")}</Link>
|
||||
</Button>
|
||||
</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" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder='Search tools… (press "/" to focus)'
|
||||
placeholder={`${t("common.search")}… (press "/" to focus)`}
|
||||
className="pl-9 w-full"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
@@ -256,7 +258,7 @@ export default function ToolsBrowse() {
|
||||
<SelectValue placeholder="All Categories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Categories</SelectItem>
|
||||
<SelectItem value="all">{t("browse.allCategories")}</SelectItem>
|
||||
{!loadingCategories &&
|
||||
categories?.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
@@ -273,7 +275,7 @@ export default function ToolsBrowse() {
|
||||
<SelectContent>
|
||||
{Object.values(ListToolsSort).map((v) => (
|
||||
<SelectItem key={v} value={v}>
|
||||
{SORT_LABELS[v]}
|
||||
{t(SORT_KEYS[v])}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -329,7 +331,7 @@ export default function ToolsBrowse() {
|
||||
)}
|
||||
{sort !== ListToolsSort.newest && (
|
||||
<Badge variant="secondary" className="gap-1 pl-2.5 pr-1.5 py-1">
|
||||
{SORT_LABELS[sort]}
|
||||
{t(SORT_KEYS[sort])}
|
||||
<button
|
||||
onClick={() => setSort(ListToolsSort.newest)}
|
||||
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 items-center text-sm text-muted-foreground">
|
||||
<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 className="flex items-center gap-3">
|
||||
<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="py-2.5">
|
||||
<SortHeader active={sortActive(ListToolsSort.name_asc) || sortActive(ListToolsSort.name_desc)} direction={nameDir} onClick={toggleNameSort}>
|
||||
Tool
|
||||
{t("browse.tool")}
|
||||
</SortHeader>
|
||||
</div>
|
||||
<div className="py-2.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Category
|
||||
{t("browse.category")}
|
||||
</div>
|
||||
<div className="py-2.5">
|
||||
<SortHeader active={sortActive(ListToolsSort.top_rated)} onClick={() => setSort(ListToolsSort.top_rated)} align="right">
|
||||
Rating
|
||||
{t("browse.rating")}
|
||||
</SortHeader>
|
||||
</div>
|
||||
<div className="py-2.5">
|
||||
<SortHeader active={sortActive(ListToolsSort.most_reviewed)} onClick={() => setSort(ListToolsSort.most_reviewed)} align="right">
|
||||
Reviews
|
||||
{t("browse.reviews")}
|
||||
</SortHeader>
|
||||
</div>
|
||||
</div>
|
||||
@@ -486,19 +488,18 @@ export default function ToolsBrowse() {
|
||||
<div className="bg-muted p-4 rounded-full mb-4">
|
||||
<Wrench className="w-8 h-8 text-muted-foreground" />
|
||||
</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">
|
||||
We couldn't find any tools matching your current filters. Try adjusting your search criteria or add a
|
||||
new tool.
|
||||
{t("browse.noToolsMatch")}
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
{hasFilters && (
|
||||
<Button variant="outline" onClick={clearFilters}>
|
||||
Clear Filters
|
||||
{t("filter.clearFilters")}
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild>
|
||||
<Link href="/tools/new">Add Tool</Link>
|
||||
<Link href="/tools/new">{t("browse.addTool")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
<Layout>
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
|
||||
<h2 className="text-2xl font-bold">Trash requires a higher tier</h2>
|
||||
<p className="text-muted-foreground">The trash is available to Premium and Enterprise users.</p>
|
||||
<h2 className="text-2xl font-bold">{t("trash.requiresPremium")}</h2>
|
||||
<p className="text-muted-foreground">{t("trash.requiresPremiumSub")}</p>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="/tools">Back to tools</a>
|
||||
<a href="/tools">{t("common.backToTools")}</a>
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -155,16 +157,16 @@ export default function Trash() {
|
||||
<Layout>
|
||||
<div className="space-y-6 pb-10">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Trash</h1>
|
||||
<p className="text-muted-foreground">Deleted tools are kept here until they are restored or permanently removed.</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("trash.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("trash.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle>Trashed Tools</CardTitle>
|
||||
<CardTitle>{t("trash.trashedTools")}</CardTitle>
|
||||
<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>
|
||||
</div>
|
||||
<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" />
|
||||
<Input
|
||||
className="pl-8 w-56"
|
||||
placeholder="Search trash…"
|
||||
placeholder={`${t("common.search")}…`}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
/>
|
||||
@@ -183,7 +185,7 @@ export default function Trash() {
|
||||
disabled={selectedIds.length === 0 || restore.isPending}
|
||||
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>
|
||||
{isAdmin && (
|
||||
<>
|
||||
@@ -193,7 +195,7 @@ export default function Trash() {
|
||||
disabled={selectedIds.length === 0 || deletePermanent.isPending}
|
||||
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
|
||||
size="sm"
|
||||
@@ -201,7 +203,7 @@ export default function Trash() {
|
||||
disabled={trashed.length === 0 || empty.isPending}
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
@@ -215,7 +217,7 @@ export default function Trash() {
|
||||
) : trashed.length === 0 ? (
|
||||
<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" />
|
||||
{search ? "No tools match your search." : "The trash is empty."}
|
||||
{search ? t("trash.noMatch") : t("trash.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
@@ -230,8 +232,8 @@ export default function Trash() {
|
||||
</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Deleted at</TableHead>
|
||||
<TableHead>Deleted by</TableHead>
|
||||
<TableHead>{t("trash.deletedAt")}</TableHead>
|
||||
<TableHead>{t("trash.deletedBy")}</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -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")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -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")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -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() {
|
||||
<Layout>
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
|
||||
<h2 className="text-2xl font-bold">Watchlist requires a higher tier</h2>
|
||||
<p className="text-muted-foreground">Saving tools to your watchlist is available to Premium and Enterprise users.</p>
|
||||
<h2 className="text-2xl font-bold">{t("watchlist.requiresPremium")}</h2>
|
||||
<p className="text-muted-foreground">{t("watchlist.requiresPremiumSub")}</p>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="/tools">Back to tools</a>
|
||||
<a href="/tools">{t("common.backToTools")}</a>
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -40,8 +42,8 @@ export default function Watchlist() {
|
||||
<Layout>
|
||||
<div className="space-y-6 pb-10">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Watchlist</h1>
|
||||
<p className="text-muted-foreground">Tools you saved for later, with live scores.</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("watchlist.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("watchlist.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -51,9 +53,9 @@ export default function Watchlist() {
|
||||
) : 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">
|
||||
<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">
|
||||
Browse tools and click the bookmark to save them here.
|
||||
{t("watchlist.emptySub")}
|
||||
</p>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="/tools">Browse tools</a>
|
||||
|
||||
Reference in New Issue
Block a user