diff --git a/apps/dashboard/src/app/(dashboard)/invite/client.tsx b/apps/dashboard/src/app/(dashboard)/invite/client.tsx index 1b7c5f26..f8597840 100644 --- a/apps/dashboard/src/app/(dashboard)/invite/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/invite/client.tsx @@ -15,6 +15,7 @@ import { SectionTitle, } from "@/components/content/section"; import { useTRPC } from "@/lib/trpc/client"; +import { switchWorkspace } from "@/lib/workspace-cookie"; import { searchParamsParsers } from "./search-params"; @@ -30,8 +31,7 @@ export function Client() { trpc.invitation.accept.mutationOptions({ onSuccess: (workspace) => { if (!workspace) return; - document.cookie = `workspace-slug=${workspace.slug}; path=/;`; - window.location.href = "/overview"; + switchWorkspace(workspace.slug); }, }), ); diff --git a/apps/dashboard/src/app/(dashboard)/layout.tsx b/apps/dashboard/src/app/(dashboard)/layout.tsx index b251d576..499cac3c 100644 --- a/apps/dashboard/src/app/(dashboard)/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/layout.tsx @@ -4,6 +4,8 @@ import { } from "@openstatus/ui/components/ui/sidebar"; import { cookies } from "next/headers"; +import { CommandMenu } from "@/components/command-menu/command-menu"; +import { CommandMenuProvider } from "@/components/command-menu/provider"; import { DismissedNotesProvider } from "@/components/common/note-dismissible"; import { AppSidebar } from "@/components/nav/app-sidebar"; import { @@ -31,8 +33,11 @@ export default async function Layout({ defaultOpen={defaultOpen} cookieName={LEFT_SIDEBAR_COOKIE} > - - {children} + + + + {children} + @@ -45,6 +50,7 @@ async function HydrateSidebar({ children }: { children: React.ReactNode }) { queryClient.prefetchQuery(trpc.page.list.queryOptions()), queryClient.prefetchQuery(trpc.monitor.list.queryOptions()), queryClient.prefetchQuery(trpc.workspace.get.queryOptions()), + queryClient.prefetchQuery(trpc.workspace.list.queryOptions()), queryClient.prefetchQuery(trpc.user.get.queryOptions()), ]); diff --git a/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx b/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx index b6993c53..ea75b841 100644 --- a/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx +++ b/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx @@ -9,15 +9,13 @@ import { DropdownMenuTrigger, } from "@openstatus/ui/components/ui/dropdown-menu"; import { ChevronDown, Plus } from "lucide-react"; -import { useRef } from "react"; +import { useState } from "react"; import { FormSheetMaintenanceCreate } from "@/components/forms/maintenance/sheet-create"; import { FormSheetStatusReportCreate } from "@/components/forms/status-report/sheet-create"; export function CreateEventButtonGroup() { - // the maintenance sheet lives outside the dropdown — a trigger inside - // DropdownMenuContent unmounts (and closes the sheet) when the menu closes - const maintenanceButtonRef = useRef(null); + const [maintenanceOpen, setMaintenanceOpen] = useState(false); return (
@@ -40,20 +38,17 @@ export function CreateEventButtonGroup() { - maintenanceButtonRef.current?.click()} - > + setMaintenanceOpen(true)}> Create Maintenance - - - +
); } diff --git a/apps/dashboard/src/app/(dashboard)/settings/account/page.tsx b/apps/dashboard/src/app/(dashboard)/settings/account/page.tsx index aabf7807..f9466526 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/account/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/account/page.tsx @@ -51,7 +51,7 @@ export default function Page() { Account - + Personal Information @@ -77,7 +77,7 @@ export default function Page() { - + Appearance @@ -88,7 +88,7 @@ export default function Page() { - + Delete Account diff --git a/apps/dashboard/src/app/(dashboard)/settings/tabs.tsx b/apps/dashboard/src/app/(dashboard)/settings/tabs.tsx index 80f92c3b..93fc88a6 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/tabs.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/tabs.tsx @@ -1,50 +1,17 @@ "use client"; -import { Blocks, Cog, CreditCard, Globe, ScanEye, User } from "lucide-react"; - import { NavTabs } from "@/components/nav/nav-tabs"; +import { SETTINGS_TABS } from "@/config/settings"; export function Tabs() { return ( ({ + value, + label, + icon, + href, + }))} /> ); } diff --git a/apps/dashboard/src/app/api/chat/route.ts b/apps/dashboard/src/app/api/chat/route.ts index 546e6b96..bd135dbf 100644 --- a/apps/dashboard/src/app/api/chat/route.ts +++ b/apps/dashboard/src/app/api/chat/route.ts @@ -25,6 +25,7 @@ import { z } from "zod"; import { toAiSdkTools } from "@/lib/agent-tools/adapter"; import { getChatServiceContext } from "@/lib/agent-tools/context"; import { chatRateLimit } from "@/lib/rate-limit/chat"; +import { WORKSPACE_SLUG_COOKIE } from "@/lib/workspace-cookie"; // Drop tool parts that have no result the SDK can turn into a tool_result // block. `ignoreIncompleteToolCalls` doesn't cover `approval-requested`, so @@ -51,7 +52,7 @@ const requestSchema = z.object({ }); export async function POST(req: NextRequest) { - const workspaceSlug = (await cookies()).get("workspace-slug")?.value; + const workspaceSlug = (await cookies()).get(WORKSPACE_SLUG_COOKIE)?.value; const ctx = await getChatServiceContext({ workspaceSlug }); if (!ctx || ctx.actor.type !== "user") { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/apps/dashboard/src/components/command-menu/command-menu.tsx b/apps/dashboard/src/components/command-menu/command-menu.tsx new file mode 100644 index 00000000..0e8a7f6f --- /dev/null +++ b/apps/dashboard/src/components/command-menu/command-menu.tsx @@ -0,0 +1,406 @@ +"use client"; + +import { + Command, + CommandEmpty, + CommandGroup, + CommandItem, + CommandList, + CommandLoading, +} from "@openstatus/ui/components/ui/command"; +import { + Dialog, + DialogContent, + DialogTitle, +} from "@openstatus/ui/components/ui/dialog"; +import { useQuery } from "@tanstack/react-query"; +import { Command as CommandPrimitive } from "cmdk"; +import { Moon, Search, Sun, X } from "lucide-react"; +import { useTheme } from "next-themes"; +import { useRouter } from "next/navigation"; +import * as React from "react"; + +import { FormSheetMaintenanceCreate } from "@/components/forms/maintenance/sheet-create"; +import { FormSheetStatusReportCreate } from "@/components/forms/status-report/sheet-create"; +import { FormDialogSupportContact } from "@/components/forms/support-contact/dialog"; +import { scrollToHash, useScrollToHash } from "@/hooks/use-scroll-to-hash"; +import { useTRPC } from "@/lib/trpc/client"; +import { switchWorkspace } from "@/lib/workspace-cookie"; + +import { + type CommandLink, + type CommandPage, + type CommandSheetAction, + CREATE_ACTIONS, + CREATE_LINKS, + HELP_LINK_ITEMS, + HELP_SUPPORT_ACTION, + MONITOR_ACTIONS, + NAVIGATION, + SETTINGS, + STATUS_PAGE_ACTIONS, +} from "./config"; +import { useCommandMenu } from "./provider"; + +type ActiveSheet = CommandSheetAction["sheet"] | null; + +const IDLE_CAP = 5; + +function LinkItem({ + item, + onSelect, +}: { + item: CommandLink; + onSelect: (item: CommandLink) => void; +}) { + return ( + onSelect(item)} + > + + {item.label} + + ); +} + +function ActionItem({ + action, + onSelect, +}: { + action: CommandSheetAction; + onSelect: () => void; +}) { + return ( + + + {action.label} + + ); +} + +export function CommandMenu() { + const { open, setOpen } = useCommandMenu(); + const [search, setSearch] = React.useState(""); + const [pages, setPages] = React.useState([]); + const [activeSheet, setActiveSheet] = React.useState(null); + const [mounted, setMounted] = React.useState(false); + const inputRef = React.useRef(null); + + const router = useRouter(); + const { resolvedTheme, setTheme } = useTheme(); + const trpc = useTRPC(); + + useScrollToHash(); + + const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); + const { data: statusPages } = useQuery(trpc.page.list.queryOptions()); + const { data: workspaces } = useQuery(trpc.workspace.list.queryOptions()); + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + + const page = pages.length > 0 ? pages[pages.length - 1] : null; + + React.useEffect(() => setMounted(true), []); + + React.useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + setOpen((prev) => !prev); + } + }; + document.addEventListener("keydown", down); + return () => document.removeEventListener("keydown", down); + }, [setOpen]); + + // Reset scope + query every time the palette opens. + React.useEffect(() => { + if (open) { + setSearch(""); + setPages([]); + } + }, [open]); + + const pop = React.useCallback(() => { + setPages((prev) => prev.slice(0, -1)); + setSearch(""); + }, []); + + const pushPage = (next: CommandPage) => { + setPages((prev) => [...prev, next]); + setSearch(""); + }; + + const navigate = (href: string) => { + router.push(href); + setOpen(false); + const hashIndex = href.indexOf("#"); + if (hashIndex !== -1) scrollToHash(href.slice(hashIndex + 1)); + }; + + const selectLink = (item: CommandLink) => { + if (item.external) { + window.open(item.href, "_blank", "noreferrer"); + setOpen(false); + return; + } + navigate(item.href); + }; + + const openSheet = (sheet: Exclude) => { + setOpen(false); + // Defer so the palette Dialog releases focus + scroll lock before the + // sheet/dialog claims them — otherwise Radix layers fight during overlap. + setTimeout(() => setActiveSheet(sheet), 0); + }; + + const idleCap = (items: T[]) => + search ? items : items.slice(0, IDLE_CAP); + + const otherWorkspaces = + workspaces?.filter((w) => w.slug !== workspace?.slug) ?? []; + + return ( + <> + + { + e.preventDefault(); + inputRef.current?.focus(); + }} + className="top-[15%] translate-y-0 overflow-hidden p-0 lg:max-w-xl" + > + Command Menu + { + if (e.key === "Backspace" && !search && pages.length > 0) { + e.preventDefault(); + pop(); + } + }} + > +
+ + {page ? ( + + {page.type === "monitor" ? page.name : page.title} + + + ) : null} + +
+ + No results found. + + {page ? ( + + {(page.type === "monitor" + ? MONITOR_ACTIONS(page.id) + : STATUS_PAGE_ACTIONS(page.id) + ).map((item) => ( + navigate(item.href)} + > + + {item.label} + + ))} + + ) : ( + <> + {monitors === undefined ? ( + Loading… + ) : null} + + {monitors && monitors.length > 0 ? ( + + {idleCap(monitors).map((m) => ( + + pushPage({ + type: "monitor", + id: m.id, + name: m.name, + }) + } + > +
+ {m.name} + + {m.url} + +
+
+ ))} +
+ ) : null} + + {statusPages && statusPages.length > 0 ? ( + + {idleCap(statusPages).map((p) => ( + + pushPage({ + type: "status-page", + id: p.id, + title: p.title, + }) + } + > +
+ {p.title} + + {p.slug} + +
+
+ ))} +
+ ) : null} + + + {NAVIGATION.map((item) => ( + + ))} + + + + {CREATE_LINKS.map((item) => ( + + ))} + {CREATE_ACTIONS.map((action) => ( + openSheet(action.sheet)} + /> + ))} + + + + {SETTINGS.map((item) => ( + + ))} + + + {otherWorkspaces.length > 0 ? ( + + {otherWorkspaces.map((w) => ( + switchWorkspace(w.slug)} + > + + {w.name || "Untitled Workspace"} + + + {w.slug} + + + ))} + + ) : null} + + + openSheet("support")} + /> + {HELP_LINK_ITEMS.map((item) => ( + + ))} + + + {mounted ? ( + + + setTheme(resolvedTheme === "dark" ? "light" : "dark") + } + > + {resolvedTheme === "dark" ? : } + + Switch to{" "} + {resolvedTheme === "dark" ? "light" : "dark"} theme + + + + ) : null} + + )} +
+
+
+
+ + setActiveSheet(o ? "status-report" : null)} + /> + setActiveSheet(o ? "maintenance" : null)} + /> + setActiveSheet(o ? "support" : null)} + /> + + ); +} diff --git a/apps/dashboard/src/components/command-menu/config.ts b/apps/dashboard/src/components/command-menu/config.ts new file mode 100644 index 00000000..bd45b7e7 --- /dev/null +++ b/apps/dashboard/src/components/command-menu/config.ts @@ -0,0 +1,154 @@ +import { + AtSign, + KeyRound, + Palette, + Pencil, + Plus, + Trash2, + Users, +} from "lucide-react"; +import type * as React from "react"; + +import { MONITOR_TABS } from "@/app/(dashboard)/monitors/[id]/constants"; +import { STATUS_PAGE_TABS } from "@/app/(dashboard)/status-pages/[id]/constants"; +import { HELP_LINKS, HELP_SUPPORT } from "@/config/help"; +import { NAV_MENU_ITEMS } from "@/config/nav"; +import { SETTINGS_TABS } from "@/config/settings"; + +// Lucide icons and the custom brand icons both accept svg props. +export type CommandIcon = React.ComponentType>; + +export type CommandLink = { + label: string; + href: string; + icon: CommandIcon; + keywords?: string[]; + external?: boolean; +}; + +export type CommandSheetAction = { + label: string; + icon: CommandIcon; + keywords?: string[]; + sheet: "status-report" | "maintenance" | "support"; +}; + +export type CommandPage = + | { type: "monitor"; id: number; name: string } + | { type: "status-page"; id: number; title: string }; + +export const NAVIGATION: CommandLink[] = NAV_MENU_ITEMS.map((item) => ({ + label: item.label, + href: item.href, + icon: item.icon, + keywords: item.keywords, +})); + +export const CREATE_LINKS: CommandLink[] = [ + { + label: "Create Monitor", + href: "/monitors/create", + icon: Plus, + keywords: ["new monitor"], + }, + { + label: "Create Status Page", + href: "/status-pages/create", + icon: Plus, + keywords: ["new page"], + }, +]; + +export const CREATE_ACTIONS: CommandSheetAction[] = [ + { + label: "Create Status Report", + icon: Plus, + keywords: ["incident", "announce", "publish"], + sheet: "status-report", + }, + { + label: "Create Maintenance", + icon: Plus, + keywords: ["downtime", "window", "schedule"], + sheet: "maintenance", + }, +]; + +const SETTINGS_TAB_LINKS: CommandLink[] = SETTINGS_TABS.map((tab) => ({ + label: tab.label, + href: tab.href, + icon: tab.icon, + keywords: tab.keywords, +})); + +// Deep-links into settings cards via anchor ids. Icons are only used here, so +// they live with the command menu rather than a shared config. +const SETTINGS_ANCHORS: CommandLink[] = [ + { + label: "API Keys", + href: "/settings/general#api-keys", + icon: KeyRound, + keywords: ["token", "key"], + }, + { + label: "Team Members", + href: "/settings/general#team-members", + icon: Users, + keywords: ["invite", "member"], + }, + { + label: "Workspace Slug", + href: "/settings/general#workspace-slug", + icon: AtSign, + }, + { + label: "Workspace Name", + href: "/settings/general#workspace-name", + icon: Pencil, + keywords: ["rename"], + }, + { + label: "Appearance", + href: "/settings/account#appearance", + icon: Palette, + keywords: ["theme", "dark mode"], + }, + { + label: "Delete Account", + href: "/settings/account#delete-account", + icon: Trash2, + }, +]; + +export const SETTINGS: CommandLink[] = [ + ...SETTINGS_TAB_LINKS, + ...SETTINGS_ANCHORS, +]; + +export const HELP_SUPPORT_ACTION: CommandSheetAction = { + label: HELP_SUPPORT.label, + icon: HELP_SUPPORT.icon, + keywords: HELP_SUPPORT.keywords, + sheet: "support", +}; + +export const HELP_LINK_ITEMS: CommandLink[] = HELP_LINKS.map((link) => ({ + ...link, + external: true, +})); + +export function MONITOR_ACTIONS(id: number): CommandLink[] { + return MONITOR_TABS.map((tab) => ({ + label: tab.label, + href: `/monitors/${id}/${tab.value}`, + icon: tab.icon, + })); +} + +export function STATUS_PAGE_ACTIONS(id: number): CommandLink[] { + return STATUS_PAGE_TABS.map((tab) => ({ + label: tab.label, + href: `/status-pages/${id}/${tab.value}`, + icon: tab.icon, + })); +} diff --git a/apps/dashboard/src/components/command-menu/provider.tsx b/apps/dashboard/src/components/command-menu/provider.tsx new file mode 100644 index 00000000..46ec5147 --- /dev/null +++ b/apps/dashboard/src/components/command-menu/provider.tsx @@ -0,0 +1,34 @@ +"use client"; + +import * as React from "react"; + +type CommandMenuContextValue = { + open: boolean; + setOpen: React.Dispatch>; +}; + +const CommandMenuContext = React.createContext( + null, +); + +export function CommandMenuProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [open, setOpen] = React.useState(false); + const value = React.useMemo(() => ({ open, setOpen }), [open]); + return ( + + {children} + + ); +} + +export function useCommandMenu() { + const ctx = React.useContext(CommandMenuContext); + if (!ctx) { + throw new Error("useCommandMenu must be used within a CommandMenuProvider"); + } + return ctx; +} diff --git a/apps/dashboard/src/components/forms/form-card.tsx b/apps/dashboard/src/components/forms/form-card.tsx index b20c9b52..93713134 100644 --- a/apps/dashboard/src/components/forms/form-card.tsx +++ b/apps/dashboard/src/components/forms/form-card.tsx @@ -10,9 +10,9 @@ import { Separator } from "@openstatus/ui/components/ui/separator"; import { cn } from "@openstatus/ui/lib/utils"; import { type VariantProps, cva } from "class-variance-authority"; -// py-0 +// py-0; scroll-mt keeps `#anchor` deep-links clear of the sticky header + tabs. const formCardVariants = cva( - "group relative w-full gap-4 overflow-hidden py-0 shadow-none", + "group relative w-full scroll-mt-28 gap-4 overflow-hidden py-0 shadow-none", { variants: { variant: { diff --git a/apps/dashboard/src/components/forms/maintenance/sheet-create.tsx b/apps/dashboard/src/components/forms/maintenance/sheet-create.tsx index d6d12c70..b5135bfe 100644 --- a/apps/dashboard/src/components/forms/maintenance/sheet-create.tsx +++ b/apps/dashboard/src/components/forms/maintenance/sheet-create.tsx @@ -37,10 +37,16 @@ import { useTRPC } from "@/lib/trpc/client"; */ export function FormSheetMaintenanceCreate({ children, + open: controlledOpen, + onOpenChange, }: { - children: React.ReactNode; + children?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; }) { - const [open, setOpen] = useState(false); + const [internalOpen, setInternalOpen] = useState(false); + const open = controlledOpen ?? internalOpen; + const setOpen = onOpenChange ?? setInternalOpen; const [selectedPageId, setSelectedPageId] = useState(null); const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -75,7 +81,9 @@ export function FormSheetMaintenanceCreate({ return ( - {children} + {children ? ( + {children} + ) : null} Maintenance diff --git a/apps/dashboard/src/components/forms/settings/form-api-key.tsx b/apps/dashboard/src/components/forms/settings/form-api-key.tsx index 83486abd..979e18e6 100644 --- a/apps/dashboard/src/components/forms/settings/form-api-key.tsx +++ b/apps/dashboard/src/components/forms/settings/form-api-key.tsx @@ -164,7 +164,7 @@ export function FormApiKey() { } return ( - + API Keys diff --git a/apps/dashboard/src/components/forms/settings/form-members.tsx b/apps/dashboard/src/components/forms/settings/form-members.tsx index fc2bf439..f33041d9 100644 --- a/apps/dashboard/src/components/forms/settings/form-members.tsx +++ b/apps/dashboard/src/components/forms/settings/form-members.tsx @@ -83,7 +83,7 @@ export function FormMembers({ return (
- + {locked ? : null} Team diff --git a/apps/dashboard/src/components/forms/settings/form-slug.tsx b/apps/dashboard/src/components/forms/settings/form-slug.tsx index 74282de8..b49fb496 100644 --- a/apps/dashboard/src/components/forms/settings/form-slug.tsx +++ b/apps/dashboard/src/components/forms/settings/form-slug.tsx @@ -27,7 +27,7 @@ export function FormSlug({ defaultValues }: { defaultValues?: FormValues }) { console.log({ defaultValues, schema }); return ( - + Slug diff --git a/apps/dashboard/src/components/forms/settings/form-workspace.tsx b/apps/dashboard/src/components/forms/settings/form-workspace.tsx index 985039c4..c4272eec 100644 --- a/apps/dashboard/src/components/forms/settings/form-workspace.tsx +++ b/apps/dashboard/src/components/forms/settings/form-workspace.tsx @@ -68,7 +68,7 @@ export function FormWorkspace({ return ( - + Workspace diff --git a/apps/dashboard/src/components/forms/status-report/sheet-create.tsx b/apps/dashboard/src/components/forms/status-report/sheet-create.tsx index 7f1bdc94..86be18f4 100644 --- a/apps/dashboard/src/components/forms/status-report/sheet-create.tsx +++ b/apps/dashboard/src/components/forms/status-report/sheet-create.tsx @@ -11,7 +11,7 @@ import { } from "@openstatus/ui/components/ui/select"; import { Separator } from "@openstatus/ui/components/ui/separator"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useRef, useState } from "react"; +import { useState } from "react"; import { EmptyStateContainer, @@ -37,10 +37,16 @@ import { useTRPC } from "@/lib/trpc/client"; */ export function FormSheetStatusReportCreate({ children, + open: controlledOpen, + onOpenChange, }: { - children: React.ReactNode; + children?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; }) { - const [open, setOpen] = useState(false); + const [internalOpen, setInternalOpen] = useState(false); + const open = controlledOpen ?? internalOpen; + const setOpen = onOpenChange ?? setInternalOpen; const [selectedPageId, setSelectedPageId] = useState(null); const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -88,7 +94,9 @@ export function FormSheetStatusReportCreate({ return ( - {children} + {children ? ( + {children} + ) : null} Status Report diff --git a/apps/dashboard/src/components/forms/support-contact/dialog.tsx b/apps/dashboard/src/components/forms/support-contact/dialog.tsx index bab020f4..f3cc2f30 100644 --- a/apps/dashboard/src/components/forms/support-contact/dialog.tsx +++ b/apps/dashboard/src/components/forms/support-contact/dialog.tsx @@ -18,11 +18,17 @@ import { ContactForm, type FormValues } from "./form"; export function FormDialogSupportContact({ children, defaultValues, + open: controlledOpen, + onOpenChange, ...props }: React.ComponentProps & { defaultValues?: FormValues; + open?: boolean; + onOpenChange?: (open: boolean) => void; }) { - const [open, setOpen] = useState(false); + const [internalOpen, setInternalOpen] = useState(false); + const open = controlledOpen ?? internalOpen; + const setOpen = onOpenChange ?? setInternalOpen; const isMobile = useIsMobile(); const trpc = useTRPC(); const { data: user } = useQuery(trpc.user.get.queryOptions()); @@ -30,9 +36,11 @@ export function FormDialogSupportContact({ return ( - - {children} - + {children ? ( + + {children} + + ) : null} Support diff --git a/apps/dashboard/src/components/nav/app-sidebar.tsx b/apps/dashboard/src/components/nav/app-sidebar.tsx index 570f8a8e..d003704a 100644 --- a/apps/dashboard/src/components/nav/app-sidebar.tsx +++ b/apps/dashboard/src/components/nav/app-sidebar.tsx @@ -15,15 +15,6 @@ import { TooltipProvider, TooltipTrigger, } from "@openstatus/ui/components/ui/tooltip"; -import { - Activity, - Bell, - Bot, - Cog, - LayoutGrid, - MessageSquare, - PanelTop, -} from "lucide-react"; import * as React from "react"; import { Kbd } from "@/components/common/kbd"; @@ -32,6 +23,7 @@ import { NavOverview } from "@/components/nav/nav-overview"; import { NavStatusPages } from "@/components/nav/nav-status-pages"; import { NavUser } from "@/components/nav/nav-user"; import { WorkspaceSwitcher } from "@/components/nav/workspace-switcher"; +import { NAV } from "@/config/nav"; import { NavBanner } from "./nav-banner"; import { NavHelp } from "./nav-help"; @@ -46,41 +38,21 @@ const data = { avatar: "/avatars/shadcn.jpg", }, overview: [ - { - name: "Overview", - url: "/overview", - icon: LayoutGrid, - }, + { name: "Overview", url: NAV.overview.href, icon: NAV.overview.icon }, { name: "Status Pages", - url: "/status-pages", - icon: PanelTop, - }, - { - name: "Monitors", - url: "/monitors", - icon: Activity, + url: NAV.statusPages.href, + icon: NAV.statusPages.icon, }, + { name: "Monitors", url: NAV.monitors.href, icon: NAV.monitors.icon }, { name: "Notifications", - url: "/notifications", - icon: Bell, - }, - { - name: "Settings", - url: "/settings/general", - icon: Cog, - }, - { - name: "Assistant", - url: "/chat", - icon: MessageSquare, - }, - { - name: "Slack agent", - url: "/agents", - icon: Bot, + url: NAV.notifications.href, + icon: NAV.notifications.icon, }, + { name: "Settings", url: NAV.settings.href, icon: NAV.settings.icon }, + { name: "Assistant", url: NAV.chat.href, icon: NAV.chat.icon }, + { name: "Slack agent", url: NAV.agents.href, icon: NAV.agents.icon }, ], }; diff --git a/apps/dashboard/src/components/nav/nav-help.tsx b/apps/dashboard/src/components/nav/nav-help.tsx index a8469dd2..ad3b29ad 100644 --- a/apps/dashboard/src/components/nav/nav-help.tsx +++ b/apps/dashboard/src/components/nav/nav-help.tsx @@ -1,7 +1,5 @@ "use client"; -import { DiscordIcon } from "@openstatus/icons"; -import { GitHubIcon } from "@openstatus/icons"; import { DropdownMenu, DropdownMenuContent, @@ -17,16 +15,11 @@ import { SidebarMenuItem, useSidebar, } from "@openstatus/ui/components/ui/sidebar"; -import { - Book, - Braces, - CalendarClock, - HelpCircle, - LifeBuoy, -} from "lucide-react"; +import { HelpCircle } from "lucide-react"; import Link from "next/link"; import { FormDialogSupportContact } from "@/components/forms/support-contact/dialog"; +import { HELP_LINKS, HELP_SUPPORT } from "@/config/help"; export function NavHelp() { const { isMobile } = useSidebar(); @@ -56,57 +49,18 @@ export function NavHelp() { e.preventDefault()}> - - Support + + {HELP_SUPPORT.label} - - - Docs - - - - - API Reference - - - - - Book a Call - - - - - - Community - - - - - - GitHub - - + {HELP_LINKS.map((item) => ( + + + + {item.label} + + + ))} diff --git a/apps/dashboard/src/components/nav/nav-overview.tsx b/apps/dashboard/src/components/nav/nav-overview.tsx index fe131bac..9cdfead0 100644 --- a/apps/dashboard/src/components/nav/nav-overview.tsx +++ b/apps/dashboard/src/components/nav/nav-overview.tsx @@ -12,6 +12,8 @@ import type { LucideIcon } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { NavSearch } from "@/components/nav/nav-search"; + function topSegment(url: string) { return url.split("/")[1] ?? ""; } @@ -32,6 +34,7 @@ export function NavOverview({ Workspace + {items.map((item) => ( + { + setOpenMobile(false); + setOpen(true); + }} + tooltip="Search" + variant="outline" + className="group/search" + > + + Search +
+ ⌘ + K +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/nav/workspace-switcher.tsx b/apps/dashboard/src/components/nav/workspace-switcher.tsx index 3b423b83..45205daf 100644 --- a/apps/dashboard/src/components/nav/workspace-switcher.tsx +++ b/apps/dashboard/src/components/nav/workspace-switcher.tsx @@ -20,6 +20,7 @@ import { ChevronsUpDown, Plus } from "lucide-react"; import { Link } from "@/components/common/link"; import { useTRPC } from "@/lib/trpc/client"; +import { switchWorkspace } from "@/lib/workspace-cookie"; interface WorkspaceSwitcherProps { className?: string; @@ -34,11 +35,6 @@ export function WorkspaceSwitcher({ className, side }: WorkspaceSwitcherProps) { if (!workspace) return null; - function handleClick(slug: string) { - document.cookie = `workspace-slug=${slug}; path=/;`; - window.location.href = "/overview"; - } - return ( @@ -88,7 +84,7 @@ export function WorkspaceSwitcher({ className, side }: WorkspaceSwitcherProps) { { - handleClick(workspace.slug); + switchWorkspace(workspace.slug); setOpenMobile(false); }} className="gap-2 p-2" diff --git a/apps/dashboard/src/config/help.ts b/apps/dashboard/src/config/help.ts new file mode 100644 index 00000000..34102e4a --- /dev/null +++ b/apps/dashboard/src/config/help.ts @@ -0,0 +1,54 @@ +import { DiscordIcon, GitHubIcon } from "@openstatus/icons"; +import { Book, Braces, CalendarClock, LifeBuoy } from "lucide-react"; +import type * as React from "react"; + +// Lucide icons and the custom brand icons both accept svg props. +export type HelpIcon = React.ComponentType>; + +// Single source of truth for the Get Help menu (sidebar nav-help) and the +// command-menu "Get Help" group so their icons stay in sync. +export const HELP_SUPPORT = { + label: "Support", + icon: LifeBuoy as HelpIcon, + keywords: ["feedback", "help", "contact"], +}; + +export type HelpLink = { + label: string; + href: string; + icon: HelpIcon; + keywords?: string[]; +}; + +export const HELP_LINKS = [ + { + label: "Docs", + href: "https://www.openstatus.dev/docs", + icon: Book, + keywords: ["documentation"], + }, + { + label: "API Reference", + href: "https://api.openstatus.dev/openapi", + icon: Braces, + keywords: ["openapi", "swagger"], + }, + { + label: "Book a Call", + href: "https://openstatus.dev/cal", + icon: CalendarClock, + keywords: ["demo", "meeting"], + }, + { + label: "Community", + href: "https://openstatus.dev/discord", + icon: DiscordIcon, + keywords: ["discord", "chat"], + }, + { + label: "GitHub", + href: "https://openstatus.dev/github", + icon: GitHubIcon, + keywords: ["source", "repo", "issues"], + }, +] satisfies HelpLink[]; diff --git a/apps/dashboard/src/config/nav.ts b/apps/dashboard/src/config/nav.ts new file mode 100644 index 00000000..57af077a --- /dev/null +++ b/apps/dashboard/src/config/nav.ts @@ -0,0 +1,83 @@ +import type { LucideIcon } from "lucide-react"; +import { + Activity, + Bell, + Bot, + Cog, + LayoutGrid, + MessageSquare, + PanelTop, + Terminal, +} from "lucide-react"; + +export type NavItem = { + label: string; + href: string; + icon: LucideIcon; + keywords?: string[]; +}; + +// Single source of truth for primary destinations. Consumed by the sidebar +// (icon + href) and the command menu (icon + href + label + keywords), so an +// icon swap here propagates to both surfaces. +export const NAV = { + overview: { + label: "Overview", + href: "/overview", + icon: LayoutGrid, + keywords: ["home", "dashboard"], + }, + monitors: { + label: "Monitors", + href: "/monitors", + icon: Activity, + keywords: ["checks", "uptime"], + }, + statusPages: { + label: "Status Pages", + href: "/status-pages", + icon: PanelTop, + keywords: ["pages"], + }, + notifications: { + label: "Notifications", + href: "/notifications", + icon: Bell, + keywords: ["notifiers", "alerts", "channels"], + }, + chat: { + label: "Chat", + href: "/chat", + icon: MessageSquare, + keywords: ["assistant", "ai"], + }, + agents: { + label: "Agents", + href: "/agents", + icon: Bot, + keywords: ["slack"], + }, + cli: { + label: "CLI", + href: "/cli", + icon: Terminal, + keywords: ["terminal"], + }, + settings: { + label: "Settings", + href: "/settings/general", + icon: Cog, + keywords: ["workspace"], + }, +} satisfies Record; + +// Command-menu "Navigation" group order. +export const NAV_MENU_ITEMS: NavItem[] = [ + NAV.overview, + NAV.monitors, + NAV.statusPages, + NAV.notifications, + NAV.chat, + NAV.agents, + NAV.cli, +]; diff --git a/apps/dashboard/src/config/settings.ts b/apps/dashboard/src/config/settings.ts new file mode 100644 index 00000000..4309dc01 --- /dev/null +++ b/apps/dashboard/src/config/settings.ts @@ -0,0 +1,56 @@ +import type { LucideIcon } from "lucide-react"; +import { Blocks, Cog, CreditCard, Globe, ScanEye, User } from "lucide-react"; + +export type SettingsTab = { + value: string; + label: string; + icon: LucideIcon; + href: string; + keywords?: string[]; +}; + +// Single source of truth for the settings tab bar and the command-menu +// "Settings" group so their icons stay in sync. +export const SETTINGS_TABS = [ + { + value: "general", + label: "General", + icon: Cog, + href: "/settings/general", + keywords: ["workspace"], + }, + { + value: "account", + label: "Account", + icon: User, + href: "/settings/account", + keywords: ["profile", "user"], + }, + { + value: "billing", + label: "Billing", + icon: CreditCard, + href: "/settings/billing", + keywords: ["plan", "subscription", "upgrade"], + }, + { + value: "integrations", + label: "Integrations", + icon: Blocks, + href: "/settings/integrations", + }, + { + value: "private-locations", + label: "Private Locations", + icon: Globe, + href: "/settings/private-locations", + keywords: ["probes", "regions"], + }, + { + value: "audit-logs", + label: "Audit Logs", + icon: ScanEye, + href: "/settings/audit-logs", + keywords: ["activity", "history"], + }, +] satisfies SettingsTab[]; diff --git a/apps/dashboard/src/hooks/use-scroll-to-hash.ts b/apps/dashboard/src/hooks/use-scroll-to-hash.ts new file mode 100644 index 00000000..9b3a422f --- /dev/null +++ b/apps/dashboard/src/hooks/use-scroll-to-hash.ts @@ -0,0 +1,43 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { useEffect } from "react"; + +// Settings pages render `null` until tRPC data resolves, so native hash scroll +// fires before the anchor exists. Retry via rAF until the element mounts. +function scrollWhenReady(id: string) { + const start = performance.now(); + const tick = () => { + const el = document.getElementById(id); + if (el) { + el.scrollIntoView(); + return; + } + if (performance.now() - start < 2000) { + requestAnimationFrame(tick); + } + }; + requestAnimationFrame(tick); +} + +// Imperative helper for anchor menu items — handles same-page and repeat +// selection where the URL hash doesn't change and `hashchange` never fires. +export function scrollToHash(id: string) { + scrollWhenReady(id); +} + +export function useScrollToHash() { + const pathname = usePathname(); + + useEffect(() => { + const id = window.location.hash.slice(1); + if (id) scrollWhenReady(id); + + const onHashChange = () => { + const next = window.location.hash.slice(1); + if (next) scrollWhenReady(next); + }; + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + }, [pathname]); +} diff --git a/apps/dashboard/src/lib/edge-context.ts b/apps/dashboard/src/lib/edge-context.ts index 60e5a5e3..f72194c2 100644 --- a/apps/dashboard/src/lib/edge-context.ts +++ b/apps/dashboard/src/lib/edge-context.ts @@ -2,6 +2,7 @@ import { asc, db, eq, schema } from "@openstatus/db"; import type { ServiceContext } from "@openstatus/services"; import { auth } from "@/lib/auth"; +import { WORKSPACE_SLUG_COOKIE } from "@/lib/workspace-cookie"; /** * Build a `ServiceContext` from a Next.js Edge Route Handler request. @@ -44,7 +45,7 @@ export async function getServiceContextFromRequest( .get("cookie") ?.split(";") .map((c) => c.trim()) - .find((c) => c.startsWith("workspace-slug=")); + .find((c) => c.startsWith(`${WORKSPACE_SLUG_COOKIE}=`)); return raw ? raw.slice(raw.indexOf("=") + 1) : undefined; })(); diff --git a/apps/dashboard/src/lib/workspace-cookie.ts b/apps/dashboard/src/lib/workspace-cookie.ts new file mode 100644 index 00000000..89ab3ed2 --- /dev/null +++ b/apps/dashboard/src/lib/workspace-cookie.ts @@ -0,0 +1,8 @@ +export const WORKSPACE_SLUG_COOKIE = "workspace-slug"; + +// Client-side workspace switch: set the cookie the proxy reads, then hard-reload +// so every server component re-resolves against the new workspace. +export function switchWorkspace(slug: string) { + document.cookie = `${WORKSPACE_SLUG_COOKIE}=${slug}; path=/;`; + window.location.href = "/overview"; +} diff --git a/apps/dashboard/src/proxy.ts b/apps/dashboard/src/proxy.ts index 2e5254bc..1a00a9df 100644 --- a/apps/dashboard/src/proxy.ts +++ b/apps/dashboard/src/proxy.ts @@ -4,6 +4,7 @@ import { getCurrency } from "@openstatus/db/src/schema/plan/utils"; import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; +import { WORKSPACE_SLUG_COOKIE } from "@/lib/workspace-cookie"; export default auth(async (req) => { const url = req.nextUrl.clone(); @@ -74,7 +75,7 @@ export default auth(async (req) => { } } - const hasWorkspaceSlug = req.cookies.has("workspace-slug"); + const hasWorkspaceSlug = req.cookies.has(WORKSPACE_SLUG_COOKIE); if (req.auth?.user?.id && !hasWorkspaceSlug) { const [query] = await db @@ -89,11 +90,11 @@ export default auth(async (req) => { console.error(">> Should not happen, no workspace found for user"); } - response.cookies.set("workspace-slug", query.workspace.slug); + response.cookies.set(WORKSPACE_SLUG_COOKIE, query.workspace.slug); } if (!req.auth && hasWorkspaceSlug) { - response.cookies.delete("workspace-slug"); + response.cookies.delete(WORKSPACE_SLUG_COOKIE); } // auth-redirect is single-use and consumed by the /onboarding Server