diff --git a/artifacts/toolrate/src/components/breadcrumbs.tsx b/artifacts/toolrate/src/components/breadcrumbs.tsx new file mode 100644 index 0000000..bc4c537 --- /dev/null +++ b/artifacts/toolrate/src/components/breadcrumbs.tsx @@ -0,0 +1,93 @@ +import { Fragment } from "react"; +import { useLocation, Link } from "wouter"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import { useGetTool, getGetToolQueryKey } from "@workspace/api-client-react"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; + +type Crumb = { href?: string; label: string }; + +function buildCrumbs(location: string, t: TFunction): Crumb[] { + const crumbs: Crumb[] = []; + + if (location.startsWith("/tools")) { + crumbs.push({ href: "/tools", label: t("nav.browseTools") }); + const match = location.match(/^\/tools\/(\d+)/); + if (match) { + crumbs.push({ href: `/tools/${match[1]}`, label: t("browse.tool") }); + if (location.includes("/edit")) crumbs.push({ label: t("common.edit") }); + } else if (location.startsWith("/tools/new")) { + crumbs.push({ label: t("nav.addTool") }); + } + } else if (location.startsWith("/admin")) { + crumbs.push({ href: "/admin", label: t("nav.admin") }); + if (location.startsWith("/admin/redundancy")) crumbs.push({ label: t("nav.redundancy") }); + } else if (location.startsWith("/watchlist")) { + crumbs.push({ label: t("nav.watchlist") }); + } else if (location.startsWith("/trash")) { + crumbs.push({ label: t("nav.trash") }); + } else if (location.startsWith("/compare")) { + crumbs.push({ label: t("compare.title") }); + } else if (location.startsWith("/analytics")) { + crumbs.push({ label: t("nav.analytics") }); + } else if (location.startsWith("/login")) { + crumbs.push({ label: t("auth.signIn") }); + } + + return crumbs; +} + +export function Breadcrumbs() { + const [location] = useLocation(); + const { t } = useTranslation(); + + const detailMatch = location.match(/^\/tools\/(\d+)/); + const toolId = detailMatch ? parseInt(detailMatch[1], 10) : 0; + const { data: tool } = useGetTool(Number.isFinite(toolId) ? toolId : 0, { + query: { + queryKey: getGetToolQueryKey(toolId), + enabled: Number.isFinite(toolId) && toolId > 0, + }, + }); + + const crumbs = buildCrumbs(location, t); + if (crumbs.length <= 1) return null; + + if (toolId > 0 && tool?.name) { + const idx = crumbs.findIndex((c) => c.href === `/tools/${toolId}`); + if (idx >= 0) crumbs[idx].label = tool.name; + } + + return ( + + + {crumbs.map((crumb, i) => { + const isLast = i === crumbs.length - 1; + return ( + + {i > 0 && } + + {isLast || !crumb.href ? ( + {crumb.label} + ) : ( + + + {crumb.label} + + + )} + + + ); + })} + + + ); +} diff --git a/artifacts/toolrate/src/components/command-palette.tsx b/artifacts/toolrate/src/components/command-palette.tsx index ef3ff15..0b5eda2 100644 --- a/artifacts/toolrate/src/components/command-palette.tsx +++ b/artifacts/toolrate/src/components/command-palette.tsx @@ -12,6 +12,7 @@ import { } from "@/components/ui/command"; import { useAuth } from "@/hooks/use-auth"; import { useTranslation } from "react-i18next"; +import { getRecentTools } from "@/lib/recent-tools"; import { Compass, PlusCircle, @@ -20,16 +21,32 @@ import { ShieldCheck, Wrench, Star, + History, } from "lucide-react"; +const openRequesters = new Set<() => void>(); + +export function requestOpenCommandPalette() { + for (const request of openRequesters) request(); +} + export function CommandPalette() { const [, navigate] = useLocation(); const { t } = useTranslation(); const { isAuthenticated, isAdmin, hasFeature } = useAuth(); const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); + const [recent, setRecent] = useState(() => getRecentTools()); const searchTimer = useRef | null>(null); + useEffect(() => { + const request = () => setOpen(true); + openRequesters.add(request); + return () => { + openRequesters.delete(request); + }; + }, []); + useEffect(() => { const down = (e: KeyboardEvent) => { if (e.key.toLowerCase() === "k" && (e.metaKey || e.ctrlKey)) { @@ -42,7 +59,11 @@ export function CommandPalette() { }, []); useEffect(() => { - if (!open) setSearch(""); + if (!open) { + setSearch(""); + } else { + setRecent(getRecentTools()); + } }, [open]); const { data: tools } = useListTools( @@ -63,6 +84,7 @@ export function CommandPalette() { const showTrash = hasFeature("trash") || isAdmin; const showWatchlist = (hasFeature("watchlist") || isAdmin) && isAuthenticated; + const searching = search.trim().length > 0; return ( @@ -76,9 +98,21 @@ export function CommandPalette() { /> - {search ? `No tools found for "${search}".` : "Start typing to search tools."} + {searching ? t("command.noResults", { query: search }) : t("command.startTyping")} - + + {!searching && recent.length > 0 && ( + + {recent.map((tool) => ( + go(`/tools/${tool.id}`)}> + + {tool.name} + + ))} + + )} + + go("/tools")}> {t("nav.browseTools")} @@ -104,10 +138,10 @@ export function CommandPalette() { )} - {search.trim().length > 0 && ( + {searching && ( <> - + {(tools ?? []).slice(0, 10).map((tool) => ( go(`/tools/${tool.id}`)}> diff --git a/artifacts/toolrate/src/components/header-search.tsx b/artifacts/toolrate/src/components/header-search.tsx new file mode 100644 index 0000000..bb335ff --- /dev/null +++ b/artifacts/toolrate/src/components/header-search.tsx @@ -0,0 +1,24 @@ +import { Search } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Kbd } from "@/components/ui/kbd"; +import { requestOpenCommandPalette } from "@/components/command-palette"; + +export function HeaderSearch() { + const { t } = useTranslation(); + + return ( + + ); +} diff --git a/artifacts/toolrate/src/components/layout.tsx b/artifacts/toolrate/src/components/layout.tsx index 7678a1a..b871be7 100644 --- a/artifacts/toolrate/src/components/layout.tsx +++ b/artifacts/toolrate/src/components/layout.tsx @@ -1,193 +1,188 @@ 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, ShieldCheck, AlertTriangle, Trash2, Bookmark } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useAuth } from "@/hooks/use-auth"; import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react"; import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; import { ThemeToggle } from "@/components/theme-toggle"; import { CommandPalette } from "@/components/command-palette"; import { LanguageSwitcher } from "@/components/language-switcher"; +import { UserMenu } from "@/components/user-menu"; +import { Breadcrumbs } from "@/components/breadcrumbs"; +import { HeaderSearch } from "@/components/header-search"; +import { + SidebarProvider, + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInset, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarRail, + SidebarTrigger, +} from "@/components/ui/sidebar"; export function Layout({ children }: { children: React.ReactNode }) { const [location] = useLocation(); const { t } = useTranslation(); - const { user, isLoading, isAuthenticated, isAdmin, isLocalMode, tier, hasFeature, login, logout } = useAuth(); + const { isAuthenticated, isLoading, isAdmin, hasFeature, login, logout } = useAuth(); const { data: version } = useGetVersion({ query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false }, }); - const links = [ + const mainLinks = [ { href: "/", label: t("nav.home"), icon: LayoutDashboard }, { href: "/tools", label: t("nav.browseTools"), icon: Wrench }, { href: "/tools/new", label: t("nav.addTool"), icon: PlusCircle }, { href: "/analytics", label: t("nav.analytics"), icon: BarChart3 }, ...(hasFeature("watchlist") ? [{ href: "/watchlist", label: t("nav.watchlist"), icon: Bookmark }] : []), ...(hasFeature("trash") ? [{ href: "/trash", label: t("nav.trash"), icon: Trash2 }] : []), - ...(isAdmin ? [{ href: "/admin", label: t("nav.admin"), icon: ShieldCheck }] : []), - ...(isAdmin ? [{ href: "/admin/redundancy", label: t("nav.redundancy"), icon: AlertTriangle }] : []), ]; - const activeLink = links.find( - (link) => location === link.href || (link.href !== "/" && location.startsWith(link.href)), - ); + const adminLinks = [ + { href: "/admin", label: t("nav.admin"), icon: ShieldCheck }, + { href: "/admin/redundancy", label: t("nav.redundancy"), icon: AlertTriangle }, + ]; - const mobileLinks = [ - { href: "/", label: t("nav.home"), icon: LayoutDashboard }, - { href: "/tools", label: t("nav.browseTools"), icon: Wrench }, - ...(hasFeature("watchlist") ? [{ href: "/watchlist", label: t("nav.watchlist"), icon: Bookmark }] : []), - ...(hasFeature("trash") ? [{ href: "/trash", label: t("nav.trash"), icon: Trash2 }] : []), - { href: "/analytics", label: t("nav.analytics"), icon: BarChart3 }, - ].slice(0, 5); + function isActive(href: string) { + return location === href || (href !== "/" && location.startsWith(href)); + } return ( -
+ - - -
-
-

- {activeLink?.label ?? "toolr"} -

-
- - + + + +
+ toolr + {version?.commitSha ? ( + + {version.version && version.version !== "dev" + ? version.version + : `sha-${version.commitSha.slice(0, 7)}`} + + ) : version?.version && version.version !== "dev" ? ( + {version.version} + ) : null}
-
-
- - - toolr - -
+ + + + + +
+
+ + + + toolr + +
+ +
+
+
+ {!isLoading && ( - isAuthenticated ? ( - - ) : ( - - ) +
+ {isAuthenticated ? ( + + ) : ( + + )} +
)}
-
+
{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) } });