From 444d293a29575402575c16e92955df1033f7b30a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 11 Jun 2026 23:46:11 +0200 Subject: [PATCH 01/12] =?UTF-8?q?=F0=9F=9A=A7=20WIP:=20copy=20command=20ba?= =?UTF-8?q?r=20from=20existing=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 23 ++ src/app/page.tsx | 4 +- src/components/Layout.tsx | 79 ++-- .../command-palette/CommandPalette.tsx | 368 ++++++++++++++++++ .../command-palette/CommandPaletteTrigger.tsx | 69 ++++ .../useCommandPaletteActions.tsx | 161 ++++++++ .../useCommandPaletteNavigation.ts | 57 +++ .../useCommandPaletteOrganizations.ts | 45 +++ .../useCommandPaletteSearch.ts | 142 +++++++ src/lib/flattenNavItems.ts | 42 ++ src/lib/hydrateNavHref.ts | 35 ++ 11 files changed, 986 insertions(+), 39 deletions(-) create mode 100644 src/components/command-palette/CommandPalette.tsx create mode 100644 src/components/command-palette/CommandPaletteTrigger.tsx create mode 100644 src/components/command-palette/useCommandPaletteActions.tsx create mode 100644 src/components/command-palette/useCommandPaletteNavigation.ts create mode 100644 src/components/command-palette/useCommandPaletteOrganizations.ts create mode 100644 src/components/command-palette/useCommandPaletteSearch.ts create mode 100644 src/lib/flattenNavItems.ts create mode 100644 src/lib/hydrateNavHref.ts diff --git a/messages/en-US.json b/messages/en-US.json index 5937595b5..e18385159 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1490,6 +1490,29 @@ "navbar": "Navigation Menu", "navbarDescription": "Main navigation menu for the application", "navbarDocsLink": "Documentation", + "commandPaletteTitle": "Command palette", + "commandPaletteDescription": "Search for pages, organizations, resources, and actions", + "commandPaletteSearchPlaceholder": "Search pages, resources, actions...", + "commandPaletteNoResults": "No results found.", + "commandPaletteSearching": "Searching...", + "commandPaletteNavigation": "Navigation", + "commandPaletteOrganizations": "Organizations", + "commandPaletteSites": "Sites", + "commandPaletteResources": "Resources", + "commandPaletteUsers": "Users", + "commandPaletteClients": "Machine clients", + "commandPaletteActions": "Actions", + "commandPaletteCreateSite": "Create site", + "commandPaletteCreateProxyResource": "Create public resource", + "commandPaletteCreateUser": "Create user", + "commandPaletteCreateApiKey": "Create API key", + "commandPaletteCreateMachineClient": "Create machine client", + "commandPaletteCreateAlertRule": "Create alert rule", + "commandPaletteCreateIdentityProvider": "Create identity provider", + "commandPaletteToggleTheme": "Toggle theme", + "commandPaletteChooseOrganization": "Choose organization", + "commandPaletteShortcutMac": "⌘K", + "commandPaletteShortcutWindows": "Ctrl K", "otpErrorEnable": "Unable to enable 2FA", "otpErrorEnableDescription": "An error occurred while enabling 2FA", "otpSetupCheckCode": "Please enter a 6-digit code", diff --git a/src/app/page.tsx b/src/app/page.tsx index 188089bda..d662acb22 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -21,9 +21,11 @@ export default async function Page(props: { searchParams: Promise<{ redirect: string | undefined; t: string | undefined; + orgs?: string | undefined; }>; }) { const params = await props.searchParams; // this is needed to prevent static optimization + const showOrgPicker = params.orgs === "1"; const env = pullEnv(); @@ -106,7 +108,7 @@ export default async function Page(props: { } } - if (targetOrgId) { + if (targetOrgId && !showOrgPicker) { return ; } diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index dd0ef3d2f..c0f814453 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -6,6 +6,7 @@ import { LayoutSidebar } from "@app/components/LayoutSidebar"; import { LayoutHeader } from "@app/components/LayoutHeader"; import { LayoutMobileMenu } from "@app/components/LayoutMobileMenu"; import { cookies } from "next/headers"; +import { CommandPaletteProvider } from "./command-palette/CommandPalette"; interface LayoutProps { children: React.ReactNode; @@ -37,51 +38,53 @@ export async function Layout({ (sidebarStateCookie !== "expanded" && defaultSidebarCollapsed); return ( -
- {/* Desktop Sidebar */} - {showSidebar && ( - - )} - - {/* Main content area */} -
- {/* Mobile header */} - {showHeader && ( - +
+ {/* Desktop Sidebar */} + {showSidebar && ( + )} - {/* Desktop header */} - {showHeader && } + {/* Main content area */} +
+ {/* Mobile header */} + {showHeader && ( + + )} - {/* Main content */} -
-
- {children} -
-
+ {/* Desktop header */} + {showHeader && } + + {/* Main content */} +
+
+ {children} +
+
+
-
+ ); } diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx new file mode 100644 index 000000000..02556b7dd --- /dev/null +++ b/src/components/command-palette/CommandPalette.tsx @@ -0,0 +1,368 @@ +"use client"; + +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator +} from "@app/components/ui/command"; +import type { SidebarNavSection } from "@app/app/navigation"; +import { Badge } from "@app/components/ui/badge"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import { Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState +} from "react"; +import { useTranslations } from "next-intl"; +import { useCommandPaletteActions } from "./useCommandPaletteActions"; +import { useCommandPaletteNavigation } from "./useCommandPaletteNavigation"; +import { useCommandPaletteOrganizations } from "./useCommandPaletteOrganizations"; +import { useCommandPaletteSearch } from "./useCommandPaletteSearch"; + +type CommandPaletteProps = { + orgId?: string; + orgs?: ListUserOrgsResponse["orgs"]; + navItems: SidebarNavSection[]; +}; + +export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { + const t = useTranslations(); + const router = useRouter(); + const { open, setOpen } = useCommandPalette(); + const [search, setSearch] = useState(""); + + const navigationGroups = useCommandPaletteNavigation(navItems); + const organizations = useCommandPaletteOrganizations(orgs); + const actions = useCommandPaletteActions(orgId, orgs); + const { shouldSearch, sites, resources, users, machineClients, isLoading } = + useCommandPaletteSearch({ + orgId, + query: search, + enabled: open + }); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + setSearch(""); + } + }, + [setOpen] + ); + + const runCommand = useCallback( + (command: () => void) => { + setOpen(false); + setSearch(""); + command(); + }, + [setOpen] + ); + + const hasEntityResults = + sites.length > 0 || + resources.length > 0 || + users.length > 0 || + machineClients.length > 0; + + return ( + + + + {t("commandPaletteNoResults")} + + {navigationGroups.map((group) => ( + + {group.items.map((item) => ( + + runCommand(() => router.push(item.href)) + } + > + {item.icon} + {item.title} + + ))} + + ))} + + {organizations.length > 1 && ( + <> + + + {organizations.map((org) => ( + + runCommand(() => router.push(org.href)) + } + > + {org.name} + + {org.orgId} + + {org.isPrimaryOrg && ( + + {t("primary")} + + )} + + ))} + + + )} + + {shouldSearch && orgId && ( + <> + + {isLoading && !hasEntityResults ? ( +
+ + {t("commandPaletteSearching")} +
+ ) : ( + <> + {sites.length > 0 && ( + + {sites.map((site) => ( + + runCommand(() => + router.push(site.href) + ) + } + > + + {site.name} + + + ))} + + )} + {resources.length > 0 && ( + + {resources.map((resource) => ( + + runCommand(() => + router.push( + resource.href + ) + ) + } + > + + {resource.name} + + + ))} + + )} + {users.length > 0 && ( + + {users.map((user) => ( + + runCommand(() => + router.push(user.href) + ) + } + > +
+ + {user.name} + + + {user.email} + +
+
+ ))} +
+ )} + {machineClients.length > 0 && ( + + {machineClients.map((client) => ( + + runCommand(() => + router.push(client.href) + ) + } + > + + {client.name} + + + ))} + + )} + + )} + + )} + + {actions.length > 0 && ( + <> + + + {actions.map((action) => ( + + runCommand(() => { + if (action.onSelect) { + action.onSelect(); + } else if (action.href) { + router.push(action.href); + } + }) + } + > + {action.icon} + {action.label} + + ))} + + + )} +
+
+ ); +} + +/*******************************/ +/* COMMAND PALETTE CONTEXT */ +/*******************************/ +export type CommandPaletteContextValue = { + open: boolean; + setOpen: (open: boolean) => void; + toggle: () => void; +}; + +const CommandPaletteContext = createContext( + null +); + +export function useCommandPalette() { + const context = useContext(CommandPaletteContext); + if (!context) { + throw new Error( + "useCommandPalette must be used within CommandPaletteProvider" + ); + } + return context; +} + +//*******************************/ +/* COMMAND PALETTE PROVIDER */ +/*******************************/ +type CommandPaletteProviderProps = { + children: React.ReactNode; + orgId?: string; + orgs?: ListUserOrgsResponse["orgs"]; + navItems: SidebarNavSection[]; +}; + +function isEditableTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) return false; + if (target.isContentEditable) return true; + const tagName = target.tagName; + return ( + tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT" + ); +} + +export function CommandPaletteProvider({ + children, + orgId, + orgs, + navItems +}: CommandPaletteProviderProps) { + const [open, setOpen] = useState(false); + + const toggle = useCallback(() => { + setOpen((current) => !current); + }, []); + + const contextValue = useMemo( + () => ({ + open, + setOpen, + toggle + }), + [open, toggle] + ); + + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if ( + event.key.toLowerCase() !== "k" || + !(event.metaKey || event.ctrlKey) + ) { + return; + } + + if (!open && isEditableTarget(event.target)) { + return; + } + + event.preventDefault(); + toggle(); + } + + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [open, toggle]); + + return ( + + {children} + + + ); +} diff --git a/src/components/command-palette/CommandPaletteTrigger.tsx b/src/components/command-palette/CommandPaletteTrigger.tsx new file mode 100644 index 000000000..785013ae2 --- /dev/null +++ b/src/components/command-palette/CommandPaletteTrigger.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { CommandShortcut } from "@app/components/ui/command"; +import { cn } from "@app/lib/cn"; +import { Search } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { useCommandPalette } from "./CommandPalette"; + +type CommandPaletteTriggerProps = { + variant?: "header" | "mobile"; + className?: string; +}; + +function useIsMac() { + const [isMac, setIsMac] = useState(false); + + useEffect(() => { + setIsMac(/Mac|iPhone|iPod|iPad/.test(navigator.platform)); + }, []); + + return isMac; +} + +export function CommandPaletteTrigger({ + variant = "header", + className +}: CommandPaletteTriggerProps) { + const t = useTranslations(); + const { setOpen } = useCommandPalette(); + const isMac = useIsMac(); + + if (variant === "mobile") { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/components/command-palette/useCommandPaletteActions.tsx b/src/components/command-palette/useCommandPaletteActions.tsx new file mode 100644 index 000000000..998fb97b8 --- /dev/null +++ b/src/components/command-palette/useCommandPaletteActions.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { build } from "@server/build"; +import { + BellRing, + Building2, + Globe, + KeyRound, + MonitorUp, + Plus, + SunMoon, + UserPlus +} from "lucide-react"; +import { useTheme } from "next-themes"; +import { usePathname } from "next/navigation"; +import type { ReactNode } from "react"; +import { useMemo } from "react"; +import { useTranslations } from "next-intl"; +import { ListUserOrgsResponse } from "@server/routers/org"; + +export type CommandPaletteAction = { + id: string; + label: string; + icon: ReactNode; + href?: string; + onSelect?: () => void; +}; + +export function useCommandPaletteActions( + orgId?: string, + orgs?: ListUserOrgsResponse["orgs"] +): CommandPaletteAction[] { + const t = useTranslations(); + const pathname = usePathname(); + const { env } = useEnvContext(); + const { user } = useUserContext(); + const { setTheme, theme } = useTheme(); + const isAdminPage = pathname?.startsWith("/admin"); + + return useMemo(() => { + const actions: CommandPaletteAction[] = []; + + function cycleTheme() { + const currentTheme = theme || "system"; + if (currentTheme === "light") { + setTheme("dark"); + } else if (currentTheme === "dark") { + setTheme("system"); + } else { + setTheme("light"); + } + } + + if (isAdminPage) { + actions.push({ + id: "create-admin-api-key", + label: t("commandPaletteCreateApiKey"), + icon: , + href: "/admin/api-keys/create" + }); + + if ( + build === "oss" || + env?.app.identityProviderMode === "global" || + env?.app.identityProviderMode === undefined + ) { + actions.push({ + id: "create-admin-idp", + label: t("commandPaletteCreateIdentityProvider"), + icon: , + href: "/admin/idp/create" + }); + } + } else if (orgId) { + actions.push({ + id: "create-site", + label: t("commandPaletteCreateSite"), + icon: , + href: `/${orgId}/settings/sites/create` + }); + actions.push({ + id: "create-proxy-resource", + label: t("commandPaletteCreateProxyResource"), + icon: , + href: `/${orgId}/settings/resources/proxy/create` + }); + actions.push({ + id: "create-user", + label: t("commandPaletteCreateUser"), + icon: , + href: `/${orgId}/settings/access/users/create` + }); + actions.push({ + id: "create-api-key", + label: t("commandPaletteCreateApiKey"), + icon: , + href: `/${orgId}/settings/api-keys/create` + }); + actions.push({ + id: "create-machine-client", + label: t("commandPaletteCreateMachineClient"), + icon: , + href: `/${orgId}/settings/clients/machine/create` + }); + + if (!env?.flags.disableEnterpriseFeatures) { + actions.push({ + id: "create-alert-rule", + label: t("commandPaletteCreateAlertRule"), + icon: , + href: `/${orgId}/settings/alerting/create` + }); + } + + if ( + (build === "oss" && !env?.flags.disableEnterpriseFeatures) || + build === "saas" || + env?.app.identityProviderMode === "org" || + (env?.app.identityProviderMode === undefined && build !== "oss") + ) { + actions.push({ + id: "create-idp", + label: t("commandPaletteCreateIdentityProvider"), + icon: , + href: `/${orgId}/settings/idp/create` + }); + } + } + + const canChooseOrganization = !isAdminPage && (orgs?.length ?? 0) > 1; + + if (canChooseOrganization) { + actions.push({ + id: "choose-org", + label: t("commandPaletteChooseOrganization"), + icon: , + href: "/?orgs=1" + }); + } + + actions.push({ + id: "toggle-theme", + label: t("commandPaletteToggleTheme"), + icon: , + onSelect: cycleTheme + }); + + if (user.serverAdmin && !isAdminPage) { + actions.push({ + id: "go-admin", + label: t("serverAdmin"), + icon: , + href: "/admin/users" + }); + } + + return actions; + }, [isAdminPage, orgId, orgs, env, user.serverAdmin, theme, setTheme, t]); +} \ No newline at end of file diff --git a/src/components/command-palette/useCommandPaletteNavigation.ts b/src/components/command-palette/useCommandPaletteNavigation.ts new file mode 100644 index 000000000..1968d1a89 --- /dev/null +++ b/src/components/command-palette/useCommandPaletteNavigation.ts @@ -0,0 +1,57 @@ +"use client"; + +import type { SidebarNavSection } from "@app/components/SidebarNav"; +import { flattenNavSections } from "@app/lib/flattenNavItems"; +import { + hydrateNavHref, + navHrefParamsFromRoute +} from "@app/lib/hydrateNavHref"; +import { useParams } from "next/navigation"; +import { useMemo } from "react"; +import { useTranslations } from "next-intl"; + +export type NavigationCommand = { + id: string; + title: string; + href: string; + icon?: React.ReactNode; + sectionHeading: string; +}; + +export type NavigationCommandGroup = { + heading: string; + items: NavigationCommand[]; +}; + +export function useCommandPaletteNavigation( + navItems: SidebarNavSection[] +): NavigationCommandGroup[] { + const params = useParams(); + const t = useTranslations(); + + return useMemo(() => { + const hrefParams = navHrefParamsFromRoute(params); + const flat = flattenNavSections(navItems); + const groups = new Map(); + + for (const item of flat) { + const href = hydrateNavHref(item.href, hrefParams); + if (!href) continue; + + const groupItems = groups.get(item.sectionHeading) ?? []; + groupItems.push({ + id: `nav-${item.sectionHeading}-${item.title}-${href}`, + title: t(item.title), + href, + icon: item.icon, + sectionHeading: item.sectionHeading + }); + groups.set(item.sectionHeading, groupItems); + } + + return Array.from(groups.entries()).map(([heading, items]) => ({ + heading: t(heading), + items + })); + }, [navItems, params, t]); +} diff --git a/src/components/command-palette/useCommandPaletteOrganizations.ts b/src/components/command-palette/useCommandPaletteOrganizations.ts new file mode 100644 index 000000000..1e5343ccd --- /dev/null +++ b/src/components/command-palette/useCommandPaletteOrganizations.ts @@ -0,0 +1,45 @@ +"use client"; + +import { ListUserOrgsResponse } from "@server/routers/org"; +import { usePathname } from "next/navigation"; +import { useMemo } from "react"; + +export type OrganizationCommand = { + id: string; + orgId: string; + name: string; + isPrimaryOrg?: boolean; + href: string; +}; + +export function useCommandPaletteOrganizations( + orgs: ListUserOrgsResponse["orgs"] | undefined +): OrganizationCommand[] { + const pathname = usePathname(); + + return useMemo(() => { + if (!orgs?.length) return []; + + const sortedOrgs = [...orgs].sort((a, b) => { + const aPrimary = Boolean(a.isPrimaryOrg); + const bPrimary = Boolean(b.isPrimaryOrg); + if (aPrimary && !bPrimary) return -1; + if (!aPrimary && bPrimary) return 1; + return 0; + }); + + return sortedOrgs.map((org) => { + const newPath = pathname.includes("/settings/") + ? pathname.replace(/^\/[^/]+/, `/${org.orgId}`) + : `/${org.orgId}`; + + return { + id: `org-${org.orgId}`, + orgId: org.orgId, + name: org.name, + isPrimaryOrg: org.isPrimaryOrg, + href: newPath + }; + }); + }, [orgs, pathname]); +} diff --git a/src/components/command-palette/useCommandPaletteSearch.ts b/src/components/command-palette/useCommandPaletteSearch.ts new file mode 100644 index 000000000..984892354 --- /dev/null +++ b/src/components/command-palette/useCommandPaletteSearch.ts @@ -0,0 +1,142 @@ +"use client"; + +import { orgQueries } from "@app/lib/queries"; +import { useQueries } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { useDebounce } from "use-debounce"; + +const SEARCH_PER_PAGE = 5; +const MIN_QUERY_LENGTH = 2; + +export type SiteSearchResult = { + id: string; + name: string; + href: string; +}; + +export type ResourceSearchResult = { + id: string; + name: string; + href: string; +}; + +export type UserSearchResult = { + id: string; + name: string; + email: string; + href: string; +}; + +export type ClientSearchResult = { + id: string; + name: string; + href: string; +}; + +export function useCommandPaletteSearch({ + orgId, + query, + enabled +}: { + orgId?: string; + query: string; + enabled: boolean; +}) { + const [debouncedQuery] = useDebounce(query, 150); + const trimmedQuery = debouncedQuery.trim(); + const shouldSearch = + enabled && !!orgId && trimmedQuery.length >= MIN_QUERY_LENGTH; + + const [sitesQuery, resourcesQuery, usersQuery, clientsQuery] = useQueries({ + queries: [ + { + ...orgQueries.sites({ + orgId: orgId ?? "", + query: trimmedQuery, + perPage: SEARCH_PER_PAGE + }), + enabled: shouldSearch + }, + { + ...orgQueries.resources({ + orgId: orgId ?? "", + query: trimmedQuery, + perPage: SEARCH_PER_PAGE + }), + enabled: shouldSearch + }, + { + ...orgQueries.users({ + orgId: orgId ?? "", + query: trimmedQuery, + perPage: SEARCH_PER_PAGE + }), + enabled: shouldSearch + }, + { + ...orgQueries.machineClients({ + orgId: orgId ?? "", + query: trimmedQuery, + perPage: SEARCH_PER_PAGE + }), + enabled: shouldSearch + } + ] + }); + + const sites = useMemo((): SiteSearchResult[] => { + if (!orgId || !sitesQuery.data) return []; + return sitesQuery.data.map((site) => ({ + id: `site-${site.siteId}`, + name: site.name, + href: `/${orgId}/settings/sites/${site.niceId}` + })); + }, [orgId, sitesQuery.data]); + + const resources = useMemo((): ResourceSearchResult[] => { + if (!orgId || !resourcesQuery.data) return []; + return resourcesQuery.data.map((resource) => ({ + id: `resource-${resource.resourceId}`, + name: resource.name, + href: `/${orgId}/settings/resources/proxy/${resource.niceId}` + })); + }, [orgId, resourcesQuery.data]); + + const users = useMemo((): UserSearchResult[] => { + if (!orgId || !usersQuery.data) return []; + return usersQuery.data.map((user) => ({ + id: `user-${user.id}`, + name: user.name ?? user.email ?? user.username ?? "", + email: user.email ?? user.username ?? "", + href: `/${orgId}/settings/access/users/${user.id}` + })); + }, [orgId, usersQuery.data]); + + const machineClients = useMemo((): ClientSearchResult[] => { + if (!orgId || !clientsQuery.data) return []; + return clientsQuery.data + .filter((client) => !client.userId) + .map((client) => ({ + id: `client-${client.clientId}`, + name: client.name, + href: `/${orgId}/settings/clients/machine/${client.niceId}` + })); + }, [orgId, clientsQuery.data]); + + const isLoading = + shouldSearch && + (sitesQuery.isFetching || + resourcesQuery.isFetching || + usersQuery.isFetching || + clientsQuery.isFetching); + + return { + debouncedQuery: trimmedQuery, + shouldSearch, + sites, + resources, + users, + machineClients, + isLoading + }; +} diff --git a/src/lib/flattenNavItems.ts b/src/lib/flattenNavItems.ts new file mode 100644 index 000000000..b64268322 --- /dev/null +++ b/src/lib/flattenNavItems.ts @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; +import type { + SidebarNavItem, + SidebarNavSection +} from "@app/components/SidebarNav"; + +export type FlatNavItem = { + title: string; + href: string; + icon?: ReactNode; + sectionHeading: string; +}; + +function flattenItems( + items: SidebarNavItem[], + sectionHeading: string, + result: FlatNavItem[] +) { + for (const item of items) { + if (item.href) { + result.push({ + title: item.title, + href: item.href, + icon: item.icon, + sectionHeading + }); + } + if (item.items?.length) { + flattenItems(item.items, sectionHeading, result); + } + } +} + +export function flattenNavSections( + sections: SidebarNavSection[] +): FlatNavItem[] { + const result: FlatNavItem[] = []; + for (const section of sections) { + flattenItems(section.items, section.heading, result); + } + return result; +} diff --git a/src/lib/hydrateNavHref.ts b/src/lib/hydrateNavHref.ts new file mode 100644 index 000000000..45fa0f5ee --- /dev/null +++ b/src/lib/hydrateNavHref.ts @@ -0,0 +1,35 @@ +export type NavHrefParams = { + orgId?: string; + niceId?: string; + resourceId?: string; + userId?: string; + apiKeyId?: string; + clientId?: string; +}; + +export function hydrateNavHref( + val: string | undefined, + params: NavHrefParams +): string | undefined { + if (!val) return undefined; + return val + .replace("{orgId}", params.orgId ?? "") + .replace("{niceId}", params.niceId ?? "") + .replace("{resourceId}", params.resourceId ?? "") + .replace("{userId}", params.userId ?? "") + .replace("{apiKeyId}", params.apiKeyId ?? "") + .replace("{clientId}", params.clientId ?? ""); +} + +export function navHrefParamsFromRoute( + params: Record +): NavHrefParams { + return { + orgId: params.orgId as string | undefined, + niceId: params.niceId as string | undefined, + resourceId: params.resourceId as string | undefined, + userId: params.userId as string | undefined, + apiKeyId: params.apiKeyId as string | undefined, + clientId: params.clientId as string | undefined + }; +} From a68b57067c5b93662e18c1da0f9f48d3d41ebff9 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 12 Jun 2026 23:51:05 +0200 Subject: [PATCH 02/12] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20Use=20command=20bar?= =?UTF-8?q?=20nav=20items?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/[orgId]/settings/layout.tsx | 5 +- src/app/navigation.tsx | 250 ++++++++++++++++++ src/components/Layout.tsx | 8 +- src/components/SitesTable.tsx | 7 - .../command-palette/CommandPalette.tsx | 85 +++--- 5 files changed, 315 insertions(+), 40 deletions(-) diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index 6a3d648a4..cac644be3 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -12,7 +12,7 @@ import UserProvider from "@app/providers/UserProvider"; import { Layout } from "@app/components/Layout"; import { getTranslations } from "next-intl/server"; import { pullEnv } from "@app/lib/pullEnv"; -import { orgNavSections } from "@app/app/navigation"; +import { commandBarNavSections, orgNavSections } from "@app/app/navigation"; import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser"; export const dynamic = "force-dynamic"; @@ -82,6 +82,9 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { navItems={orgNavSections(env, { isPrimaryOrg: primaryOrg })} + commandNavItems={commandBarNavSections(env, { + isPrimaryOrg: primaryOrg + })} > {children} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 43323ba2f..e0ada4c1c 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -340,3 +340,253 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [ ] } ]; + +export const commandBarNavSections = ( + env?: Env, + options?: OrgNavSectionsOptions +): SidebarNavSection[] => [ + { + heading: "network", + items: [ + { + title: "sidebarSites", + href: "/{orgId}/settings/sites", + icon: + }, + { + title: "sidebarResources", + icon: , + items: [ + { + title: "sidebarProxyResources", + href: "/{orgId}/settings/resources/public", + icon: + }, + { + title: "sidebarClientResources", + href: "/{orgId}/settings/resources/private", + icon: + } + ] + }, + { + title: "sidebarClients", + icon: , + items: [ + { + href: "/{orgId}/settings/clients/user", + title: "sidebarUserDevices", + icon: + }, + { + href: "/{orgId}/settings/clients/machine", + title: "sidebarMachineClients", + icon: + } + ] + }, + { + title: "sidebarDomains", + href: "/{orgId}/settings/domains", + icon: + }, + ...(build === "saas" + ? [ + { + title: "sidebarRemoteExitNodes", + href: "/{orgId}/settings/remote-exit-nodes", + icon: + } + ] + : []) + ] + }, + { + heading: "accessControl", + items: [ + { + title: "sidebarTeam", + icon: , + items: [ + { + title: "sidebarUsers", + href: "/{orgId}/settings/access/users", + icon: + }, + { + title: "sidebarRoles", + href: "/{orgId}/settings/access/roles", + icon: + }, + { + title: "sidebarInvitations", + href: "/{orgId}/settings/access/invitations", + icon: + } + ] + }, + ...(!env?.flags.disableEnterpriseFeatures + ? [ + { + title: "sidebarPolicies", + + icon: , + items: [ + { + title: "sidebarResourcePolicies", + href: "/{orgId}/settings/policies/resources/public", + icon: ( + + ) + } + ] + } + ] + : []), + // PaidFeaturesAlert + ...((build === "oss" && !env?.flags.disableEnterpriseFeatures) || + build === "saas" || + env?.app.identityProviderMode === "org" || + (env?.app.identityProviderMode === undefined && build !== "oss") + ? [ + { + title: "sidebarIdentityProviders", + href: "/{orgId}/settings/idp", + icon: + } + ] + : []), + ...(!env?.flags.disableEnterpriseFeatures + ? [ + { + title: "sidebarApprovals", + href: "/{orgId}/settings/access/approvals", + icon: + } + ] + : []), + { + title: "sidebarShareableLinks", + href: "/{orgId}/settings/share-links", + icon: + } + ] + }, + { + heading: "sidebarOrganization", + items: [ + { + title: "sidebarLogsAndAnalytics", + icon: , + items: [ + { + title: "sidebarLogsAnalytics", + href: "/{orgId}/settings/logs/analytics", + icon: + }, + { + title: "sidebarLogsRequest", + href: "/{orgId}/settings/logs/request", + icon: ( + + ) + }, + ...(!env?.flags.disableEnterpriseFeatures + ? [ + { + title: "sidebarLogsAccess", + href: "/{orgId}/settings/logs/access", + icon: + }, + { + title: "sidebarLogsAction", + href: "/{orgId}/settings/logs/action", + icon: + }, + { + title: "sidebarLogsConnection", + href: "/{orgId}/settings/logs/connection", + icon: + }, + { + title: "sidebarLogsStreaming", + href: "/{orgId}/settings/logs/streaming", + icon: + } + ] + : []) + ] + }, + { + title: "sidebarManagement", + icon: , + items: [ + ...(!env?.flags.disableEnterpriseFeatures + ? [ + { + title: "sidebarAlerting", + href: "/{orgId}/settings/alerting", + icon: ( + + ) + }, + { + title: "sidebarProvisioning", + href: "/{orgId}/settings/provisioning", + icon: + } + ] + : []), + { + title: "sidebarBluePrints", + href: "/{orgId}/settings/blueprints", + icon: + }, + { + title: "sidebarApiKeys", + href: "/{orgId}/settings/api-keys", + icon: + }, + ...(!env?.flags.disableEnterpriseFeatures + ? [ + { + title: "labels", + href: "/{orgId}/settings/labels", + icon: + } + ] + : []) + ] + }, + ...(build === "saas" && options?.isPrimaryOrg + ? [ + { + title: "sidebarBillingAndLicenses", + icon: , + items: [ + { + title: "sidebarBilling", + href: "/{orgId}/settings/billing", + icon: ( + + ) + }, + { + title: "sidebarEnterpriseLicenses", + href: "/{orgId}/settings/license", + icon: ( + + ) + } + ] + } + ] + : []), + { + title: "sidebarSettings", + href: "/{orgId}/settings/general", + icon: + } + ] + } +]; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index c0f814453..8dc95968f 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -13,6 +13,7 @@ interface LayoutProps { orgId?: string; orgs?: ListUserOrgsResponse["orgs"]; navItems?: SidebarNavSection[]; + commandNavItems?: SidebarNavSection[]; showSidebar?: boolean; showHeader?: boolean; showTopBar?: boolean; @@ -24,6 +25,7 @@ export async function Layout({ orgId, orgs, navItems = [], + commandNavItems = [], showSidebar = true, showHeader = true, showTopBar = true, @@ -38,7 +40,11 @@ export async function Layout({ (sidebarStateCookie !== "expanded" && defaultSidebarCollapsed); return ( - +
{/* Desktop Sidebar */} {showSidebar && ( diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 8c3036c4a..98b69fa3e 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -113,13 +113,6 @@ export default function SitesTable({ const api = createApiClient(useEnvContext()); const t = useTranslations(); - // useEffect(() => { - // const interval = setInterval(() => { - // router.refresh(); - // }, 30_000); - // return () => clearInterval(interval); - // }, []); - const booleanSearchFilterSchema = z .enum(["true", "false"]) .optional() diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index 02556b7dd..b0536d8a9 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -14,7 +14,7 @@ import { Badge } from "@app/components/ui/badge"; import { ListUserOrgsResponse } from "@server/routers/org"; import { Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; -import { +import React, { createContext, useCallback, useContext, @@ -27,6 +27,7 @@ import { useCommandPaletteActions } from "./useCommandPaletteActions"; import { useCommandPaletteNavigation } from "./useCommandPaletteNavigation"; import { useCommandPaletteOrganizations } from "./useCommandPaletteOrganizations"; import { useCommandPaletteSearch } from "./useCommandPaletteSearch"; +import { useUserContext } from "@app/hooks/useUserContext"; type CommandPaletteProps = { orgId?: string; @@ -34,6 +35,12 @@ type CommandPaletteProps = { navItems: SidebarNavSection[]; }; +/** + * Plan for command bar: + * - the nav items should be custom items instead of all of the ones in the sidebar + * - actions should be triggered by using `>` (like in Github) + */ + export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { const t = useTranslations(); const router = useRouter(); @@ -41,7 +48,7 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { const [search, setSearch] = useState(""); const navigationGroups = useCommandPaletteNavigation(navItems); - const organizations = useCommandPaletteOrganizations(orgs); + // const organizations = useCommandPaletteOrganizations(orgs); const actions = useCommandPaletteActions(orgId, orgs); const { shouldSearch, sites, resources, users, machineClients, isLoading } = useCommandPaletteSearch({ @@ -69,45 +76,58 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { [setOpen] ); - const hasEntityResults = - sites.length > 0 || - resources.length > 0 || - users.length > 0 || - machineClients.length > 0; + // const hasEntityResults = + // sites.length > 0 || + // resources.length > 0 || + // users.length > 0 || + // machineClients.length > 0; return ( - + {t("commandPaletteNoResults")} - {navigationGroups.map((group) => ( - - {group.items.map((item) => ( - - runCommand(() => router.push(item.href)) - } - > - {item.icon} - {item.title} - - ))} - + + + {navigationGroups.map((group, idx) => ( + + {idx > 0 && } + + {group.items.map((item) => ( + + runCommand(() => router.push(item.href)) + } + className="h-9" + > + {item.icon} + {item.title} + + ))} + + ))} - {organizations.length > 1 && ( + {/* {organizations.length > 1 && ( <> - )} + )} */} - {shouldSearch && orgId && ( + {/* {shouldSearch && orgId && ( <> {isLoading && !hasEntityResults ? ( @@ -243,12 +263,15 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { )} - )} + )} */} - {actions.length > 0 && ( + {/* {actions.length > 0 && ( <> - + {actions.map((action) => ( - )} + )} */} ); From abc0a41d9ecf9a27a023202a729311d748c63310 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 13 Jun 2026 00:27:39 +0200 Subject: [PATCH 03/12] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20reorganize=20command?= =?UTF-8?q?=20bar=20items?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 36 ++ src/app/navigation.tsx | 328 +++++++++--------- src/components/Layout.tsx | 7 +- .../command-palette/CommandPalette.tsx | 27 +- .../useCommandPaletteNavigation.ts | 6 +- src/components/ui/command.tsx | 6 +- 6 files changed, 223 insertions(+), 187 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index e18385159..e6567cc08 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1590,6 +1590,42 @@ "sidebarManagement": "Management", "sidebarBillingAndLicenses": "Billing & Licenses", "sidebarLogsAnalytics": "Analytics", + "commandSites": "Sites", + "commandActionModeInfo": "Type \">\" to open action mode", + "commandResources": "Resources", + "commandProxyResources": "Public Resources", + "commandClientResources": "Private Resources", + "commandClients": "Clients", + "commandUserDevices": "User Devices", + "commandMachineClients": "Machine Clients", + "commandDomains": "Domains", + "commandRemoteExitNodes": "Remote Nodes", + "commandTeam": "Team", + "commandUsers": "Users", + "commandRoles": "Roles", + "commandInvitations": "Invitations", + "commandPolicies": "Shared Policies", + "commandResourcePolicies": "Public Resources Policies", + "commandIdentityProviders": "Identity Providers", + "commandApprovals": "Approval Requests", + "commandShareableLinks": "Shareable Links", + "commandOrganization": "Organization", + "commandLogsAndAnalytics": "Logs & Analytics", + "commandLogsAnalytics": "Analytics", + "commandLogsRequest": "HTTP Request Logs", + "commandLogsAccess": "Authentication Logs", + "commandLogsAction": "Admin Action Logs", + "commandLogsConnection": "Network Logs", + "commandLogsStreaming": "Event Streaming", + "commandManagement": "Management", + "commandAlerting": "Alerting", + "commandProvisioning": "Provisioning", + "commandBluePrints": "Blueprints", + "commandApiKeys": "API Keys", + "commandBillingAndLicenses": "Billing & Licenses", + "commandBilling": "Billing", + "commandEnterpriseLicenses": "Licenses", + "commandSettings": "Settings", "alertingTitle": "Alerting", "alertingDescription": "Define sources, triggers, and actions for notifications", "alertingRules": "Alert rules", diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index e0ada4c1c..b3eab82ed 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -341,59 +341,56 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [ } ]; +export type CommandBarNavSection = { + // Added from 'dev' branch + heading: string; + items: CommandBarNavItem[]; +}; + +export type CommandBarNavItem = { + href?: string; + title: string; + icon?: React.ReactNode; + showEE?: boolean; + isBeta?: boolean; +}; + export const commandBarNavSections = ( env?: Env, options?: OrgNavSectionsOptions -): SidebarNavSection[] => [ +): CommandBarNavSection[] => [ { heading: "network", items: [ { - title: "sidebarSites", + title: "commandSites", href: "/{orgId}/settings/sites", icon: }, { - title: "sidebarResources", - icon: , - items: [ - { - title: "sidebarProxyResources", - href: "/{orgId}/settings/resources/public", - icon: - }, - { - title: "sidebarClientResources", - href: "/{orgId}/settings/resources/private", - icon: - } - ] - }, - { - title: "sidebarClients", - icon: , - items: [ - { - href: "/{orgId}/settings/clients/user", - title: "sidebarUserDevices", - icon: - }, - { - href: "/{orgId}/settings/clients/machine", - title: "sidebarMachineClients", - icon: - } - ] - }, - { - title: "sidebarDomains", - href: "/{orgId}/settings/domains", + title: "commandProxyResources", + href: "/{orgId}/settings/resources/public", icon: }, + { + title: "commandClientResources", + href: "/{orgId}/settings/resources/private", + icon: + }, + { + href: "/{orgId}/settings/clients/user", + title: "commandUserDevices", + icon: + }, + { + href: "/{orgId}/settings/clients/machine", + title: "commandMachineClients", + icon: + }, ...(build === "saas" ? [ { - title: "sidebarRemoteExitNodes", + title: "commandRemoteExitNodes", href: "/{orgId}/settings/remote-exit-nodes", icon: } @@ -402,44 +399,34 @@ export const commandBarNavSections = ( ] }, { - heading: "accessControl", + heading: "commandTeam", items: [ { - title: "sidebarTeam", - icon: , - items: [ - { - title: "sidebarUsers", - href: "/{orgId}/settings/access/users", - icon: - }, - { - title: "sidebarRoles", - href: "/{orgId}/settings/access/roles", - icon: - }, - { - title: "sidebarInvitations", - href: "/{orgId}/settings/access/invitations", - icon: - } - ] + title: "commandUsers", + href: "/{orgId}/settings/access/users", + icon: }, + { + title: "commandRoles", + href: "/{orgId}/settings/access/roles", + icon: + }, + { + title: "commandInvitations", + href: "/{orgId}/settings/access/invitations", + icon: + } + ] + }, + { + heading: "accessControl", + items: [ ...(!env?.flags.disableEnterpriseFeatures ? [ { - title: "sidebarPolicies", - - icon: , - items: [ - { - title: "sidebarResourcePolicies", - href: "/{orgId}/settings/policies/resources/public", - icon: ( - - ) - } - ] + title: "commandResourcePolicies", + href: "/{orgId}/settings/policies/resources/public", + icon: } ] : []), @@ -450,7 +437,7 @@ export const commandBarNavSections = ( (env?.app.identityProviderMode === undefined && build !== "oss") ? [ { - title: "sidebarIdentityProviders", + title: "commandIdentityProviders", href: "/{orgId}/settings/idp", icon: } @@ -459,134 +446,129 @@ export const commandBarNavSections = ( ...(!env?.flags.disableEnterpriseFeatures ? [ { - title: "sidebarApprovals", + title: "commandApprovals", href: "/{orgId}/settings/access/approvals", icon: } ] : []), { - title: "sidebarShareableLinks", + title: "commandShareableLinks", href: "/{orgId}/settings/share-links", icon: } ] }, { - heading: "sidebarOrganization", + heading: "commandLogsAndAnalytics", items: [ { - title: "sidebarLogsAndAnalytics", - icon: , - items: [ - { - title: "sidebarLogsAnalytics", - href: "/{orgId}/settings/logs/analytics", - icon: - }, - { - title: "sidebarLogsRequest", - href: "/{orgId}/settings/logs/request", - icon: ( - - ) - }, - ...(!env?.flags.disableEnterpriseFeatures - ? [ - { - title: "sidebarLogsAccess", - href: "/{orgId}/settings/logs/access", - icon: - }, - { - title: "sidebarLogsAction", - href: "/{orgId}/settings/logs/action", - icon: - }, - { - title: "sidebarLogsConnection", - href: "/{orgId}/settings/logs/connection", - icon: - }, - { - title: "sidebarLogsStreaming", - href: "/{orgId}/settings/logs/streaming", - icon: - } - ] - : []) - ] + title: "commandLogsAnalytics", + href: "/{orgId}/settings/logs/analytics", + icon: }, { - title: "sidebarManagement", - icon: , - items: [ - ...(!env?.flags.disableEnterpriseFeatures - ? [ - { - title: "sidebarAlerting", - href: "/{orgId}/settings/alerting", - icon: ( - - ) - }, - { - title: "sidebarProvisioning", - href: "/{orgId}/settings/provisioning", - icon: - } - ] - : []), - { - title: "sidebarBluePrints", - href: "/{orgId}/settings/blueprints", - icon: - }, - { - title: "sidebarApiKeys", - href: "/{orgId}/settings/api-keys", - icon: - }, - ...(!env?.flags.disableEnterpriseFeatures - ? [ - { - title: "labels", - href: "/{orgId}/settings/labels", - icon: - } - ] - : []) - ] + title: "commandLogsRequest", + href: "/{orgId}/settings/logs/request", + icon: }, - ...(build === "saas" && options?.isPrimaryOrg + ...(!env?.flags.disableEnterpriseFeatures ? [ { - title: "sidebarBillingAndLicenses", - icon: , - items: [ - { - title: "sidebarBilling", - href: "/{orgId}/settings/billing", - icon: ( - - ) - }, - { - title: "sidebarEnterpriseLicenses", - href: "/{orgId}/settings/license", - icon: ( - - ) - } - ] + title: "commandLogsAccess", + href: "/{orgId}/settings/logs/access", + icon: + }, + { + title: "commandLogsAction", + href: "/{orgId}/settings/logs/action", + icon: + }, + { + title: "commandLogsConnection", + href: "/{orgId}/settings/logs/connection", + icon: + }, + { + title: "commandLogsStreaming", + href: "/{orgId}/settings/logs/streaming", + icon: + } + ] + : []) + ] + }, + { + heading: "commandManagement", + items: [ + ...(!env?.flags.disableEnterpriseFeatures + ? [ + { + title: "commandAlerting", + href: "/{orgId}/settings/alerting", + icon: + }, + { + title: "commandProvisioning", + href: "/{orgId}/settings/provisioning", + icon: } ] : []), { - title: "sidebarSettings", + title: "commandBluePrints", + href: "/{orgId}/settings/blueprints", + icon: + }, + { + title: "commandApiKeys", + href: "/{orgId}/settings/api-keys", + icon: + }, + ...(!env?.flags.disableEnterpriseFeatures + ? [ + { + title: "labels", + href: "/{orgId}/settings/labels", + icon: + } + ] + : []) + ] + }, + + { + heading: "commandOrganization", + items: [ + { + title: "commandSettings", href: "/{orgId}/settings/general", icon: + }, + { + title: "commandDomains", + href: "/{orgId}/settings/domains", + icon: } ] - } + }, + ...(build === "saas" && options?.isPrimaryOrg + ? [ + { + heading: "commandBillingAndLicenses", + items: [ + { + title: "commandBilling", + href: "/{orgId}/settings/billing", + icon: + }, + { + title: "commandEnterpriseLicenses", + href: "/{orgId}/settings/license", + icon: + } + ] + } + ] + : []) ]; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 8dc95968f..991c489a7 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,7 +1,10 @@ import React from "react"; import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; -import type { SidebarNavSection } from "@app/app/navigation"; +import type { + CommandBarNavSection, + SidebarNavSection +} from "@app/app/navigation"; import { LayoutSidebar } from "@app/components/LayoutSidebar"; import { LayoutHeader } from "@app/components/LayoutHeader"; import { LayoutMobileMenu } from "@app/components/LayoutMobileMenu"; @@ -13,7 +16,7 @@ interface LayoutProps { orgId?: string; orgs?: ListUserOrgsResponse["orgs"]; navItems?: SidebarNavSection[]; - commandNavItems?: SidebarNavSection[]; + commandNavItems?: CommandBarNavSection[]; showSidebar?: boolean; showHeader?: boolean; showTopBar?: boolean; diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index b0536d8a9..bbf5b9187 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -9,7 +9,10 @@ import { CommandList, CommandSeparator } from "@app/components/ui/command"; -import type { SidebarNavSection } from "@app/app/navigation"; +import type { + CommandBarNavSection, + SidebarNavSection +} from "@app/app/navigation"; import { Badge } from "@app/components/ui/badge"; import { ListUserOrgsResponse } from "@server/routers/org"; import { Loader2 } from "lucide-react"; @@ -28,11 +31,12 @@ import { useCommandPaletteNavigation } from "./useCommandPaletteNavigation"; import { useCommandPaletteOrganizations } from "./useCommandPaletteOrganizations"; import { useCommandPaletteSearch } from "./useCommandPaletteSearch"; import { useUserContext } from "@app/hooks/useUserContext"; +import { cn } from "@app/lib/cn"; type CommandPaletteProps = { orgId?: string; orgs?: ListUserOrgsResponse["orgs"]; - navItems: SidebarNavSection[]; + navItems: CommandBarNavSection[]; }; /** @@ -89,28 +93,35 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { title={t("commandPaletteTitle")} description={t("commandPaletteDescription")} className="max-w-2xl **:data-[slot=command-input-wrapper]:h-15" + commandProps={{ + loop: true + }} > - + {t("commandPaletteNoResults")} - {navigationGroups.map((group, idx) => ( + {navigationGroups.map((group, groupIndex) => ( - {idx > 0 && } + {groupIndex > 0 && } 0 && + "[&_[cmdk-group-heading]]:pt-3" + )} > - {group.items.map((item) => ( + {group.items.map((item, itemIndex) => ( ; }) { return ( @@ -49,7 +50,10 @@ function CommandDialog({ {description} - + {children} From ce77268c82a7d2b1ed4618e80e752d3856612718 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 13 Jun 2026 00:47:36 +0200 Subject: [PATCH 04/12] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20block=20top=20posit?= =?UTF-8?q?ion=20of=20command=20palette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/command-palette/CommandPalette.tsx | 9 +++++++-- src/components/ui/command.tsx | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index bbf5b9187..3045e534e 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -88,13 +88,18 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { return ( {title} {description} - + {children} From 3bc5ab2136f030c0bc5477ba95001c9b69b7b546 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 13 Jun 2026 00:48:32 +0200 Subject: [PATCH 05/12] =?UTF-8?q?=F0=9F=9A=A7=20=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/command-palette/CommandPalette.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index 3045e534e..f0c022430 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -43,6 +43,7 @@ type CommandPaletteProps = { * Plan for command bar: * - the nav items should be custom items instead of all of the ones in the sidebar * - actions should be triggered by using `>` (like in Github) + * -> if search starts with `>`, the filter should exclude that char in the filter string */ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { From c0a9db92ef13556a6fe158dbae0cfb86e9ac341a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 16 Jun 2026 19:28:22 +0200 Subject: [PATCH 06/12] =?UTF-8?q?=F0=9F=92=84=20update=20command=20bar=20U?= =?UTF-8?q?I?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command-palette/CommandPalette.tsx | 110 +++++++++++------- src/components/ui/command.tsx | 13 ++- 2 files changed, 77 insertions(+), 46 deletions(-) diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index f0c022430..726d1c999 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -1,5 +1,9 @@ "use client"; +import type { + CommandBarNavSection, + SidebarNavSection +} from "@app/app/navigation"; import { CommandDialog, CommandEmpty, @@ -9,13 +13,9 @@ import { CommandList, CommandSeparator } from "@app/components/ui/command"; -import type { - CommandBarNavSection, - SidebarNavSection -} from "@app/app/navigation"; -import { Badge } from "@app/components/ui/badge"; +import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; -import { Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import React, { createContext, @@ -25,13 +25,10 @@ import React, { useMemo, useState } from "react"; -import { useTranslations } from "next-intl"; import { useCommandPaletteActions } from "./useCommandPaletteActions"; import { useCommandPaletteNavigation } from "./useCommandPaletteNavigation"; -import { useCommandPaletteOrganizations } from "./useCommandPaletteOrganizations"; import { useCommandPaletteSearch } from "./useCommandPaletteSearch"; -import { useUserContext } from "@app/hooks/useUserContext"; -import { cn } from "@app/lib/cn"; +import { search } from "jmespath"; type CommandPaletteProps = { orgId?: string; @@ -52,14 +49,16 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { const { open, setOpen } = useCommandPalette(); const [search, setSearch] = useState(""); + const isActionMode = search.startsWith(">"); + const navigationGroups = useCommandPaletteNavigation(navItems); // const organizations = useCommandPaletteOrganizations(orgs); const actions = useCommandPaletteActions(orgId, orgs); const { shouldSearch, sites, resources, users, machineClients, isLoading } = useCommandPaletteSearch({ orgId, - query: search, - enabled: open + query: search.substring(1), + enabled: open && isActionMode }); const handleOpenChange = useCallback( @@ -96,9 +95,24 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { className="max-w-2xl **:data-[slot=command-input-wrapper]:h-15" commandProps={{ loop: true, - filter(value, search) { - if (value.toLowerCase().includes(search.toLowerCase())) + filter(value, query) { + let search = query; + if (query.startsWith(">")) { + search = query.substring(1); + + console.log({ + search, + value + }); + } + + if ( + value + .toLowerCase() + .includes(search.trim().toLowerCase()) + ) { return 1; + } return 0; } }} @@ -107,8 +121,9 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { placeholder={t("commandPaletteSearchPlaceholder")} value={search} onValueChange={setSearch} + // isLoading /> - + {t("commandPaletteNoResults")} - {navigationGroups.map((group, groupIndex) => ( - - {groupIndex > 0 && } - 0 && - "[&_[cmdk-group-heading]]:pt-3" - )} - > - {group.items.map((item, itemIndex) => ( - - runCommand(() => router.push(item.href)) - } - className="h-9" + {!isActionMode && + navigationGroups.map((group, groupIndex) => { + console.log({ + groupIndex, + groupHeading: group.heading + }); + return ( + + {groupIndex > 0 && } + 0 && + "[&_[cmdk-group-heading]]:pt-3" + )} > - {item.icon} - {item.title} - - ))} - - - ))} + {group.items.map((item) => ( + + runCommand(() => + router.push(item.href) + ) + } + className="h-9" + > + {item.icon} + {item.title} + + ))} + + + ); + })} {/* {organizations.length > 1 && ( <> @@ -282,7 +306,7 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { )} */} - {/* {actions.length > 0 && ( + {isActionMode && actions.length > 0 && ( <> - )} */} + )} ); diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index f4b9e1bfe..efd1c35f5 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { Command as CommandPrimitive } from "cmdk"; -import { SearchIcon } from "lucide-react"; +import { LoaderIcon, SearchIcon } from "lucide-react"; import { Dialog, @@ -68,14 +68,21 @@ function CommandDialog({ function CommandInput({ className, + isLoading, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + isLoading?: boolean; +}) { return (
- + {isLoading ? ( + + ) : ( + + )} Date: Tue, 16 Jun 2026 22:28:04 +0200 Subject: [PATCH 07/12] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20rename=20query,=20?= =?UTF-8?q?=20add=20query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/CreateShareLinkForm.tsx | 2 +- .../alert-rule-editor/AlertRuleFields.tsx | 2 +- src/components/resource-selector.tsx | 2 +- src/lib/queries.ts | 82 ++++++++++++++++++- 4 files changed, 81 insertions(+), 7 deletions(-) diff --git a/src/components/CreateShareLinkForm.tsx b/src/components/CreateShareLinkForm.tsx index 78f705445..95884b486 100644 --- a/src/components/CreateShareLinkForm.tsx +++ b/src/components/CreateShareLinkForm.tsx @@ -90,7 +90,7 @@ export default function CreateShareLinkForm({ const t = useTranslations(); const { data: allResources = [] } = useQuery( - orgQueries.resources({ orgId: org?.org.orgId ?? "" }) + orgQueries.proxyResources({ orgId: org?.org.orgId ?? "" }) ); const [selectedResource, setSelectedResource] = diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index d787595ed..7ae6d0a04 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -348,7 +348,7 @@ function ResourceMultiSelect({ const [debounced] = useDebounce(q, 150); const { data: resources = [] } = useQuery( - orgQueries.resources({ orgId, query: debounced, perPage: 10 }) + orgQueries.proxyResources({ orgId, query: debounced, perPage: 10 }) ); const shown = useMemo(() => { diff --git a/src/components/resource-selector.tsx b/src/components/resource-selector.tsx index b01263333..625bb2d64 100644 --- a/src/components/resource-selector.tsx +++ b/src/components/resource-selector.tsx @@ -39,7 +39,7 @@ export function ResourceSelector({ const [debouncedSearchQuery] = useDebounce(resourceSearchQuery, 150); const { data: resources = [] } = useQuery( - orgQueries.resources({ + orgQueries.proxyResources({ orgId: orgId, query: debouncedSearchQuery, perPage: 10 diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 7d224c7b1..18e937ceb 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -7,7 +7,10 @@ import type { QueryConnectionAuditLogResponse, QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types"; -import type { ListClientsResponse } from "@server/routers/client"; +import type { + ListClientsResponse, + ListUserDevicesResponse +} from "@server/routers/client"; import type { GetDNSRecordsResponse, ListDomainsResponse @@ -25,6 +28,7 @@ import type { import type { ListRolesResponse } from "@server/routers/role"; import type { ListSitesResponse } from "@server/routers/site"; import type { + ListAllSiteResourcesByOrgResponse, ListSiteResourceClientsResponse, ListSiteResourceRolesResponse, ListSiteResourceUsersResponse @@ -38,7 +42,7 @@ import { queryOptions } from "@tanstack/react-query"; import type { AxiosResponse } from "axios"; -import z from "zod"; +import z, { meta } from "zod"; import { remote } from "./api"; import { durationToMs } from "./durationToMs"; import type { ListOrgLabelsResponse } from "@server/routers/labels/types"; @@ -138,6 +142,38 @@ export const orgQueries = { return res.data.data.clients; } }), + userDevices: ({ + orgId, + query, + perPage = 10_000 + }: { + orgId: string; + query?: string; + perPage?: number; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "USER_DEVICES", + { query, perPage } + ] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + pageSize: perPage.toString() + }); + + if (query?.trim()) { + sp.set("query", query); + } + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/user-devices?${sp.toString()}`, { signal }); + + return res.data.data.devices; + } + }), users: ({ orgId, query, @@ -282,7 +318,7 @@ export const orgQueries = { } }), - resources: ({ + proxyResources: ({ orgId, query, perPage = 10_000 @@ -292,7 +328,12 @@ export const orgQueries = { perPage?: number; }) => queryOptions({ - queryKey: ["ORG", orgId, "RESOURCES", { query, perPage }] as const, + queryKey: [ + "ORG", + orgId, + "PROXY_RESOURCES", + { query, perPage } + ] as const, queryFn: async ({ signal, meta }) => { const sp = new URLSearchParams({ pageSize: perPage.toString() @@ -310,6 +351,39 @@ export const orgQueries = { } }), + privateResources: ({ + orgId, + query, + perPage = 10_000 + }: { + orgId: string; + query?: string; + perPage?: number; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "PRIVATE_RESOURCES", + { query, perPage } + ] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + pageSize: perPage.toString() + }); + + if (query?.trim()) { + sp.set("query", query); + } + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/site-resources?${sp.toString()}`, { signal }); + + return res.data.data.siteResources; + } + }), + healthChecks: ({ orgId, perPage = 10_000 From 6380079239a599996255a0883348ed2fd9106b35 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 16 Jun 2026 22:28:31 +0200 Subject: [PATCH 08/12] =?UTF-8?q?=E2=9C=A8=20Command=20palette=20with=20se?= =?UTF-8?q?arch=20&=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + .../command-palette/CommandPalette.tsx | 362 ++++++++++-------- .../useCommandPaletteSearch.ts | 74 +++- 3 files changed, 270 insertions(+), 167 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index e6567cc08..cf32d15fd 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1626,6 +1626,7 @@ "commandBilling": "Billing", "commandEnterpriseLicenses": "Licenses", "commandSettings": "Settings", + "commandSearchResults": "Search Results", "alertingTitle": "Alerting", "alertingDescription": "Define sources, triggers, and actions for notifications", "alertingRules": "Alert rules", diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index 726d1c999..816c83fb3 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -15,6 +15,15 @@ import { } from "@app/components/ui/command"; import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; +import { + ChevronRightIcon, + GlobeIcon, + GlobeLockIcon, + LaptopIcon, + PlugIcon, + ServerIcon, + UserIcon +} from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import React, { @@ -28,7 +37,7 @@ import React, { import { useCommandPaletteActions } from "./useCommandPaletteActions"; import { useCommandPaletteNavigation } from "./useCommandPaletteNavigation"; import { useCommandPaletteSearch } from "./useCommandPaletteSearch"; -import { search } from "jmespath"; +import { resources } from "@server/db"; type CommandPaletteProps = { orgId?: string; @@ -54,12 +63,21 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { const navigationGroups = useCommandPaletteNavigation(navItems); // const organizations = useCommandPaletteOrganizations(orgs); const actions = useCommandPaletteActions(orgId, orgs); - const { shouldSearch, sites, resources, users, machineClients, isLoading } = - useCommandPaletteSearch({ - orgId, - query: search.substring(1), - enabled: open && isActionMode - }); + const { + shouldSearch, + sites, + publicResources, + privateResources, + users, + machineClients, + userDevices, + isLoading, + hasResults: hasEntityResults + } = useCommandPaletteSearch({ + orgId, + query: search, + enabled: !isActionMode && open + }); const handleOpenChange = useCallback( (nextOpen: boolean) => { @@ -80,15 +98,9 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { [setOpen] ); - // const hasEntityResults = - // sites.length > 0 || - // resources.length > 0 || - // users.length > 0 || - // machineClients.length > 0; - return ( - + {t("commandPaletteNoResults")} {!isActionMode && - navigationGroups.map((group, groupIndex) => { - console.log({ - groupIndex, - groupHeading: group.heading - }); - return ( - - {groupIndex > 0 && } - 0 && - "[&_[cmdk-group-heading]]:pt-3" - )} - > - {group.items.map((item) => ( - - runCommand(() => - router.push(item.href) - ) - } - className="h-9" - > - {item.icon} - {item.title} - - ))} - - - ); - })} + navigationGroups.map((group, groupIndex) => ( + + {groupIndex > 0 && } + 0 && + "[&_[cmdk-group-heading]]:pt-3" + )} + > + {group.items.map((item) => ( + + runCommand(() => + router.push(item.href) + ) + } + className="h-9" + > + {item.icon} + {item.title} + + ))} + + + ))} {/* {organizations.length > 1 && ( <> @@ -200,118 +206,157 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { )} */} - {/* {shouldSearch && orgId && ( - <> - - {isLoading && !hasEntityResults ? ( -
- - {t("commandPaletteSearching")} -
- ) : ( - <> - {sites.length > 0 && ( - - {sites.map((site) => ( - - runCommand(() => - router.push(site.href) - ) - } - > - - {site.name} - - - ))} - - )} - {resources.length > 0 && ( - - {resources.map((resource) => ( - - runCommand(() => - router.push( - resource.href - ) - ) - } - > - - {resource.name} - - - ))} - - )} - {users.length > 0 && ( - - {users.map((user) => ( - - runCommand(() => - router.push(user.href) - ) - } - > -
- - {user.name} - - - {user.email} - -
-
- ))} -
- )} - {machineClients.length > 0 && ( - - {machineClients.map((client) => ( - - runCommand(() => - router.push(client.href) - ) - } - > - - {client.name} - - - ))} - - )} - + {!isActionMode && shouldSearch && orgId && hasEntityResults && ( + - )} */} + > + {sites.map((site) => ( + + runCommand(() => router.push(site.href)) + } + className="h-9" + > +
+ + + {t("commandSites")} + + + + {site.name} + +
+
+ ))} + {publicResources.map((resource) => ( + + runCommand(() => router.push(resource.href)) + } + className="h-9" + > +
+ + + {t("commandProxyResources")} + + + + {resource.name} + +
+
+ ))} + {privateResources.map((resource) => ( + + runCommand(() => router.push(resource.href)) + } + className="h-9" + > +
+ + + {t("commandClientResources")} + + + + {resource.name} + +
+
+ ))} + {users.map((user) => ( + + runCommand(() => router.push(user.href)) + } + className="h-9" + > +
+ + + {t("commandUsers")} + + +
+ + {user.name} + + + · + + + {user.email} + +
+
+
+ ))} + {machineClients.map((client) => ( + + runCommand(() => router.push(client.href)) + } + className="h-9" + > +
+ + + {t("commandMachineClients")} + + + + {client.name} + +
+
+ ))} + {userDevices.map((device) => ( + + runCommand(() => router.push(device.href)) + } + className="h-9" + > +
+ + + {t("commandUserDevices")} + + + + {device.name} + +
+
+ ))} +
+ )} {isActionMode && actions.length > 0 && ( <> - {actions.map((action) => ( {action.icon} {action.label} @@ -387,7 +433,7 @@ export function CommandPaletteProvider({ orgs, navItems }: CommandPaletteProviderProps) { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(true); // FIXME: should be set to `false` by default, this is temporary const toggle = useCallback(() => { setOpen((current) => !current); diff --git a/src/components/command-palette/useCommandPaletteSearch.ts b/src/components/command-palette/useCommandPaletteSearch.ts index 984892354..7471438d5 100644 --- a/src/components/command-palette/useCommandPaletteSearch.ts +++ b/src/components/command-palette/useCommandPaletteSearch.ts @@ -47,7 +47,14 @@ export function useCommandPaletteSearch({ const shouldSearch = enabled && !!orgId && trimmedQuery.length >= MIN_QUERY_LENGTH; - const [sitesQuery, resourcesQuery, usersQuery, clientsQuery] = useQueries({ + const [ + sitesQuery, + proxyResourcesQuery, + privateResourcesQuery, + usersQuery, + clientsQuery, + userDevicesQuery + ] = useQueries({ queries: [ { ...orgQueries.sites({ @@ -58,7 +65,15 @@ export function useCommandPaletteSearch({ enabled: shouldSearch }, { - ...orgQueries.resources({ + ...orgQueries.proxyResources({ + orgId: orgId ?? "", + query: trimmedQuery, + perPage: SEARCH_PER_PAGE + }), + enabled: shouldSearch + }, + { + ...orgQueries.privateResources({ orgId: orgId ?? "", query: trimmedQuery, perPage: SEARCH_PER_PAGE @@ -80,6 +95,14 @@ export function useCommandPaletteSearch({ perPage: SEARCH_PER_PAGE }), enabled: shouldSearch + }, + { + ...orgQueries.userDevices({ + orgId: orgId ?? "", + query: trimmedQuery, + perPage: SEARCH_PER_PAGE + }), + enabled: shouldSearch } ] }); @@ -93,14 +116,23 @@ export function useCommandPaletteSearch({ })); }, [orgId, sitesQuery.data]); - const resources = useMemo((): ResourceSearchResult[] => { - if (!orgId || !resourcesQuery.data) return []; - return resourcesQuery.data.map((resource) => ({ + const publicResources = useMemo((): ResourceSearchResult[] => { + if (!orgId || !proxyResourcesQuery.data) return []; + return proxyResourcesQuery.data.map((resource) => ({ id: `resource-${resource.resourceId}`, name: resource.name, href: `/${orgId}/settings/resources/proxy/${resource.niceId}` })); - }, [orgId, resourcesQuery.data]); + }, [orgId, proxyResourcesQuery.data]); + + const privateResources = useMemo((): ResourceSearchResult[] => { + if (!orgId || !privateResourcesQuery.data) return []; + return privateResourcesQuery.data.map((resource) => ({ + id: `site-resource-${resource.siteResourceId}`, + name: resource.name, + href: `/${orgId}/settings/resources/private?query=${resource.name}` + })); + }, [orgId, privateResourcesQuery.data]); const users = useMemo((): UserSearchResult[] => { if (!orgId || !usersQuery.data) return []; @@ -123,20 +155,44 @@ export function useCommandPaletteSearch({ })); }, [orgId, clientsQuery.data]); + const userDevices = useMemo((): ClientSearchResult[] => { + if (!orgId || !userDevicesQuery.data) return []; + return userDevicesQuery.data + .filter((client) => !client.userId) + .map((client) => ({ + id: `client-${client.clientId}`, + name: client.name, + href: `/${orgId}/settings/clients/user/${client.niceId}` + })); + }, [orgId, userDevicesQuery.data]); + const isLoading = shouldSearch && (sitesQuery.isFetching || - resourcesQuery.isFetching || + proxyResourcesQuery.isFetching || + privateResourcesQuery.isFetching || usersQuery.isFetching || + userDevicesQuery.isFetching || clientsQuery.isFetching); + const hasResults = + sites.length > 0 || + publicResources.length > 0 || + users.length > 0 || + privateResources.length > 0 || + userDevices.length > 0 || + machineClients.length > 0; + return { debouncedQuery: trimmedQuery, shouldSearch, sites, - resources, + publicResources, + privateResources, users, machineClients, - isLoading + userDevices, + isLoading, + hasResults }; } From 4e7328a1cc2c37b4fa5f17e648395323dd1659ea Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 16 Jun 2026 22:29:03 +0200 Subject: [PATCH 09/12] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor=20private?= =?UTF-8?q?=20resource=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settings/resources/private/page.tsx | 45 ++----------------- src/components/PrivateResourcesTable.tsx | 6 +-- 2 files changed, 5 insertions(+), 46 deletions(-) diff --git a/src/app/[orgId]/settings/resources/private/page.tsx b/src/app/[orgId]/settings/resources/private/page.tsx index 23ff86296..3cd2507fc 100644 --- a/src/app/[orgId]/settings/resources/private/page.tsx +++ b/src/app/[orgId]/settings/resources/private/page.tsx @@ -1,18 +1,15 @@ +import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner"; import type { InternalResourceRow } from "@app/components/PrivateResourcesTable"; import PrivateResourcesTable from "@app/components/PrivateResourcesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import OrgProvider from "@app/providers/OrgProvider"; -import type { ListResourcesResponse } from "@server/routers/resource"; -import { GetSiteResponse } from "@server/routers/site/getSite"; import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; -import type ResponseT from "@server/types/Response"; import type { AxiosResponse } from "axios"; -import { getTranslations } from "next-intl/server"; import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; export const metadata: Metadata = { @@ -24,13 +21,6 @@ export interface ClientResourcesPageProps { searchParams: Promise>; } -function parsePositiveInt(s: string | undefined): number | undefined { - if (!s) return undefined; - const n = Number(s); - if (!Number.isInteger(n) || n <= 0) return undefined; - return n; -} - export default async function ClientResourcesPage( props: ClientResourcesPageProps ) { @@ -39,7 +29,7 @@ export default async function ClientResourcesPage( const searchParams = new URLSearchParams(await props.searchParams); let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; - let pagination: ListResourcesResponse["pagination"] = { + let pagination: ListAllSiteResourcesByOrgResponse["pagination"] = { total: 0, page: 1, pageSize: 20 @@ -56,34 +46,6 @@ export default async function ClientResourcesPage( pagination = responseData.pagination; } catch (e) {} - const siteIdParam = parsePositiveInt( - searchParams.get("siteId") ?? undefined - ); - - let initialFilterSite: { - siteId: number; - name: string; - type: string; - } | null = null; - if (siteIdParam) { - try { - const siteRes = await internal.get( - `/site/${siteIdParam}`, - await authCookieHeader() - ); - const s = (siteRes.data as ResponseT).data; - if (s && s.orgId === params.orgId) { - initialFilterSite = { - siteId: s.siteId, - name: s.name, - type: s.type - }; - } - } catch { - // leave null - } - } - let org = null; try { const res = await getCachedOrg(params.orgId); @@ -153,7 +115,6 @@ export default async function ClientResourcesPage( pageIndex: pagination.page - 1, pageSize: pagination.pageSize }} - initialFilterSite={initialFilterSite} /> diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index 2a4d63e85..c7b1fdf9f 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -117,15 +117,13 @@ type ClientResourcesTableProps = { orgId: string; pagination: PaginationState; rowCount: number; - initialFilterSite?: Selectedsite | null; }; export default function PrivateResourcesTable({ internalResources, orgId, pagination, - rowCount, - initialFilterSite = null + rowCount }: ClientResourcesTableProps) { const router = useRouter(); const { @@ -142,7 +140,7 @@ export default function PrivateResourcesTable({ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedInternalResource, setSelectedInternalResource] = - useState(); + useState(null); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [editingResource, setEditingResource] = useState(); From 2ab5540085bfb9415638455848bddcbb4ef903b6 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 19 Jun 2026 18:08:36 +0200 Subject: [PATCH 10/12] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20add=20command=20pale?= =?UTF-8?q?tte=20trigger=20on=20the=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/LayoutHeader.tsx | 2 ++ src/components/LayoutMobileMenu.tsx | 33 ++++++++++--------- .../command-palette/CommandPalette.tsx | 4 +-- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/components/LayoutHeader.tsx b/src/components/LayoutHeader.tsx index 29850f115..5e825a8e2 100644 --- a/src/components/LayoutHeader.tsx +++ b/src/components/LayoutHeader.tsx @@ -8,6 +8,7 @@ import { useTheme } from "next-themes"; import BrandingLogo from "./BrandingLogo"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger"; interface LayoutHeaderProps { showTopBar: boolean; @@ -67,6 +68,7 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) { {showTopBar && (
+
diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx index 13efdd564..8e7d0dfc0 100644 --- a/src/components/LayoutMobileMenu.tsx +++ b/src/components/LayoutMobileMenu.tsx @@ -1,27 +1,27 @@ "use client"; -import React, { useState } from "react"; -import { SidebarNav } from "@app/components/SidebarNav"; -import { OrgSelector } from "@app/components/OrgSelector"; -import { cn } from "@app/lib/cn"; -import { ListUserOrgsResponse } from "@server/routers/org"; -import { Button } from "@app/components/ui/button"; -import { ArrowRight, Menu, Server } from "lucide-react"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { useUserContext } from "@app/hooks/useUserContext"; -import { useTranslations } from "next-intl"; -import ProfileIcon from "@app/components/ProfileIcon"; -import ThemeSwitcher from "@app/components/ThemeSwitcher"; import type { SidebarNavSection } from "@app/app/navigation"; +import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger"; +import { OrgSelector } from "@app/components/OrgSelector"; +import ProfileIcon from "@app/components/ProfileIcon"; +import { SidebarNav } from "@app/components/SidebarNav"; +import ThemeSwitcher from "@app/components/ThemeSwitcher"; +import { Button } from "@app/components/ui/button"; import { Sheet, SheetContent, - SheetTrigger, + SheetDescription, SheetTitle, - SheetDescription + SheetTrigger } from "@app/components/ui/sheet"; -import { Abel } from "next/font/google"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { cn } from "@app/lib/cn"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import { Menu, Server } from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useState } from "react"; interface LayoutMobileMenuProps { orgId?: string; @@ -121,6 +121,7 @@ export function LayoutMobileMenu({ {showTopBar && (
+
diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index 816c83fb3..514ef6e1a 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -100,7 +100,7 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { return ( { setOpen((current) => !current); From 8f0e17774f07292d9187f22924e3f9a85d7d8e63 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 22 Jun 2026 21:22:55 +0200 Subject: [PATCH 11/12] =?UTF-8?q?=F0=9F=92=84=20Update=20command=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../useCommandPaletteActions.tsx | 25 ++++++------------- src/components/ui/command.tsx | 3 ++- 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/src/components/command-palette/useCommandPaletteActions.tsx b/src/components/command-palette/useCommandPaletteActions.tsx index 998fb97b8..4b288c3bc 100644 --- a/src/components/command-palette/useCommandPaletteActions.tsx +++ b/src/components/command-palette/useCommandPaletteActions.tsx @@ -86,6 +86,12 @@ export function useCommandPaletteActions( icon: , href: `/${orgId}/settings/resources/proxy/create` }); + actions.push({ + id: "create-machine-client", + label: t("commandPaletteCreateMachineClient"), + icon: , + href: `/${orgId}/settings/clients/machine/create` + }); actions.push({ id: "create-user", label: t("commandPaletteCreateUser"), @@ -98,12 +104,6 @@ export function useCommandPaletteActions( icon: , href: `/${orgId}/settings/api-keys/create` }); - actions.push({ - id: "create-machine-client", - label: t("commandPaletteCreateMachineClient"), - icon: , - href: `/${orgId}/settings/clients/machine/create` - }); if (!env?.flags.disableEnterpriseFeatures) { actions.push({ @@ -129,17 +129,6 @@ export function useCommandPaletteActions( } } - const canChooseOrganization = !isAdminPage && (orgs?.length ?? 0) > 1; - - if (canChooseOrganization) { - actions.push({ - id: "choose-org", - label: t("commandPaletteChooseOrganization"), - icon: , - href: "/?orgs=1" - }); - } - actions.push({ id: "toggle-theme", label: t("commandPaletteToggleTheme"), @@ -158,4 +147,4 @@ export function useCommandPaletteActions( return actions; }, [isAdminPage, orgId, orgs, env, user.serverAdmin, theme, setTheme, t]); -} \ No newline at end of file +} diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index efd1c35f5..3d577c275 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -174,10 +174,11 @@ function CommandShortcut({ ...props }: React.ComponentProps<"span">) { return ( - Date: Wed, 8 Jul 2026 00:58:03 +0200 Subject: [PATCH 12/12] =?UTF-8?q?=F0=9F=90=9B=20fix=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/LayoutMobileMenu.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx index 7278b2371..52536820e 100644 --- a/src/components/LayoutMobileMenu.tsx +++ b/src/components/LayoutMobileMenu.tsx @@ -3,14 +3,6 @@ import type { SidebarNavSection } from "@app/app/navigation"; import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger"; import { OrgSelector } from "@app/components/OrgSelector"; -import { cn } from "@app/lib/cn"; -import { ListUserOrgsResponse } from "@server/routers/org"; -import { Button } from "@app/components/ui/button"; -import { Menu, Server, Settings, SquareMousePointer } from "lucide-react"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { useUserContext } from "@app/hooks/useUserContext"; -import { useTranslations } from "next-intl"; import ProfileIcon from "@app/components/ProfileIcon"; import { SidebarNav } from "@app/components/SidebarNav"; import ThemeSwitcher from "@app/components/ThemeSwitcher"; @@ -25,7 +17,7 @@ import { import { useUserContext } from "@app/hooks/useUserContext"; import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; -import { Menu, Server } from "lucide-react"; +import { Menu, Server, Settings, SquareMousePointer } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { usePathname } from "next/navigation";