+
+
+
+
+
+
-
+
{children}
-
-
-
-
+
+
);
}
diff --git a/artifacts/toolrate/src/components/user-menu.tsx b/artifacts/toolrate/src/components/user-menu.tsx
new file mode 100644
index 0000000..3ed4841
--- /dev/null
+++ b/artifacts/toolrate/src/components/user-menu.tsx
@@ -0,0 +1,136 @@
+import { useState } from "react";
+import { useLocation } from "wouter";
+import { useTranslation } from "react-i18next";
+import { useAuth } from "@/hooks/use-auth";
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Bookmark, LogIn, LogOut, Trash2 } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+function initials(name?: string | null): string {
+ if (!name) return "?";
+ const parts = name.trim().split(/\s+/).filter(Boolean);
+ if (parts.length === 0) return "?";
+ return parts
+ .slice(0, 2)
+ .map((p) => p[0]?.toUpperCase() ?? "")
+ .join("");
+}
+
+export function UserMenu() {
+ const [location, navigate] = useLocation();
+ const { t } = useTranslation();
+ const { user, isLoading, isAuthenticated, isAdmin, tier, hasFeature, login, logout } = useAuth();
+ const [open, setOpen] = useState(false);
+
+ const showWatchlist = hasFeature("watchlist");
+ const showTrash = hasFeature("trash");
+ const displayName = user?.name || user?.preferredUsername || "User";
+
+ if (isLoading) {
+ return (
+
+
+
+
+ );
+ }
+
+ if (!isAuthenticated || !user) {
+ return (
+
+ );
+ }
+
+ function go(path: string) {
+ setOpen(false);
+ navigate(path);
+ }
+
+ return (
+
+
+
+
+
+
+
+ {displayName}
+ {user.email && {user.email}}
+
+
+ {isAdmin ? "admin" : tier}
+
+
+
+ {(showWatchlist || showTrash) && (
+ <>
+ {showWatchlist && (
+ go("/watchlist")}>
+
+ {t("nav.watchlist")}
+
+ )}
+ {showTrash && (
+ go("/trash")}>
+
+ {t("nav.trash")}
+
+ )}
+
+ >
+ )}
+
+
+ {t("auth.signOut")}
+
+
+
+ );
+}
diff --git a/artifacts/toolrate/src/i18n/locales/de.json b/artifacts/toolrate/src/i18n/locales/de.json
index 77a9de8..7365796 100644
--- a/artifacts/toolrate/src/i18n/locales/de.json
+++ b/artifacts/toolrate/src/i18n/locales/de.json
@@ -11,7 +11,8 @@
"watchlist": "Merkliste",
"trash": "Papierkorb",
"admin": "Admin",
- "redundancy": "Redundanz"
+ "redundancy": "Redundanz",
+ "search": "Tools suchen…"
},
"auth": {
"signIn": "Anmelden",
@@ -164,5 +165,12 @@
"notFound": {
"text": "Diese Seite existiert nicht.",
"backHome": "Zurück zur Startseite"
+ },
+ "command": {
+ "navigate": "Navigation",
+ "recent": "Zuletzt besucht",
+ "tools": "Tools",
+ "noResults": "Keine Tools für „{{query}}“ gefunden.",
+ "startTyping": "Beginne zu tippen, um Tools zu suchen."
}
}
\ No newline at end of file
diff --git a/artifacts/toolrate/src/i18n/locales/en.json b/artifacts/toolrate/src/i18n/locales/en.json
index db5f533..3373eef 100644
--- a/artifacts/toolrate/src/i18n/locales/en.json
+++ b/artifacts/toolrate/src/i18n/locales/en.json
@@ -11,7 +11,8 @@
"watchlist": "Watchlist",
"trash": "Trash",
"admin": "Admin",
- "redundancy": "Redundancy"
+ "redundancy": "Redundancy",
+ "search": "Search tools…"
},
"auth": {
"signIn": "Sign in",
@@ -164,5 +165,12 @@
"notFound": {
"text": "This page doesn't exist.",
"backHome": "Back to Home"
+ },
+ "command": {
+ "navigate": "Navigate",
+ "recent": "Recent",
+ "tools": "Tools",
+ "noResults": "No tools found for \"{{query}}\".",
+ "startTyping": "Start typing to search tools."
}
}
\ No newline at end of file
diff --git a/artifacts/toolrate/src/lib/recent-tools.ts b/artifacts/toolrate/src/lib/recent-tools.ts
new file mode 100644
index 0000000..c53bf5d
--- /dev/null
+++ b/artifacts/toolrate/src/lib/recent-tools.ts
@@ -0,0 +1,32 @@
+const STORAGE_KEY = "toolrate-recent";
+const MAX_ITEMS = 5;
+
+export type RecentTool = { id: number; name: string };
+
+export function getRecentTools(): RecentTool[] {
+ if (typeof window === "undefined") return [];
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return [];
+ return parsed
+ .filter(
+ (item): item is RecentTool =>
+ !!item && typeof item.id === "number" && typeof item.name === "string",
+ )
+ .slice(0, MAX_ITEMS);
+ } catch {
+ return [];
+ }
+}
+
+export function recordRecentTool(id: number, name: string) {
+ if (typeof window === "undefined" || !Number.isFinite(id) || !name) return;
+ try {
+ const next = [{ id, name }, ...getRecentTools().filter((t) => t.id !== id)].slice(0, MAX_ITEMS);
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
+ } catch {
+ /* ignore */
+ }
+}
diff --git a/artifacts/toolrate/src/pages/tool-detail.tsx b/artifacts/toolrate/src/pages/tool-detail.tsx
index d766623..edc1b69 100644
--- a/artifacts/toolrate/src/pages/tool-detail.tsx
+++ b/artifacts/toolrate/src/pages/tool-detail.tsx
@@ -59,6 +59,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { customFetch } from "@workspace/api-client-react";
+import { recordRecentTool } from "@/lib/recent-tools";
const ratingSchema = z.object({
usefulness: z.number().min(1).max(5),
@@ -202,6 +203,10 @@ export default function ToolDetail() {
query: { enabled: !!id, queryKey: getGetToolQueryKey(id) }
});
+ useEffect(() => {
+ if (tool?.id && tool.name) recordRecentTool(tool.id, tool.name);
+ }, [tool]);
+
const { data: ratings, isLoading: loadingRatings } = useListToolRatings(id, {
query: { enabled: !!id, queryKey: getListToolRatingsQueryKey(id) }
});