Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(webapp): redesign the side menu project and organization menus#4066
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
c6138e3f2e8a0e440023bd7c6af03aa2886e9fe1d0f2a3cbe8e37da92fe465c8f99e6af593730d5c39f524557d80b0f66d26aa1be6d701ab347fabe064583472cd93dc7e7766ea6253808c2ab71a574d771c540b11efa95907288aaa15a48aec628885feda0a71886abaeb7bd635c9c7f990d56fc60f130c8a1ac3cd5208e8a2007654bfc3fbc3d8893d3364e6cfa952e3d1fb49f696855be0432c264d4428f1033e10d10b06d18e289ee7243d8adad012cc9d6ca3b2bf082280d62bdbe6a8dea29735f189be730f7529b3297c951eb77082159a39bac3bdfb2af68e0728b8901af8efbd2b62505ac7532e5a3c0dFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| area: webapp | ||
| type: improvement | ||
| --- | ||
| Refreshed the side menu: separate organization and account menus, a new project switcher, and the menu is now resizable by dragging its edge. The account Profile page has also been redesigned. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| export function ChainLinkIcon({ className }: { className?: string }) { | ||
| return ( | ||
| <svg | ||
| className={className} | ||
| width="24" | ||
| height="24" | ||
| viewBox="0 0 24 24" | ||
| fill="none" | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| > | ||
| <path | ||
| d="M10 19.0004L9.82843 19.1719C8.26634 20.734 5.73368 20.734 4.17158 19.1719L3.82843 18.8288C2.26634 17.2667 2.26633 14.734 3.82843 13.1719L7.17158 9.8288C8.73368 8.2667 11.2663 8.2667 12.8284 9.8288L13.1716 10.1719C13.8252 10.8256 14.2053 11.6491 14.312 12.5004" | ||
| stroke="currentColor" | ||
| strokeWidth="2" | ||
| strokeLinecap="round" | ||
| strokeLinejoin="round" | ||
| /> | ||
| <path | ||
| d="M9.68799 12.5004C9.79463 13.3516 10.1748 14.1752 10.8284 14.8288L11.1715 15.1719C12.7336 16.734 15.2663 16.734 16.8284 15.1719L20.1715 11.8288C21.7336 10.2667 21.7336 7.73404 20.1715 6.17194L19.8284 5.8288C18.2663 4.2667 15.7336 4.2667 14.1715 5.8288L14 6.00037" | ||
| stroke="currentColor" | ||
| strokeWidth="2" | ||
| strokeLinecap="round" | ||
| strokeLinejoin="round" | ||
| /> | ||
| </svg> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| export function LeftSideMenuCollapsedIcon({ className }: { className?: string }) { | ||
| return ( | ||
| <svg | ||
| className={className} | ||
| width="24" | ||
| height="24" | ||
| viewBox="0 0 24 24" | ||
| fill="none" | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| > | ||
| <rect x="4" y="4" width="16" height="16" rx="3" stroke="currentColor" strokeWidth="2" /> | ||
| <rect x="6" y="6" width="2" height="12" rx="1" fill="currentColor" /> | ||
| <path | ||
| d="M12 14.5L14.5 12L12 9.5" | ||
| stroke="currentColor" | ||
| strokeWidth="2" | ||
| strokeLinecap="round" | ||
| strokeLinejoin="round" | ||
| /> | ||
| </svg> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import { motion } from "framer-motion"; | ||
| import { useState } from "react"; | ||
| export function LeftSideMenuIcon({ | ||
| className, | ||
| hovered: controlledHovered, | ||
| }: { | ||
| className?: string; | ||
| /** Drives the animation when provided (e.g. parent hover); otherwise the icon uses its own hover. */ | ||
| hovered?: boolean; | ||
| }) { | ||
| const [internalHovered, setInternalHovered] = useState(false); | ||
| const isControlled = controlledHovered !== undefined; | ||
| const hovered = isControlled ? controlledHovered : internalHovered; | ||
| return ( | ||
| <svg | ||
| className={className} | ||
| width="24" | ||
| height="24" | ||
| viewBox="0 0 24 24" | ||
| fill="none" | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| onMouseEnter={isControlled ? undefined : () => setInternalHovered(true)} | ||
| onMouseLeave={isControlled ? undefined : () => setInternalHovered(false)} | ||
| > | ||
| <rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" /> | ||
| {/* Animate a transform (scaleX), not the SVG `width` attr — framer snaps the first animation | ||
| of an idle SVG geometry attribute. Left origin collapses the panel right-to-left. */} | ||
| <motion.rect | ||
| x="6" | ||
| y="6" | ||
| width="5" | ||
| height="12" | ||
| rx="1" | ||
| fill="currentColor" | ||
| initial={false} | ||
| style={{ originX: 0 }} | ||
| animate={{ scaleX: hovered ? 0.2 : 1 }} | ||
| transition={{ duration: 0.3, ease: "easeInOut" }} | ||
| /> | ||
| </svg> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -11,11 +11,12 @@ import { useSearchParams } from "@remix-run/react"; | ||
| import DOMPurify from "dompurify"; | ||
| import { motion } from "framer-motion"; | ||
| import { marked } from "marked"; | ||
| import { useCallback, useEffect, useRef, useState } from "react"; | ||
| import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; | ||
| import { useTypedRouteLoaderData } from "remix-typedjson"; | ||
| import { AISparkleIcon } from "~/assets/icons/AISparkleIcon"; | ||
| import { SparkleListIcon } from "~/assets/icons/SparkleListIcon"; | ||
| import { useFeatures } from "~/hooks/useFeatures"; | ||
| import { useShortcutKeys } from "~/hooks/useShortcutKeys"; | ||
| import { type loader } from "~/root"; | ||
| import { Button } from "./primitives/Buttons"; | ||
| import { Callout } from "./primitives/Callout"; | ||
| @@ -38,6 +39,104 @@ function useKapaWebsiteId() { | ||
| return routeMatch?.kapa.websiteId; | ||
| } | ||
| /** Open/close state for the Ask AI dialog, including the `?aiHelp=` deep-link handling. */ | ||
| function useAskAIState() { | ||
| const [isOpen, setIsOpen] = useState(false); | ||
| const [initialQuery, setInitialQuery] = useState<string | undefined>(); | ||
| const [searchParams, setSearchParams] = useSearchParams(); | ||
| const openAskAI = useCallback((question?: string) => { | ||
| if (question) { | ||
| setInitialQuery(question); | ||
| } else { | ||
| setInitialQuery(undefined); | ||
| } | ||
| setIsOpen(true); | ||
| }, []); | ||
| const closeAskAI = useCallback(() => { | ||
| setIsOpen(false); | ||
| setInitialQuery(undefined); | ||
| }, []); | ||
| // Handle URL param functionality | ||
| useEffect(() => { | ||
| const aiHelp = searchParams.get("aiHelp"); | ||
| if (aiHelp) { | ||
| // Delay to avoid hCaptcha bot detection | ||
| window.setTimeout(() => openAskAI(aiHelp), 1000); | ||
| // Clone instead of mutating in place | ||
| const next = new URLSearchParams(searchParams); | ||
| next.delete("aiHelp"); | ||
| setSearchParams(next); | ||
| } | ||
| }, [searchParams, openAskAI]); | ||
| return { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI }; | ||
| } | ||
| /** | ||
| * Hosts Ask AI (Kapa provider, ⌘I shortcut, dialog) for a menu that renders its own trigger. Wrap | ||
| * it around the popover, not inside, so the dialog and shortcut survive the popover closing. | ||
| * `children` receives the open function, or undefined when Ask AI is unavailable (self-hosted, no | ||
| * Kapa website id, or SSR). | ||
| */ | ||
| export function AskAIRoot({ | ||
| children, | ||
| }: { | ||
| children: (openAskAI: (() => void) | undefined) => ReactNode; | ||
| }) { | ||
| const { isManagedCloud } = useFeatures(); | ||
| const websiteId = useKapaWebsiteId(); | ||
| if (!isManagedCloud || !websiteId) { | ||
| return <>{children(undefined)}</>; | ||
| } | ||
| return ( | ||
| <ClientOnly fallback={<>{children(undefined)}</>}> | ||
| {() => <AskAIRootProvider websiteId={websiteId}>{children}</AskAIRootProvider>} | ||
| </ClientOnly> | ||
| ); | ||
| } | ||
| function AskAIRootProvider({ | ||
| websiteId, | ||
| children, | ||
| }: { | ||
| websiteId: string; | ||
| children: (openAskAI: () => void) => ReactNode; | ||
| }) { | ||
| const { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI } = useAskAIState(); | ||
| useShortcutKeys({ | ||
| shortcut: { modifiers: ["mod"], key: "i", enabledOnInputElements: true }, | ||
| action: () => openAskAI(), | ||
| }); | ||
| return ( | ||
| <KapaProvider | ||
| integrationId={websiteId} | ||
| callbacks={{ | ||
| askAI: { | ||
| onQuerySubmit: () => openAskAI(), | ||
| onAnswerGenerationCompleted: () => openAskAI(), | ||
| }, | ||
| }} | ||
| botProtectionMechanism="hcaptcha" | ||
| > | ||
| {children(() => openAskAI())} | ||
| <AskAIDialog | ||
| initialQuery={initialQuery} | ||
| isOpen={isOpen} | ||
| onOpenChange={setIsOpen} | ||
| closeAskAI={closeAskAI} | ||
| /> | ||
| </KapaProvider> | ||
| ); | ||
| } | ||
| export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) { | ||
| const { isManagedCloud } = useFeatures(); | ||
| const websiteId = useKapaWebsiteId(); | ||
samejr marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| @@ -72,37 +171,7 @@ type AskAIProviderProps = { | ||
| }; | ||
| function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) { | ||
| const [isOpen, setIsOpen] = useState(false); | ||
| const [initialQuery, setInitialQuery] = useState<string | undefined>(); | ||
| const [searchParams, setSearchParams] = useSearchParams(); | ||
| const openAskAI = useCallback((question?: string) => { | ||
| if (question) { | ||
| setInitialQuery(question); | ||
| } else { | ||
| setInitialQuery(undefined); | ||
| } | ||
| setIsOpen(true); | ||
| }, []); | ||
| const closeAskAI = useCallback(() => { | ||
| setIsOpen(false); | ||
| setInitialQuery(undefined); | ||
| }, []); | ||
| // Handle URL param functionality | ||
| useEffect(() => { | ||
| const aiHelp = searchParams.get("aiHelp"); | ||
| if (aiHelp) { | ||
| // Delay to avoid hCaptcha bot detection | ||
| window.setTimeout(() => openAskAI(aiHelp), 1000); | ||
| // Clone instead of mutating in place | ||
| const next = new URLSearchParams(searchParams); | ||
| next.delete("aiHelp"); | ||
| setSearchParams(next); | ||
| } | ||
| }, [searchParams, openAskAI]); | ||
| const { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI } = useAskAIState(); | ||
| return ( | ||
| <KapaProvider | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import { useNavigate, useSubmit } from "@remix-run/react"; | ||
| import { useEffect } from "react"; | ||
| import { useIsImpersonating } from "~/hooks/useOrganizations"; | ||
| import { useOptionalUser } from "~/hooks/useUser"; | ||
| import { adminPath } from "~/utils/pathBuilder"; | ||
| /** App-wide keyboard shortcuts, mounted once at the root so they work everywhere. Renders nothing. */ | ||
| export function GlobalShortcuts() { | ||
| const user = useOptionalUser(); | ||
| const isImpersonating = useIsImpersonating(); | ||
| const navigate = useNavigate(); | ||
| const submit = useSubmit(); | ||
| const isAdmin = Boolean(user?.admin) || isImpersonating; | ||
samejr marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. samejr marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| useEffect(() => { | ||
| if (!isAdmin) return; | ||
| const onKeyDown = (event: KeyboardEvent) => { | ||
| // Admin escape hatch: Cmd+Option+A (Ctrl+Alt+A on Windows) opens the admin dashboard, or stops | ||
| // impersonating. Avoids Escape — Chrome/macOS never delivers a keydown for Escape+modifier (why | ||
| // the old Cmd+Esc did nothing). Matched on `event.code`, not `event.key`, because Option makes | ||
| // "A" report "å" (so a raw listener, not the `event.key`-based useShortcutKeys hook). | ||
| if (event.code !== "KeyA" || !event.altKey || !(event.metaKey || event.ctrlKey)) { | ||
| return; | ||
| } | ||
| event.preventDefault(); | ||
| if (isImpersonating) { | ||
| submit(null, { action: "/resources/impersonation", method: "delete" }); | ||
| } else { | ||
| navigate(adminPath()); | ||
| } | ||
| }; | ||
| document.addEventListener("keydown", onKeyDown); | ||
| return () => document.removeEventListener("keydown", onKeyDown); | ||
| }, [isAdmin, isImpersonating, navigate, submit]); | ||
| return null; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.