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
@@ -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>
);
}
+24 -17
View File
@@ -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>
)
)}