diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 0e408fd9..839c4c84 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -5,7 +5,7 @@ import { useSearchParams } from "next/navigation"; import { Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { usePostHog } from 'posthog-js/react'; -import type { AgentState } from "@/lib/workspace-state/types"; +import type { AgentState, PdfData, WorkspaceWithState } from "@/lib/workspace-state/types"; import { initialState } from "@/lib/workspace-state/state"; import { useScrollHeader } from "@/hooks/ui/use-scroll-header"; import { useKeyboardShortcuts } from "@/hooks/ui/use-keyboard-shortcuts"; @@ -22,6 +22,7 @@ import { useUIStore } from "@/lib/stores/ui-store"; import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { useSession } from "@/lib/auth-client"; import { WorkspaceSection } from "@/components/workspace-canvas/WorkspaceSection"; +import WorkspaceHeader from "@/components/workspace-canvas/WorkspaceHeader"; import { ModalManager } from "@/components/modals/ModalManager"; import { AnonymousSignInPrompt } from "@/components/modals/AnonymousSignInPrompt"; import { DashboardLayout } from "@/components/layout/DashboardLayout"; @@ -32,23 +33,26 @@ import { AnonymousSessionHandler, SidebarCoordinator } from "@/components/layout // import { OnboardingVideoDialog } from "@/components/onboarding/OnboardingVideoDialog"; // import { useOnboardingStatus } from "@/hooks/user/use-onboarding-status"; import { PdfEngineWrapper } from "@/components/pdf/PdfEngineWrapper"; +import WorkspaceSettingsModal from "@/components/workspace/WorkspaceSettingsModal"; +import ShareWorkspaceDialog from "@/components/workspace/ShareWorkspaceDialog"; // Main dashboard content component +interface DashboardContentProps { + currentWorkspace: WorkspaceWithState | null; + loadingWorkspaces: boolean; +} + function DashboardContent({ - currentWorkspaceId, + currentWorkspace, loadingWorkspaces, - currentWorkspaceTitle, - currentWorkspaceIcon, - currentWorkspaceColor, -}: { - currentWorkspaceId: string | null; - loadingWorkspaces: boolean; - currentWorkspaceTitle?: string; - currentWorkspaceIcon?: string | null; - currentWorkspaceColor?: string | null; -}) { +}: DashboardContentProps) { const posthog = usePostHog(); const { data: session } = useSession(); + const currentWorkspaceId = currentWorkspace?.id || null; + const currentWorkspaceTitle = currentWorkspace?.name; + const currentWorkspaceIcon = currentWorkspace?.icon; + const currentWorkspaceColor = currentWorkspace?.color; + // Check onboarding status // const { shouldShowOnboarding, isLoading: isLoadingOnboarding } = useOnboardingStatus(); // const [showOnboardingDialog, setShowOnboardingDialog] = useState(false); @@ -90,6 +94,10 @@ function DashboardContent({ // Track sign-in prompt for anonymous users const [showSignInPrompt, setShowSignInPrompt] = useState(false); + // Workspace settings/share modals (lifted so header can open them) + const [showWorkspaceSettings, setShowWorkspaceSettings] = useState(false); + const [showWorkspaceShare, setShowWorkspaceShare] = useState(false); + // Show sign-in prompt after 13 events for anonymous users useEffect(() => { // Only show for anonymous users @@ -128,7 +136,7 @@ function DashboardContent({ const manualSave = useCallback(async () => { posthog.capture('manual-save-clicked', { workspace_id: currentWorkspaceId }); updateLastSaved(new Date()); - }, [updateLastSaved, currentWorkspaceId]); + }, [updateLastSaved, currentWorkspaceId, posthog]); // Update save status based on mutation status useEffect(() => { @@ -302,6 +310,60 @@ function DashboardContent({ // Text selection handlers - delegate to agent for intelligent processing const { handleCreateInstantNote, handleCreateCardFromSelections } = useTextSelectionAgent(operations); + const handleWorkspacePdfUpload = useCallback( + async (files: File[]) => { + if (!currentWorkspaceId) { + throw new Error("Workspace not available"); + } + + const uploadPromises = files.map(async (file) => { + const formData = new FormData(); + formData.append("file", file); + + const uploadResponse = await fetch("/api/upload-file", { + method: "POST", + body: formData, + }); + + if (!uploadResponse.ok) { + throw new Error(`Failed to upload PDF: ${uploadResponse.statusText}`); + } + + const { url: fileUrl, filename } = await uploadResponse.json(); + + return { + fileUrl, + filename: filename || file.name, + fileSize: file.size, + name: file.name.replace(/\.pdf$/i, ""), + }; + }); + + const uploadResults = await Promise.all(uploadPromises); + const pdfCardDefinitions = uploadResults.map((result) => { + const pdfData: Partial = { + fileUrl: result.fileUrl, + filename: result.filename, + fileSize: result.fileSize, + }; + + return { + type: "pdf" as const, + name: result.name, + initialData: pdfData, + }; + }); + + operations.createItems(pdfCardDefinitions); + }, + [operations, currentWorkspaceId] + ); + + const handleShowHistory = useCallback(() => { + posthog.capture("version-history-viewed", { workspace_id: currentWorkspaceId }); + setShowVersionHistory(true); + }, [posthog, currentWorkspaceId, setShowVersionHistory]); + return ( @@ -320,6 +382,39 @@ function DashboardContent({ onWorkspaceSwitch={switchWorkspace} showCreateModal={showCreateWorkspaceModal} setShowCreateModal={setShowCreateWorkspaceModal} + workspaceHeader={ + !showJsonView && !isChatMaximized && currentWorkspaceId && !isLoadingWorkspace ? ( + { + void manualSave(); + }} + currentWorkspaceId={currentWorkspaceId} + onShowHistory={handleShowHistory} + isDesktop={isDesktop} + isChatExpanded={isChatExpanded} + setIsChatExpanded={setIsChatExpanded} + workspaceName={currentWorkspaceTitle || state.globalTitle} + workspaceIcon={currentWorkspaceIcon} + workspaceColor={currentWorkspaceColor} + addItem={operations.createItem} + onPDFUpload={handleWorkspacePdfUpload} + setOpenModalItemId={setOpenModalItemId} + items={state.items || []} + onRenameFolder={(folderId, newName) => { + operations.updateItem(folderId, { name: newName }); + }} + onOpenSettings={() => setShowWorkspaceSettings(true)} + onOpenShare={() => setShowWorkspaceShare(true)} + isItemPanelOpen={panels.length > 0} + /> + ) : null + } isDesktop={isDesktop} isChatExpanded={isChatExpanded} isChatMaximized={isChatMaximized} @@ -356,10 +451,7 @@ function DashboardContent({ setIsChatExpanded={setIsChatExpanded} isItemPanelOpen={panels.length > 0} setOpenModalItemId={setOpenModalItemId} - onShowHistory={() => { - posthog.capture('version-history-viewed', { workspace_id: currentWorkspaceId }); - setShowVersionHistory(true); - }} + onShowHistory={handleShowHistory} titleInputRef={titleInputRef as React.RefObject} operations={operations} scrollAreaRef={scrollAreaRef as React.RefObject} @@ -370,6 +462,17 @@ function DashboardContent({ /> } /> + + + ); } @@ -386,33 +489,13 @@ export function DashboardPage() { loadingWorkspaces, } = useWorkspaceContext(); - // Derive workspace ID directly from slug and sync to store - const currentWorkspaceId = useMemo(() => { - if (!currentSlug) return null; - const workspace = workspaces.find(w => w.slug === currentSlug); - return workspace?.id || null; - }, [currentSlug, workspaces]); - - // Get current workspace title for JSON download filename - const currentWorkspaceTitle = useMemo(() => { - if (!currentSlug) return undefined; - const workspace = workspaces.find(w => w.slug === currentSlug); - return workspace?.name || undefined; - }, [currentSlug, workspaces]); - - // Get current workspace icon for header breadcrumbs - const currentWorkspaceIcon = useMemo(() => { + // Find current workspace from slug + const currentWorkspace = useMemo(() => { if (!currentSlug) return null; - const workspace = workspaces.find(w => w.slug === currentSlug); - return workspace?.icon || null; + return workspaces.find(w => w.slug === currentSlug) || null; }, [currentSlug, workspaces]); - // Get current workspace color for header breadcrumbs - const currentWorkspaceColor = useMemo(() => { - if (!currentSlug) return null; - const workspace = workspaces.find(w => w.slug === currentSlug); - return workspace?.color || null; - }, [currentSlug, workspaces]); + const currentWorkspaceId = currentWorkspace?.id || null; // Sync workspace ID to store const setCurrentWorkspaceId = useWorkspaceStore((state) => state.setCurrentWorkspaceId); @@ -424,10 +507,10 @@ export function DashboardPage() { const lastTrackedWorkspaceIdRef = useRef(null); useEffect(() => { if (!currentWorkspaceId) return; - + // Only track if this is a different workspace than last time if (lastTrackedWorkspaceIdRef.current === currentWorkspaceId) return; - + // Track the open fetch(`/api/workspaces/${currentWorkspaceId}/track-open`, { method: 'POST', @@ -435,7 +518,7 @@ export function DashboardPage() { // Silently fail - tracking is not critical console.error('Failed to track workspace open:', error); }); - + lastTrackedWorkspaceIdRef.current = currentWorkspaceId; }, [currentWorkspaceId]); @@ -459,11 +542,8 @@ export function DashboardPage() { return ( ); } diff --git a/src/components/layout/DashboardLayout.tsx b/src/components/layout/DashboardLayout.tsx index 2419736d..9b681e97 100644 --- a/src/components/layout/DashboardLayout.tsx +++ b/src/components/layout/DashboardLayout.tsx @@ -18,6 +18,9 @@ interface DashboardLayoutProps { showCreateModal: boolean; setShowCreateModal: (show: boolean) => void; + // Workspace header (should span sidebar + workspace area) + workspaceHeader?: React.ReactNode; + // Chat state isDesktop: boolean; isChatExpanded: boolean; @@ -46,6 +49,7 @@ export function DashboardLayout({ onWorkspaceSwitch, showCreateModal, setShowCreateModal, + workspaceHeader, isDesktop, isChatExpanded, isChatMaximized, @@ -87,142 +91,164 @@ export function DashboardLayout({ const content = (
- {/* Left Sidebar - Key forces remount on workspace change */} - - - - - {/* Main Layout */} - - -
- {/* MAXIMIZED MODE: Show only chat (workspace completely hidden) */} - {effectiveChatMaximized ? ( -
- -
- -
-
+ + + {/* MAXIMIZED MODE: Show only chat (workspace completely hidden) */} + {effectiveChatMaximized ? ( +
+ +
+
- ) : ( - /* NORMAL MODE: Resizable workspace + chat */ +
+
+ ) : ( + + {/* Left Area: Split between workspace area (with header) and item panels */} + { + if (!effectiveChatExpanded) return 100; + return 100 - PANEL_DEFAULTS.CHAT; + })()} + minSize={effectiveChatExpanded ? PANEL_DEFAULTS.WORKSPACE_MIN : 100} + > - {/* Workspace Panel - hidden when 2 item panels are open (split view) */} - {panels.length < 2 && ( - { - if (panels.length === 0) { - return effectiveChatExpanded ? PANEL_DEFAULTS.WORKSPACE_WITH_CHAT : 100; - } - // 1 panel + workspace = split based on ratio (workspace gets the smaller share) - const availableSpace = effectiveChatExpanded ? (100 - PANEL_DEFAULTS.CHAT_MIN) : 100; - return availableSpace * (1 - PANEL_DEFAULTS.ITEM_PANEL_SPLIT_RATIO); - })()} - minSize={panels.length > 0 ? 20 : (effectiveChatExpanded ? PANEL_DEFAULTS.WORKSPACE_MIN : 100)} - > - - {workspaceSection} - - - )} + {/* Workspace area: header + sidebar + workspace canvas */} + { + if (panels.length === 0) { + return 100; + } + if (panels.length >= 2) { + return 0; // Hide workspace when 2 panels are open + } + return 100 * (1 - PANEL_DEFAULTS.ITEM_PANEL_SPLIT_RATIO); + })()} + minSize={panels.length >= 2 ? 0 : (panels.length > 0 ? 20 : 100)} + > +
+ {/* Header spans sidebar + workspace canvas (only render when a workspace exists) */} + {!!currentWorkspaceId && workspaceHeader} - {/* Item Panels - dynamically rendered from array */} - {panels.map((panel, index) => { - // Extract key from the panel ReactNode (set by page.tsx as item.id) - const panelKey = React.isValidElement(panel) ? panel.key : `panel-${index}`; - return ( - - {/* Handle only needed when there's something before this panel */} - {(index > 0 || panels.length < 2) && ( - - )} - = 2 ? (1 + index) : (2 + index)} - defaultSize={(() => { - if (panels.length >= 2) { - // 2 panels with chat = chat at min, panels split remaining - return effectiveChatExpanded ? (100 - PANEL_DEFAULTS.CHAT_MIN) / 2 : 50; - } - // 1 panel + workspace with chat = chat at min, split remaining based on ratio - const availableSpace = effectiveChatExpanded ? (100 - PANEL_DEFAULTS.CHAT_MIN) : 100; - return availableSpace * PANEL_DEFAULTS.ITEM_PANEL_SPLIT_RATIO; - })()} - minSize={20} + {/* Below header: sidebar + workspace content */} +
+ - {panel} - - - ); - })} - - {/* Chat Section - Only when expanded and workspace exists */} - {effectiveChatExpanded && ( - <> - - - {/* Chat Panel */} - = 2 ? 3 : (2 + panels.length)} - defaultSize={panels.length > 0 ? PANEL_DEFAULTS.CHAT_MIN : PANEL_DEFAULTS.CHAT} - minSize={PANEL_DEFAULTS.CHAT_MIN} - maxSize={PANEL_DEFAULTS.CHAT_MAX} - > - - - - + + + + {workspaceSection} + +
+
+
+ + {/* Item Panels - full height, start at top */} + {panels.length > 0 && ( + <> + {panels.length < 2 && ( + + )} + {panels.map((panel, index) => { + const panelKey = React.isValidElement(panel) ? panel.key : `panel-${index}`; + return ( + + {panels.length >= 2 && index > 0 && ( + + )} + = 2 ? 50 : 100 * PANEL_DEFAULTS.ITEM_PANEL_SPLIT_RATIO} + minSize={20} + > + {panel} + + + ); + })} )}
+
+ + {/* Chat Section - Only when expanded and workspace exists */} + {effectiveChatExpanded && ( + <> + + 0 ? PANEL_DEFAULTS.CHAT_MIN : PANEL_DEFAULTS.CHAT} + minSize={PANEL_DEFAULTS.CHAT_MIN} + maxSize={PANEL_DEFAULTS.CHAT_MAX} + > + + + + + )} -
- + + )}
); diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 23031e0e..58788204 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -149,6 +149,7 @@ function Sidebar({ side = "left", variant = "sidebar", collapsible = "offcanvas", + embedded = false, className, children, ...props @@ -156,6 +157,12 @@ function Sidebar({ side?: "left" | "right" variant?: "sidebar" | "floating" | "inset" collapsible?: "offcanvas" | "icon" | "none" + /** + * When true, renders the sidebar container within normal layout flow + * (absolute inside its reserved "gap" column) instead of `fixed` to viewport. + * Useful when you want the sidebar to sit under a page header. + */ + embedded?: boolean }) { const { isMobile, state, openMobile, setOpenMobile, open, setOpen } = useSidebar() @@ -201,7 +208,10 @@ function Sidebar({ return (