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:
@@ -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>
|
||||
)
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user