diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/page.tsx new file mode 100644 index 000000000..48a5e7cde --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/page.tsx @@ -0,0 +1,36 @@ +import { UserSettingsPanel, type UserSettingsTab } from "@/components/settings/user-settings-panel"; + +const USER_SETTINGS_TABS = new Set([ + "profile", + "api-key", + "prompts", + "community-prompts", + "memory", + "agent-permissions", + "agent-status", + "purchases", + "desktop", + "desktop-shortcuts", +]); + +function getInitialTab(tab: string | string[] | undefined): UserSettingsTab { + const value = Array.isArray(tab) ? tab[0] : tab; + return value && USER_SETTINGS_TABS.has(value) ? (value as UserSettingsTab) : "profile"; +} + +export default async function UserSettingsPage({ + params, + searchParams, +}: { + params: Promise<{ search_space_id: string }>; + searchParams: Promise<{ tab?: string | string[] }>; +}) { + const [{ search_space_id }, resolvedSearchParams] = await Promise.all([params, searchParams]); + + return ( + + ); +} diff --git a/surfsense_web/atoms/settings/settings-dialog.atoms.ts b/surfsense_web/atoms/settings/settings-dialog.atoms.ts index 480c41204..e066fc8ca 100644 --- a/surfsense_web/atoms/settings/settings-dialog.atoms.ts +++ b/surfsense_web/atoms/settings/settings-dialog.atoms.ts @@ -5,21 +5,11 @@ export interface SearchSpaceSettingsDialogState { initialTab: string; } -export interface UserSettingsDialogState { - open: boolean; - initialTab: string; -} - export const searchSpaceSettingsDialogAtom = atom({ open: false, initialTab: "general", }); -export const userSettingsDialogAtom = atom({ - open: false, - initialTab: "profile", -}); - export const teamDialogAtom = atom(false); export const announcementsDialogAtom = atom(false); diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index db44e8895..c1fe3e5c1 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -18,7 +18,6 @@ import { announcementsDialogAtom, searchSpaceSettingsDialogAtom, teamDialogAtom, - userSettingsDialogAtom, } from "@/atoms/settings/settings-dialog.atoms"; import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; @@ -26,7 +25,6 @@ import { ActionLogDialog } from "@/components/agent-action-log/action-log-dialog import { AnnouncementsDialog } from "@/components/announcements/AnnouncementsDialog"; import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog"; import { TeamDialog } from "@/components/settings/team-dialog"; -import { UserSettingsDialog } from "@/components/settings/user-settings-dialog"; import { AlertDialog, AlertDialogAction, @@ -382,14 +380,13 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid setIsCreateSearchSpaceDialogOpen(true); }, []); - const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom); const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom); const setTeamDialogOpen = useSetAtom(teamDialogAtom); const setAnnouncementsDialog = useSetAtom(announcementsDialogAtom); const handleUserSettings = useCallback(() => { - setUserSettingsDialog({ open: true, initialTab: "profile" }); - }, [setUserSettingsDialog]); + router.push(`/dashboard/${searchSpaceId}/user-settings`); + }, [router, searchSpaceId]); const handleAnnouncements = useCallback(() => { setAnnouncementsDialog(true); @@ -668,8 +665,11 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid // Detect if we're on the chat page (needs overflow-hidden for chat's own scroll) const isChatPage = pathname?.includes("/new-chat") ?? false; + const isUserSettingsPage = pathname?.endsWith("/user-settings") === true; const useWorkspacePanel = - pathname?.endsWith("/buy-more") === true || pathname?.endsWith("/more-pages") === true; + pathname?.endsWith("/buy-more") === true || + pathname?.endsWith("/more-pages") === true || + isUserSettingsPage; return ( <> @@ -708,6 +708,10 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid setTheme={setTheme} isChatPage={isChatPage} useWorkspacePanel={useWorkspacePanel} + workspacePanelViewportClassName={ + isUserSettingsPage ? "items-start justify-center px-6 py-8 md:px-10 md:py-10" : undefined + } + workspacePanelContentClassName={isUserSettingsPage ? "max-w-5xl" : undefined} isLoadingChats={isLoadingThreads} activeSlideoutPanel={activeSlideoutPanel} onSlideoutPanelChange={setActiveSlideoutPanel} @@ -887,9 +891,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid onOpenChange={setIsCreateSearchSpaceDialogOpen} /> - {/* Settings Dialogs */} - diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index ad1d809ab..c090a8886 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -100,6 +100,8 @@ interface LayoutShellProps { defaultCollapsed?: boolean; isChatPage?: boolean; useWorkspacePanel?: boolean; + workspacePanelViewportClassName?: string; + workspacePanelContentClassName?: string; children: React.ReactNode; className?: string; // Unified slide-out panel state @@ -205,6 +207,8 @@ export function LayoutShell({ defaultCollapsed = false, isChatPage = false, useWorkspacePanel = false, + workspacePanelViewportClassName, + workspacePanelContentClassName, children, className, activeSlideoutPanel = null, @@ -295,7 +299,12 @@ export function LayoutShell({ /> {useWorkspacePanel ? ( - {children} + + {children} + ) : (
{children} @@ -519,7 +528,12 @@ export function LayoutShell({ {useWorkspacePanel ? ( - {children} + + {children} + ) : ( <> {/* Main content panel */} diff --git a/surfsense_web/components/layout/ui/shell/WorkspacePanel.tsx b/surfsense_web/components/layout/ui/shell/WorkspacePanel.tsx index d6dbe1870..e90240a5a 100644 --- a/surfsense_web/components/layout/ui/shell/WorkspacePanel.tsx +++ b/surfsense_web/components/layout/ui/shell/WorkspacePanel.tsx @@ -4,6 +4,7 @@ import { cn } from "@/lib/utils"; interface WorkspacePanelProps { children: ReactNode; className?: string; + viewportClassName?: string; contentClassName?: string; } @@ -12,7 +13,12 @@ interface WorkspacePanelProps { * Use this when a route should own the whole workspace instead of rendering * inside the normal TabBar/Header/main/right-panel chrome. */ -export function WorkspacePanel({ children, className, contentClassName }: WorkspacePanelProps) { +export function WorkspacePanel({ + children, + className, + viewportClassName, + contentClassName, +}: WorkspacePanelProps) { return (
-
+
{children}
diff --git a/surfsense_web/components/new-chat/prompt-picker.tsx b/surfsense_web/components/new-chat/prompt-picker.tsx index 220fe3436..7700dd0b2 100644 --- a/surfsense_web/components/new-chat/prompt-picker.tsx +++ b/surfsense_web/components/new-chat/prompt-picker.tsx @@ -1,7 +1,8 @@ "use client"; -import { useAtomValue, useSetAtom } from "jotai"; +import { useAtomValue } from "jotai"; import { Plus, Zap } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; import { forwardRef, useCallback, @@ -14,7 +15,6 @@ import { } from "react"; import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms"; -import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; @@ -35,7 +35,8 @@ export const PromptPicker = forwardRef(funct { onSelect, onDone, externalSearch = "" }, ref ) { - const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom); + const router = useRouter(); + const params = useParams(); const { data: prompts, isLoading, isError } = useAtomValue(promptsAtom); const [highlightedIndex, setHighlightedIndex] = useState(0); const scrollContainerRef = useRef(null); @@ -62,19 +63,24 @@ export const PromptPicker = forwardRef(funct const createPromptIndex = filtered.length; const totalItems = filtered.length + 1; + const searchSpaceId = Array.isArray(params?.search_space_id) + ? params.search_space_id[0] + : params?.search_space_id; const handleSelect = useCallback( (index: number) => { if (index === createPromptIndex) { onDone(); - setUserSettingsDialog({ open: true, initialTab: "prompts" }); + if (searchSpaceId) { + router.push(`/dashboard/${searchSpaceId}/user-settings?tab=prompts`); + } return; } const action = filtered[index]; if (!action) return; onSelect({ name: action.name, prompt: action.prompt, mode: action.mode }); }, - [filtered, onSelect, createPromptIndex, onDone, setUserSettingsDialog] + [filtered, onSelect, createPromptIndex, onDone, router, searchSpaceId] ); useEffect(() => { diff --git a/surfsense_web/components/settings/user-settings-dialog.tsx b/surfsense_web/components/settings/user-settings-dialog.tsx deleted file mode 100644 index 81dfc68f1..000000000 --- a/surfsense_web/components/settings/user-settings-dialog.tsx +++ /dev/null @@ -1,178 +0,0 @@ -"use client"; - -import { useAtom } from "jotai"; -import { - Brain, - CircleUser, - Globe, - Keyboard, - KeyRound, - Monitor, - ReceiptText, - ShieldCheck, - Sparkles, - Workflow, -} from "lucide-react"; -import dynamic from "next/dynamic"; -import { useTranslations } from "next-intl"; -import { useMemo } from "react"; -import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms"; -import { SettingsDialog } from "@/components/settings/settings-dialog"; -import { usePlatform } from "@/hooks/use-platform"; - -const ProfileContent = dynamic( - () => - import("@/app/dashboard/[search_space_id]/user-settings/components/ProfileContent").then( - (m) => ({ default: m.ProfileContent }) - ), - { ssr: false } -); -const ApiKeyContent = dynamic( - () => - import("@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent").then( - (m) => ({ default: m.ApiKeyContent }) - ), - { ssr: false } -); -const PromptsContent = dynamic( - () => - import("@/app/dashboard/[search_space_id]/user-settings/components/PromptsContent").then( - (m) => ({ default: m.PromptsContent }) - ), - { ssr: false } -); -const CommunityPromptsContent = dynamic( - () => - import( - "@/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent" - ).then((m) => ({ default: m.CommunityPromptsContent })), - { ssr: false } -); -const PurchaseHistoryContent = dynamic( - () => - import( - "@/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent" - ).then((m) => ({ default: m.PurchaseHistoryContent })), - { ssr: false } -); -const DesktopContent = dynamic( - () => - import("@/app/dashboard/[search_space_id]/user-settings/components/DesktopContent").then( - (m) => ({ default: m.DesktopContent }) - ), - { ssr: false } -); -const DesktopShortcutsContent = dynamic( - () => - import( - "@/app/dashboard/[search_space_id]/user-settings/components/DesktopShortcutsContent" - ).then((m) => ({ default: m.DesktopShortcutsContent })), - { ssr: false } -); -const MemoryContent = dynamic( - () => - import("@/app/dashboard/[search_space_id]/user-settings/components/MemoryContent").then( - (m) => ({ default: m.MemoryContent }) - ), - { ssr: false } -); -const AgentPermissionsContent = dynamic( - () => - import( - "@/app/dashboard/[search_space_id]/user-settings/components/AgentPermissionsContent" - ).then((m) => ({ default: m.AgentPermissionsContent })), - { ssr: false } -); -const AgentStatusContent = dynamic( - () => - import("@/app/dashboard/[search_space_id]/user-settings/components/AgentStatusContent").then( - (m) => ({ default: m.AgentStatusContent }) - ), - { ssr: false } -); - -export function UserSettingsDialog() { - const t = useTranslations("userSettings"); - const [state, setState] = useAtom(userSettingsDialogAtom); - const { isDesktop } = usePlatform(); - - const navItems = useMemo( - () => [ - { value: "profile", label: t("profile_nav_label"), icon: }, - { - value: "api-key", - label: t("api_key_nav_label"), - icon: , - }, - { - value: "prompts", - label: "My Prompts", - icon: , - }, - { - value: "community-prompts", - label: "Community Prompts", - icon: , - }, - { - value: "memory", - label: "Memory", - icon: , - }, - { - value: "agent-permissions", - label: "Agent Permissions", - icon: , - }, - { - value: "agent-status", - label: "Agent Status", - icon: , - }, - { - value: "purchases", - label: "Purchase History", - icon: , - }, - ...(isDesktop - ? [ - { - value: "desktop", - label: "App Preferences", - icon: , - }, - { - value: "desktop-shortcuts", - label: "Hotkeys", - icon: , - }, - ] - : []), - ], - [t, isDesktop] - ); - - return ( - setState((prev) => ({ ...prev, open }))} - title={t("title")} - navItems={navItems} - activeItem={state.initialTab} - onItemChange={(tab) => setState((prev) => ({ ...prev, initialTab: tab }))} - > -
- {state.initialTab === "profile" && } - {state.initialTab === "api-key" && } - {state.initialTab === "prompts" && } - {state.initialTab === "community-prompts" && } - {state.initialTab === "memory" && } - {state.initialTab === "agent-permissions" && } - {state.initialTab === "agent-status" && } - {state.initialTab === "purchases" && } - {state.initialTab === "desktop" && } - {state.initialTab === "desktop-shortcuts" && } -
-
- ); -} diff --git a/surfsense_web/components/settings/user-settings-panel.tsx b/surfsense_web/components/settings/user-settings-panel.tsx new file mode 100644 index 000000000..28e04a8d4 --- /dev/null +++ b/surfsense_web/components/settings/user-settings-panel.tsx @@ -0,0 +1,271 @@ +"use client"; + +import { + Brain, + CircleUser, + Globe, + Keyboard, + KeyRound, + Monitor, + ReceiptText, + ShieldCheck, + Sparkles, + Workflow, +} from "lucide-react"; +import dynamic from "next/dynamic"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { usePlatform } from "@/hooks/use-platform"; +import { cn } from "@/lib/utils"; + +const ProfileContent = dynamic( + () => + import("@/app/dashboard/[search_space_id]/user-settings/components/ProfileContent").then( + (m) => ({ default: m.ProfileContent }) + ), + { ssr: false } +); +const ApiKeyContent = dynamic( + () => + import("@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent").then( + (m) => ({ default: m.ApiKeyContent }) + ), + { ssr: false } +); +const PromptsContent = dynamic( + () => + import("@/app/dashboard/[search_space_id]/user-settings/components/PromptsContent").then( + (m) => ({ default: m.PromptsContent }) + ), + { ssr: false } +); +const CommunityPromptsContent = dynamic( + () => + import( + "@/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent" + ).then((m) => ({ default: m.CommunityPromptsContent })), + { ssr: false } +); +const PurchaseHistoryContent = dynamic( + () => + import( + "@/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent" + ).then((m) => ({ default: m.PurchaseHistoryContent })), + { ssr: false } +); +const DesktopContent = dynamic( + () => + import("@/app/dashboard/[search_space_id]/user-settings/components/DesktopContent").then( + (m) => ({ default: m.DesktopContent }) + ), + { ssr: false } +); +const DesktopShortcutsContent = dynamic( + () => + import( + "@/app/dashboard/[search_space_id]/user-settings/components/DesktopShortcutsContent" + ).then((m) => ({ default: m.DesktopShortcutsContent })), + { ssr: false } +); +const MemoryContent = dynamic( + () => + import("@/app/dashboard/[search_space_id]/user-settings/components/MemoryContent").then( + (m) => ({ default: m.MemoryContent }) + ), + { ssr: false } +); +const AgentPermissionsContent = dynamic( + () => + import( + "@/app/dashboard/[search_space_id]/user-settings/components/AgentPermissionsContent" + ).then((m) => ({ default: m.AgentPermissionsContent })), + { ssr: false } +); +const AgentStatusContent = dynamic( + () => + import("@/app/dashboard/[search_space_id]/user-settings/components/AgentStatusContent").then( + (m) => ({ default: m.AgentStatusContent }) + ), + { ssr: false } +); + +export type UserSettingsTab = + | "profile" + | "api-key" + | "prompts" + | "community-prompts" + | "memory" + | "agent-permissions" + | "agent-status" + | "purchases" + | "desktop" + | "desktop-shortcuts"; + +interface UserSettingsPanelProps { + searchSpaceId: string; + initialTab?: UserSettingsTab; +} + +export function UserSettingsPanel({ + searchSpaceId, + initialTab = "profile", +}: UserSettingsPanelProps) { + const t = useTranslations("userSettings"); + const router = useRouter(); + const { isDesktop } = usePlatform(); + const [activeTab, setActiveTab] = useState(initialTab); + const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start"); + + useEffect(() => { + setActiveTab(initialTab); + }, [initialTab]); + + const handleTabScroll = useCallback((e: React.UIEvent) => { + const el = e.currentTarget; + const atStart = el.scrollLeft <= 2; + const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2; + setTabScrollPos(atStart ? "start" : atEnd ? "end" : "middle"); + }, []); + + const navItems = useMemo( + () => [ + { value: "profile", label: t("profile_nav_label"), icon: }, + { + value: "api-key", + label: t("api_key_nav_label"), + icon: , + }, + { + value: "prompts", + label: "My Prompts", + icon: , + }, + { + value: "community-prompts", + label: "Community Prompts", + icon: , + }, + { + value: "memory", + label: "Memory", + icon: , + }, + { + value: "agent-permissions", + label: "Agent Permissions", + icon: , + }, + { + value: "agent-status", + label: "Agent Status", + icon: , + }, + { + value: "purchases", + label: "Purchase History", + icon: , + }, + ...(isDesktop + ? [ + { + value: "desktop" as const, + label: "App Preferences", + icon: , + }, + { + value: "desktop-shortcuts" as const, + label: "Hotkeys", + icon: , + }, + ] + : []), + ], + [t, isDesktop] + ); + + const selectedTab = navItems.some((item) => item.value === activeTab) ? activeTab : "profile"; + const selectedLabel = navItems.find((item) => item.value === selectedTab)?.label ?? t("title"); + + const handleItemChange = (tab: UserSettingsTab) => { + setActiveTab(tab); + const suffix = tab === "profile" ? "" : `?tab=${tab}`; + router.replace(`/dashboard/${searchSpaceId}/user-settings${suffix}`, { scroll: false }); + }; + + return ( +
+
+

{t("title")}

+ +
+
+ {navItems.map((item) => ( + + ))} +
+
+
+ +
+
+

{selectedLabel}

+ +
+
+ {selectedTab === "profile" && } + {selectedTab === "api-key" && } + {selectedTab === "prompts" && } + {selectedTab === "community-prompts" && } + {selectedTab === "memory" && } + {selectedTab === "agent-permissions" && } + {selectedTab === "agent-status" && } + {selectedTab === "purchases" && } + {selectedTab === "desktop" && } + {selectedTab === "desktop-shortcuts" && } +
+
+
+ ); +}