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