mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
Merge pull request #675 from CREDO23/sur-73-impr-implement-new-main-app-ux
[Refactor] Implement new main app UX
This commit is contained in:
commit
504ecf52d5
39 changed files with 2239 additions and 1784 deletions
|
|
@ -5,7 +5,7 @@ import { Loader2 } from "lucide-react";
|
||||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
|
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||||
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||||
|
|
@ -17,22 +17,18 @@ import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-quer
|
||||||
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
|
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
|
||||||
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
|
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
|
||||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||||
|
import { LayoutDataProvider } from "@/components/layout";
|
||||||
import { OnboardingTour } from "@/components/onboarding-tour";
|
import { OnboardingTour } from "@/components/onboarding-tour";
|
||||||
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
|
||||||
|
|
||||||
export function DashboardClientLayout({
|
export function DashboardClientLayout({
|
||||||
children,
|
children,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
navSecondary,
|
|
||||||
navMain,
|
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
searchSpaceId: string;
|
searchSpaceId: string;
|
||||||
navSecondary: any[];
|
navSecondary?: any[];
|
||||||
navMain: any[];
|
navMain?: any[];
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("dashboard");
|
const t = useTranslations("dashboard");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -59,50 +55,15 @@ export function DashboardClientLayout({
|
||||||
const [isAutoConfiguring, setIsAutoConfiguring] = useState(false);
|
const [isAutoConfiguring, setIsAutoConfiguring] = useState(false);
|
||||||
const hasAttemptedAutoConfig = useRef(false);
|
const hasAttemptedAutoConfig = useRef(false);
|
||||||
|
|
||||||
// Skip onboarding check if we're already on the onboarding page
|
|
||||||
const isOnboardingPage = pathname?.includes("/onboard");
|
const isOnboardingPage = pathname?.includes("/onboard");
|
||||||
|
|
||||||
// Only owners should see onboarding - invited members use existing config
|
|
||||||
const isOwner = access?.is_owner ?? false;
|
const isOwner = access?.is_owner ?? false;
|
||||||
|
|
||||||
// Translate navigation items
|
|
||||||
const tNavMenu = useTranslations("nav_menu");
|
|
||||||
const translatedNavMain = useMemo(() => {
|
|
||||||
return navMain.map((item) => ({
|
|
||||||
...item,
|
|
||||||
title: tNavMenu(item.title.toLowerCase().replace(/ /g, "_")),
|
|
||||||
items: item.items?.map((subItem: any) => ({
|
|
||||||
...subItem,
|
|
||||||
title: tNavMenu(subItem.title.toLowerCase().replace(/ /g, "_")),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
}, [navMain, tNavMenu]);
|
|
||||||
|
|
||||||
const translatedNavSecondary = useMemo(() => {
|
|
||||||
return navSecondary.map((item) => ({
|
|
||||||
...item,
|
|
||||||
title: item.title === "All Search Spaces" ? tNavMenu("all_search_spaces") : item.title,
|
|
||||||
}));
|
|
||||||
}, [navSecondary, tNavMenu]);
|
|
||||||
|
|
||||||
const [open, setOpen] = useState<boolean>(() => {
|
|
||||||
try {
|
|
||||||
const match = document.cookie.match(/(?:^|; )sidebar_state=([^;]+)/);
|
|
||||||
if (match) return match[1] === "true";
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip check if already on onboarding page
|
|
||||||
if (isOnboardingPage) {
|
if (isOnboardingPage) {
|
||||||
setHasCheckedOnboarding(true);
|
setHasCheckedOnboarding(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for all data to load
|
|
||||||
if (
|
if (
|
||||||
!loading &&
|
!loading &&
|
||||||
!accessLoading &&
|
!accessLoading &&
|
||||||
|
|
@ -112,19 +73,16 @@ export function DashboardClientLayout({
|
||||||
) {
|
) {
|
||||||
const onboardingComplete = isOnboardingComplete();
|
const onboardingComplete = isOnboardingComplete();
|
||||||
|
|
||||||
// If onboarding is complete, nothing to do
|
|
||||||
if (onboardingComplete) {
|
if (onboardingComplete) {
|
||||||
setHasCheckedOnboarding(true);
|
setHasCheckedOnboarding(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only handle onboarding for owners
|
|
||||||
if (!isOwner) {
|
if (!isOwner) {
|
||||||
setHasCheckedOnboarding(true);
|
setHasCheckedOnboarding(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If global configs available, auto-configure without going to onboard page
|
|
||||||
if (globalConfigs.length > 0 && !hasAttemptedAutoConfig.current) {
|
if (globalConfigs.length > 0 && !hasAttemptedAutoConfig.current) {
|
||||||
hasAttemptedAutoConfig.current = true;
|
hasAttemptedAutoConfig.current = true;
|
||||||
setIsAutoConfiguring(true);
|
setIsAutoConfiguring(true);
|
||||||
|
|
@ -149,7 +107,6 @@ export function DashboardClientLayout({
|
||||||
setHasCheckedOnboarding(true);
|
setHasCheckedOnboarding(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Auto-configuration failed:", error);
|
console.error("Auto-configuration failed:", error);
|
||||||
// Fall back to onboard page
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/onboard`);
|
router.push(`/dashboard/${searchSpaceId}/onboard`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsAutoConfiguring(false);
|
setIsAutoConfiguring(false);
|
||||||
|
|
@ -160,7 +117,6 @@ export function DashboardClientLayout({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No global configs - redirect to onboard page
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/onboard`);
|
router.push(`/dashboard/${searchSpaceId}/onboard`);
|
||||||
setHasCheckedOnboarding(true);
|
setHasCheckedOnboarding(true);
|
||||||
}
|
}
|
||||||
|
|
@ -180,7 +136,6 @@ export function DashboardClientLayout({
|
||||||
refetchPreferences,
|
refetchPreferences,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Synchronize active search space and chat IDs with URL
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activeSeacrhSpaceId =
|
const activeSeacrhSpaceId =
|
||||||
typeof search_space_id === "string"
|
typeof search_space_id === "string"
|
||||||
|
|
@ -192,7 +147,6 @@ export function DashboardClientLayout({
|
||||||
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
|
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
|
||||||
}, [search_space_id, setActiveSearchSpaceIdState]);
|
}, [search_space_id, setActiveSearchSpaceIdState]);
|
||||||
|
|
||||||
// Show loading screen while checking onboarding status or auto-configuring
|
|
||||||
if (
|
if (
|
||||||
(!hasCheckedOnboarding &&
|
(!hasCheckedOnboarding &&
|
||||||
(loading || accessLoading || globalConfigsLoading) &&
|
(loading || accessLoading || globalConfigsLoading) &&
|
||||||
|
|
@ -220,7 +174,6 @@ export function DashboardClientLayout({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show error screen if there's an error loading preferences (but not on onboarding page)
|
|
||||||
if (error && !hasCheckedOnboarding && !isOnboardingPage) {
|
if (error && !hasCheckedOnboarding && !isOnboardingPage) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
|
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
|
||||||
|
|
@ -244,33 +197,13 @@ export function DashboardClientLayout({
|
||||||
return (
|
return (
|
||||||
<DocumentUploadDialogProvider>
|
<DocumentUploadDialogProvider>
|
||||||
<OnboardingTour />
|
<OnboardingTour />
|
||||||
<SidebarProvider className="h-full overflow-hidden" open={open} onOpenChange={setOpen}>
|
<LayoutDataProvider
|
||||||
{/* Use AppSidebarProvider which fetches user, search space, and recent chats */}
|
|
||||||
<AppSidebarProvider
|
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
navSecondary={translatedNavSecondary}
|
breadcrumb={<DashboardBreadcrumb />}
|
||||||
navMain={translatedNavMain}
|
languageSwitcher={<LanguageSwitcher />}
|
||||||
/>
|
>
|
||||||
<SidebarInset className="h-full ">
|
{children}
|
||||||
<main className="flex flex-col h-full">
|
</LayoutDataProvider>
|
||||||
<header className="sticky top-0 flex h-16 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 border-b">
|
|
||||||
<div className="flex items-center justify-between w-full gap-2 px-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<SidebarTrigger className="-ml-1" />
|
|
||||||
<div className="hidden md:flex items-center gap-2">
|
|
||||||
<Separator orientation="vertical" className="h-6" />
|
|
||||||
<DashboardBreadcrumb />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<LanguageSwitcher />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div className="flex-1 overflow-hidden">{children}</div>
|
|
||||||
</main>
|
|
||||||
</SidebarInset>
|
|
||||||
</SidebarProvider>
|
|
||||||
</DocumentUploadDialogProvider>
|
</DocumentUploadDialogProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
surfsense_web/components/layout/hooks/index.ts
Normal file
1
surfsense_web/components/layout/hooks/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { useSidebarState } from "./useSidebarState";
|
||||||
61
surfsense_web/components/layout/hooks/useSidebarState.ts
Normal file
61
surfsense_web/components/layout/hooks/useSidebarState.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const SIDEBAR_COOKIE_NAME = "sidebar_collapsed";
|
||||||
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
||||||
|
|
||||||
|
interface UseSidebarStateReturn {
|
||||||
|
isCollapsed: boolean;
|
||||||
|
setIsCollapsed: (collapsed: boolean) => void;
|
||||||
|
toggleCollapsed: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidebarState(defaultCollapsed = false): UseSidebarStateReturn {
|
||||||
|
const [isCollapsed, setIsCollapsedState] = useState(defaultCollapsed);
|
||||||
|
|
||||||
|
// Initialize from cookie on mount
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const match = document.cookie.match(/(?:^|; )sidebar_collapsed=([^;]+)/);
|
||||||
|
if (match) {
|
||||||
|
setIsCollapsedState(match[1] === "true");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore cookie read errors
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Persist to cookie when state changes
|
||||||
|
const setIsCollapsed = useCallback((collapsed: boolean) => {
|
||||||
|
setIsCollapsedState(collapsed);
|
||||||
|
try {
|
||||||
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${collapsed}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||||
|
} catch {
|
||||||
|
// Ignore cookie write errors
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleCollapsed = useCallback(() => {
|
||||||
|
setIsCollapsed(!isCollapsed);
|
||||||
|
}, [isCollapsed, setIsCollapsed]);
|
||||||
|
|
||||||
|
// Keyboard shortcut: Cmd/Ctrl + B
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "b" && (event.metaKey || event.ctrlKey)) {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleCollapsed();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [toggleCollapsed]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isCollapsed,
|
||||||
|
setIsCollapsed,
|
||||||
|
toggleCollapsed,
|
||||||
|
};
|
||||||
|
}
|
||||||
30
surfsense_web/components/layout/index.ts
Normal file
30
surfsense_web/components/layout/index.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
export { useSidebarState } from "./hooks";
|
||||||
|
export { LayoutDataProvider } from "./providers";
|
||||||
|
export type {
|
||||||
|
ChatItem,
|
||||||
|
IconRailProps,
|
||||||
|
NavItem,
|
||||||
|
NoteItem,
|
||||||
|
PageUsage,
|
||||||
|
SidebarSectionProps,
|
||||||
|
User,
|
||||||
|
Workspace,
|
||||||
|
} from "./types/layout.types";
|
||||||
|
export {
|
||||||
|
ChatListItem,
|
||||||
|
Header,
|
||||||
|
IconRail,
|
||||||
|
LayoutShell,
|
||||||
|
MobileSidebar,
|
||||||
|
MobileSidebarTrigger,
|
||||||
|
NavIcon,
|
||||||
|
NavSection,
|
||||||
|
NoteListItem,
|
||||||
|
PageUsageDisplay,
|
||||||
|
Sidebar,
|
||||||
|
SidebarCollapseButton,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarSection,
|
||||||
|
SidebarUserProfile,
|
||||||
|
WorkspaceAvatar,
|
||||||
|
} from "./ui";
|
||||||
486
surfsense_web/components/layout/providers/LayoutDataProvider.tsx
Normal file
486
surfsense_web/components/layout/providers/LayoutDataProvider.tsx
Normal file
|
|
@ -0,0 +1,486 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
|
import { Logs, SquareLibrary, Trash2 } from "lucide-react";
|
||||||
|
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms";
|
||||||
|
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { useLogsSummary } from "@/hooks/use-logs";
|
||||||
|
import { notesApiService } from "@/lib/apis/notes-api.service";
|
||||||
|
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||||
|
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
|
||||||
|
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
||||||
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
import type { ChatItem, NavItem, NoteItem, Workspace } from "../types/layout.types";
|
||||||
|
import { LayoutShell } from "../ui/shell";
|
||||||
|
import { AllChatsSidebar } from "../ui/sidebar/AllChatsSidebar";
|
||||||
|
import { AllNotesSidebar } from "../ui/sidebar/AllNotesSidebar";
|
||||||
|
|
||||||
|
interface LayoutDataProviderProps {
|
||||||
|
searchSpaceId: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
breadcrumb?: React.ReactNode;
|
||||||
|
languageSwitcher?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LayoutDataProvider({
|
||||||
|
searchSpaceId,
|
||||||
|
children,
|
||||||
|
breadcrumb,
|
||||||
|
languageSwitcher,
|
||||||
|
}: LayoutDataProviderProps) {
|
||||||
|
const t = useTranslations("dashboard");
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
// Atoms
|
||||||
|
const { data: user } = useAtomValue(currentUserAtom);
|
||||||
|
const { data: searchSpacesData } = useAtomValue(searchSpacesAtom);
|
||||||
|
const hasUnsavedEditorChanges = useAtomValue(hasUnsavedEditorChangesAtom);
|
||||||
|
const setPendingNavigation = useSetAtom(pendingEditorNavigationAtom);
|
||||||
|
|
||||||
|
// Current IDs from URL
|
||||||
|
const currentChatId = params?.chat_id
|
||||||
|
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
|
||||||
|
: null;
|
||||||
|
const currentNoteId = params?.note_id
|
||||||
|
? Number(Array.isArray(params.note_id) ? params.note_id[0] : params.note_id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Fetch current search space
|
||||||
|
const { data: searchSpace } = useQuery({
|
||||||
|
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId),
|
||||||
|
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
|
||||||
|
enabled: !!searchSpaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch threads
|
||||||
|
const { data: threadsData, refetch: refetchThreads } = useQuery({
|
||||||
|
queryKey: ["threads", searchSpaceId, { limit: 4 }],
|
||||||
|
queryFn: () => fetchThreads(Number(searchSpaceId), 4),
|
||||||
|
enabled: !!searchSpaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch notes
|
||||||
|
const { data: notesData, refetch: refetchNotes } = useQuery({
|
||||||
|
queryKey: ["notes", searchSpaceId],
|
||||||
|
queryFn: () =>
|
||||||
|
notesApiService.getNotes({
|
||||||
|
search_space_id: Number(searchSpaceId),
|
||||||
|
page_size: 4,
|
||||||
|
}),
|
||||||
|
enabled: !!searchSpaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Poll for active reindexing tasks to show inline loading indicators
|
||||||
|
const { summary } = useLogsSummary(searchSpaceId ? Number(searchSpaceId) : 0, 24, {
|
||||||
|
enablePolling: true,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a Set of document IDs that are currently being reindexed
|
||||||
|
const reindexingDocumentIds = useMemo(() => {
|
||||||
|
if (!summary?.active_tasks) return new Set<number>();
|
||||||
|
return new Set(
|
||||||
|
summary.active_tasks
|
||||||
|
.filter((task) => task.document_id != null)
|
||||||
|
.map((task) => task.document_id as number)
|
||||||
|
);
|
||||||
|
}, [summary?.active_tasks]);
|
||||||
|
|
||||||
|
// All chats/notes sidebars state
|
||||||
|
const [isAllChatsSidebarOpen, setIsAllChatsSidebarOpen] = useState(false);
|
||||||
|
const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
// Delete dialogs state
|
||||||
|
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
|
||||||
|
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
|
||||||
|
const [isDeletingChat, setIsDeletingChat] = useState(false);
|
||||||
|
|
||||||
|
const [showDeleteNoteDialog, setShowDeleteNoteDialog] = useState(false);
|
||||||
|
const [noteToDelete, setNoteToDelete] = useState<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
search_space_id: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [isDeletingNote, setIsDeletingNote] = useState(false);
|
||||||
|
|
||||||
|
// Transform workspaces (API returns array directly, not { items: [...] })
|
||||||
|
const workspaces: Workspace[] = useMemo(() => {
|
||||||
|
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
|
||||||
|
return searchSpacesData.map((space) => ({
|
||||||
|
id: space.id,
|
||||||
|
name: space.name,
|
||||||
|
description: space.description,
|
||||||
|
isOwner: space.is_owner,
|
||||||
|
memberCount: space.member_count || 0,
|
||||||
|
}));
|
||||||
|
}, [searchSpacesData]);
|
||||||
|
|
||||||
|
// Use searchSpace query result for current workspace (more reliable than finding in list)
|
||||||
|
const activeWorkspace: Workspace | null = searchSpace
|
||||||
|
? {
|
||||||
|
id: searchSpace.id,
|
||||||
|
name: searchSpace.name,
|
||||||
|
description: searchSpace.description,
|
||||||
|
isOwner: searchSpace.is_owner,
|
||||||
|
memberCount: searchSpace.member_count || 0,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Transform chats
|
||||||
|
const chats: ChatItem[] = useMemo(() => {
|
||||||
|
if (!threadsData?.threads) return [];
|
||||||
|
return threadsData.threads.map((thread) => ({
|
||||||
|
id: thread.id,
|
||||||
|
name: thread.title || `Chat ${thread.id}`,
|
||||||
|
url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`,
|
||||||
|
}));
|
||||||
|
}, [threadsData, searchSpaceId]);
|
||||||
|
|
||||||
|
// Transform notes
|
||||||
|
const notes: NoteItem[] = useMemo(() => {
|
||||||
|
if (!notesData?.items) return [];
|
||||||
|
const sortedNotes = [...notesData.items].sort((a, b) => {
|
||||||
|
const dateA = a.updated_at
|
||||||
|
? new Date(a.updated_at).getTime()
|
||||||
|
: new Date(a.created_at).getTime();
|
||||||
|
const dateB = b.updated_at
|
||||||
|
? new Date(b.updated_at).getTime()
|
||||||
|
: new Date(b.created_at).getTime();
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
return sortedNotes.slice(0, 4).map((note) => ({
|
||||||
|
id: note.id,
|
||||||
|
name: note.title,
|
||||||
|
url: `/dashboard/${note.search_space_id}/editor/${note.id}`,
|
||||||
|
isReindexing: reindexingDocumentIds.has(note.id),
|
||||||
|
}));
|
||||||
|
}, [notesData, reindexingDocumentIds]);
|
||||||
|
|
||||||
|
// Navigation items
|
||||||
|
const navItems: NavItem[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: "Documents",
|
||||||
|
url: `/dashboard/${searchSpaceId}/documents`,
|
||||||
|
icon: SquareLibrary,
|
||||||
|
isActive: pathname?.includes("/documents"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Logs",
|
||||||
|
url: `/dashboard/${searchSpaceId}/logs`,
|
||||||
|
icon: Logs,
|
||||||
|
isActive: pathname?.includes("/logs"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[searchSpaceId, pathname]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleWorkspaceSelect = useCallback(
|
||||||
|
(id: number) => {
|
||||||
|
router.push(`/dashboard/${id}/new-chat`);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddWorkspace = useCallback(() => {
|
||||||
|
router.push("/dashboard/searchspaces");
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleSeeAllWorkspaces = useCallback(() => {
|
||||||
|
router.push("/dashboard");
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleNavItemClick = useCallback(
|
||||||
|
(item: NavItem) => {
|
||||||
|
router.push(item.url);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNewChat = useCallback(() => {
|
||||||
|
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||||
|
}, [router, searchSpaceId]);
|
||||||
|
|
||||||
|
const handleChatSelect = useCallback(
|
||||||
|
(chat: ChatItem) => {
|
||||||
|
router.push(chat.url);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChatDelete = useCallback((chat: ChatItem) => {
|
||||||
|
setChatToDelete({ id: chat.id, name: chat.name });
|
||||||
|
setShowDeleteChatDialog(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNoteSelect = useCallback(
|
||||||
|
(note: NoteItem) => {
|
||||||
|
if (hasUnsavedEditorChanges) {
|
||||||
|
setPendingNavigation(note.url);
|
||||||
|
} else {
|
||||||
|
router.push(note.url);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[router, hasUnsavedEditorChanges, setPendingNavigation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNoteDelete = useCallback(
|
||||||
|
(note: NoteItem) => {
|
||||||
|
setNoteToDelete({ id: note.id, name: note.name, search_space_id: Number(searchSpaceId) });
|
||||||
|
setShowDeleteNoteDialog(true);
|
||||||
|
},
|
||||||
|
[searchSpaceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddNote = useCallback(() => {
|
||||||
|
const newNoteUrl = `/dashboard/${searchSpaceId}/editor/new`;
|
||||||
|
if (hasUnsavedEditorChanges) {
|
||||||
|
setPendingNavigation(newNoteUrl);
|
||||||
|
} else {
|
||||||
|
router.push(newNoteUrl);
|
||||||
|
}
|
||||||
|
}, [router, searchSpaceId, hasUnsavedEditorChanges, setPendingNavigation]);
|
||||||
|
|
||||||
|
const handleSettings = useCallback(() => {
|
||||||
|
router.push(`/dashboard/${searchSpaceId}/settings`);
|
||||||
|
}, [router, searchSpaceId]);
|
||||||
|
|
||||||
|
const handleInviteMembers = useCallback(() => {
|
||||||
|
router.push(`/dashboard/${searchSpaceId}/team`);
|
||||||
|
}, [router, searchSpaceId]);
|
||||||
|
|
||||||
|
const handleLogout = useCallback(() => {
|
||||||
|
try {
|
||||||
|
trackLogout();
|
||||||
|
resetUser();
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.removeItem("surfsense_bearer_token");
|
||||||
|
router.push("/");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during logout:", error);
|
||||||
|
router.push("/");
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleToggleTheme = useCallback(() => {
|
||||||
|
setTheme(theme === "dark" ? "light" : "dark");
|
||||||
|
}, [theme, setTheme]);
|
||||||
|
|
||||||
|
const handleViewAllChats = useCallback(() => {
|
||||||
|
setIsAllChatsSidebarOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleViewAllNotes = useCallback(() => {
|
||||||
|
setIsAllNotesSidebarOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Delete handlers
|
||||||
|
const confirmDeleteChat = useCallback(async () => {
|
||||||
|
if (!chatToDelete) return;
|
||||||
|
setIsDeletingChat(true);
|
||||||
|
try {
|
||||||
|
await deleteThread(chatToDelete.id);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||||
|
if (currentChatId === chatToDelete.id) {
|
||||||
|
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting thread:", error);
|
||||||
|
} finally {
|
||||||
|
setIsDeletingChat(false);
|
||||||
|
setShowDeleteChatDialog(false);
|
||||||
|
setChatToDelete(null);
|
||||||
|
}
|
||||||
|
}, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]);
|
||||||
|
|
||||||
|
const confirmDeleteNote = useCallback(async () => {
|
||||||
|
if (!noteToDelete) return;
|
||||||
|
setIsDeletingNote(true);
|
||||||
|
try {
|
||||||
|
await notesApiService.deleteNote({
|
||||||
|
search_space_id: noteToDelete.search_space_id,
|
||||||
|
note_id: noteToDelete.id,
|
||||||
|
});
|
||||||
|
refetchNotes();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting note:", error);
|
||||||
|
} finally {
|
||||||
|
setIsDeletingNote(false);
|
||||||
|
setShowDeleteNoteDialog(false);
|
||||||
|
setNoteToDelete(null);
|
||||||
|
}
|
||||||
|
}, [noteToDelete, refetchNotes]);
|
||||||
|
|
||||||
|
// Page usage
|
||||||
|
const pageUsage = user
|
||||||
|
? {
|
||||||
|
pagesUsed: user.pages_used,
|
||||||
|
pagesLimit: user.pages_limit,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Detect if we're on the chat page (needs overflow-hidden for chat's own scroll)
|
||||||
|
const isChatPage = pathname?.includes("/new-chat") ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LayoutShell
|
||||||
|
workspaces={workspaces}
|
||||||
|
activeWorkspaceId={Number(searchSpaceId)}
|
||||||
|
onWorkspaceSelect={handleWorkspaceSelect}
|
||||||
|
onAddWorkspace={handleAddWorkspace}
|
||||||
|
workspace={activeWorkspace}
|
||||||
|
navItems={navItems}
|
||||||
|
onNavItemClick={handleNavItemClick}
|
||||||
|
chats={chats}
|
||||||
|
activeChatId={currentChatId}
|
||||||
|
onNewChat={handleNewChat}
|
||||||
|
onChatSelect={handleChatSelect}
|
||||||
|
onChatDelete={handleChatDelete}
|
||||||
|
onViewAllChats={handleViewAllChats}
|
||||||
|
notes={notes}
|
||||||
|
activeNoteId={currentNoteId}
|
||||||
|
onNoteSelect={handleNoteSelect}
|
||||||
|
onNoteDelete={handleNoteDelete}
|
||||||
|
onAddNote={handleAddNote}
|
||||||
|
onViewAllNotes={handleViewAllNotes}
|
||||||
|
user={{ email: user?.email || "", name: user?.email?.split("@")[0] }}
|
||||||
|
onSettings={handleSettings}
|
||||||
|
onInviteMembers={handleInviteMembers}
|
||||||
|
onSeeAllWorkspaces={handleSeeAllWorkspaces}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
pageUsage={pageUsage}
|
||||||
|
breadcrumb={breadcrumb}
|
||||||
|
languageSwitcher={languageSwitcher}
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={handleToggleTheme}
|
||||||
|
isChatPage={isChatPage}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</LayoutShell>
|
||||||
|
|
||||||
|
{/* Delete Chat Dialog */}
|
||||||
|
<Dialog open={showDeleteChatDialog} onOpenChange={setShowDeleteChatDialog}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Trash2 className="h-5 w-5 text-destructive" />
|
||||||
|
<span>{t("delete_chat")}</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("delete_chat_confirm")} <span className="font-medium">{chatToDelete?.name}</span>?{" "}
|
||||||
|
{t("action_cannot_undone")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowDeleteChatDialog(false)}
|
||||||
|
disabled={isDeletingChat}
|
||||||
|
>
|
||||||
|
{tCommon("cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={confirmDeleteChat}
|
||||||
|
disabled={isDeletingChat}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isDeletingChat ? (
|
||||||
|
<>
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
{t("deleting")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
{tCommon("delete")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* All Chats Sidebar */}
|
||||||
|
<AllChatsSidebar
|
||||||
|
open={isAllChatsSidebarOpen}
|
||||||
|
onOpenChange={setIsAllChatsSidebarOpen}
|
||||||
|
searchSpaceId={searchSpaceId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* All Notes Sidebar */}
|
||||||
|
<AllNotesSidebar
|
||||||
|
open={isAllNotesSidebarOpen}
|
||||||
|
onOpenChange={setIsAllNotesSidebarOpen}
|
||||||
|
searchSpaceId={searchSpaceId}
|
||||||
|
onAddNote={handleAddNote}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Note Dialog */}
|
||||||
|
<Dialog open={showDeleteNoteDialog} onOpenChange={setShowDeleteNoteDialog}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Trash2 className="h-5 w-5 text-destructive" />
|
||||||
|
<span>{t("delete_note")}</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("delete_note_confirm")} <span className="font-medium">{noteToDelete?.name}</span>?{" "}
|
||||||
|
{t("action_cannot_undone")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowDeleteNoteDialog(false)}
|
||||||
|
disabled={isDeletingNote}
|
||||||
|
>
|
||||||
|
{tCommon("cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={confirmDeleteNote}
|
||||||
|
disabled={isDeletingNote}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isDeletingNote ? (
|
||||||
|
<>
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
{t("deleting")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
{tCommon("delete")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
surfsense_web/components/layout/providers/index.ts
Normal file
1
surfsense_web/components/layout/providers/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { LayoutDataProvider } from "./LayoutDataProvider";
|
||||||
139
surfsense_web/components/layout/types/layout.types.ts
Normal file
139
surfsense_web/components/layout/types/layout.types.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export interface Workspace {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
isOwner: boolean;
|
||||||
|
memberCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
isActive?: boolean;
|
||||||
|
badge?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
isReindexing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageUsage {
|
||||||
|
pagesUsed: number;
|
||||||
|
pagesLimit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IconRailProps {
|
||||||
|
workspaces: Workspace[];
|
||||||
|
activeWorkspaceId: number | null;
|
||||||
|
onWorkspaceSelect: (id: number) => void;
|
||||||
|
onAddWorkspace: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarHeaderProps {
|
||||||
|
workspace: Workspace | null;
|
||||||
|
onSettings?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarSectionProps {
|
||||||
|
title: string;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavSectionProps {
|
||||||
|
items: NavItem[];
|
||||||
|
onItemClick?: (item: NavItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatsSectionProps {
|
||||||
|
chats: ChatItem[];
|
||||||
|
activeChatId?: number | null;
|
||||||
|
onChatSelect: (chat: ChatItem) => void;
|
||||||
|
onChatDelete?: (chat: ChatItem) => void;
|
||||||
|
onViewAllChats?: () => void;
|
||||||
|
searchSpaceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotesSectionProps {
|
||||||
|
notes: NoteItem[];
|
||||||
|
activeNoteId?: number | null;
|
||||||
|
onNoteSelect: (note: NoteItem) => void;
|
||||||
|
onNoteDelete?: (note: NoteItem) => void;
|
||||||
|
onAddNote?: () => void;
|
||||||
|
onViewAllNotes?: () => void;
|
||||||
|
searchSpaceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageUsageDisplayProps {
|
||||||
|
pagesUsed: number;
|
||||||
|
pagesLimit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarUserProfileProps {
|
||||||
|
user: User;
|
||||||
|
searchSpaceId?: string;
|
||||||
|
onSettings?: () => void;
|
||||||
|
onInviteMembers?: () => void;
|
||||||
|
onSwitchWorkspace?: () => void;
|
||||||
|
onToggleTheme?: () => void;
|
||||||
|
onLogout?: () => void;
|
||||||
|
theme?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarProps {
|
||||||
|
workspace: Workspace | null;
|
||||||
|
searchSpaceId?: string;
|
||||||
|
navItems: NavItem[];
|
||||||
|
chats: ChatItem[];
|
||||||
|
activeChatId?: number | null;
|
||||||
|
onNewChat: () => void;
|
||||||
|
onChatSelect: (chat: ChatItem) => void;
|
||||||
|
onChatDelete?: (chat: ChatItem) => void;
|
||||||
|
onViewAllChats?: () => void;
|
||||||
|
notes: NoteItem[];
|
||||||
|
activeNoteId?: number | null;
|
||||||
|
onNoteSelect: (note: NoteItem) => void;
|
||||||
|
onNoteDelete?: (note: NoteItem) => void;
|
||||||
|
onAddNote?: () => void;
|
||||||
|
onViewAllNotes?: () => void;
|
||||||
|
user: User;
|
||||||
|
theme?: string;
|
||||||
|
onSettings?: () => void;
|
||||||
|
onInviteMembers?: () => void;
|
||||||
|
onSwitchWorkspace?: () => void;
|
||||||
|
onToggleTheme?: () => void;
|
||||||
|
onLogout?: () => void;
|
||||||
|
pageUsage?: PageUsage;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutShellProps {
|
||||||
|
workspaces: Workspace[];
|
||||||
|
activeWorkspaceId: number | null;
|
||||||
|
onWorkspaceSelect: (id: number) => void;
|
||||||
|
onAddWorkspace: () => void;
|
||||||
|
sidebarProps: Omit<SidebarProps, "className">;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
49
surfsense_web/components/layout/ui/header/Header.tsx
Normal file
49
surfsense_web/components/layout/ui/header/Header.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
breadcrumb?: React.ReactNode;
|
||||||
|
languageSwitcher?: React.ReactNode;
|
||||||
|
theme?: string;
|
||||||
|
onToggleTheme?: () => void;
|
||||||
|
mobileMenuTrigger?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({
|
||||||
|
breadcrumb,
|
||||||
|
languageSwitcher,
|
||||||
|
theme,
|
||||||
|
onToggleTheme,
|
||||||
|
mobileMenuTrigger,
|
||||||
|
}: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4">
|
||||||
|
{/* Left side - Mobile menu trigger + Breadcrumb */}
|
||||||
|
<div className="flex flex-1 items-center gap-2 min-w-0">
|
||||||
|
{mobileMenuTrigger}
|
||||||
|
{breadcrumb}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Theme toggle */}
|
||||||
|
{onToggleTheme && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" onClick={onToggleTheme} className="h-8 w-8">
|
||||||
|
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
|
<span className="sr-only">Toggle theme</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{theme === "dark" ? "Light mode" : "Dark mode"}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{languageSwitcher}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
surfsense_web/components/layout/ui/header/index.ts
Normal file
1
surfsense_web/components/layout/ui/header/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { Header } from "./Header";
|
||||||
60
surfsense_web/components/layout/ui/icon-rail/IconRail.tsx
Normal file
60
surfsense_web/components/layout/ui/icon-rail/IconRail.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { Workspace } from "../../types/layout.types";
|
||||||
|
import { WorkspaceAvatar } from "./WorkspaceAvatar";
|
||||||
|
|
||||||
|
interface IconRailProps {
|
||||||
|
workspaces: Workspace[];
|
||||||
|
activeWorkspaceId: number | null;
|
||||||
|
onWorkspaceSelect: (id: number) => void;
|
||||||
|
onAddWorkspace: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconRail({
|
||||||
|
workspaces,
|
||||||
|
activeWorkspaceId,
|
||||||
|
onWorkspaceSelect,
|
||||||
|
onAddWorkspace,
|
||||||
|
className,
|
||||||
|
}: IconRailProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex h-full w-14 flex-col items-center", className)}>
|
||||||
|
<ScrollArea className="w-full">
|
||||||
|
<div className="flex flex-col items-center gap-2 px-1.5 py-3">
|
||||||
|
{workspaces.map((workspace) => (
|
||||||
|
<WorkspaceAvatar
|
||||||
|
key={workspace.id}
|
||||||
|
name={workspace.name}
|
||||||
|
isActive={workspace.id === activeWorkspaceId}
|
||||||
|
onClick={() => onWorkspaceSelect(workspace.id)}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onAddWorkspace}
|
||||||
|
className="h-10 w-10 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50"
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<span className="sr-only">Add workspace</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" sideOffset={8}>
|
||||||
|
Add workspace
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
surfsense_web/components/layout/ui/icon-rail/NavIcon.tsx
Normal file
34
surfsense_web/components/layout/ui/icon-rail/NavIcon.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface NavIconProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavIcon({ icon: Icon, label, isActive, onClick }: NavIconProps) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn("h-10 w-10 rounded-lg", isActive && "bg-accent text-accent-foreground")}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
<span className="sr-only">{label}</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" sideOffset={8}>
|
||||||
|
{label}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface WorkspaceAvatarProps {
|
||||||
|
name: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a consistent color based on workspace name
|
||||||
|
*/
|
||||||
|
function stringToColor(str: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
const colors = [
|
||||||
|
"#6366f1", // indigo
|
||||||
|
"#22c55e", // green
|
||||||
|
"#f59e0b", // amber
|
||||||
|
"#ef4444", // red
|
||||||
|
"#8b5cf6", // violet
|
||||||
|
"#06b6d4", // cyan
|
||||||
|
"#ec4899", // pink
|
||||||
|
"#14b8a6", // teal
|
||||||
|
];
|
||||||
|
return colors[Math.abs(hash) % colors.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets initials from workspace name (max 2 chars)
|
||||||
|
*/
|
||||||
|
function getInitials(name: string): string {
|
||||||
|
const words = name.trim().split(/\s+/);
|
||||||
|
if (words.length >= 2) {
|
||||||
|
return (words[0][0] + words[1][0]).toUpperCase();
|
||||||
|
}
|
||||||
|
return name.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspaceAvatar({ name, isActive, onClick, size = "md" }: WorkspaceAvatarProps) {
|
||||||
|
const bgColor = stringToColor(name);
|
||||||
|
const initials = getInitials(name);
|
||||||
|
const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center rounded-lg font-semibold text-white transition-all",
|
||||||
|
"hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
sizeClasses,
|
||||||
|
isActive && "ring-2 ring-primary ring-offset-1 ring-offset-background"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: bgColor }}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" sideOffset={8}>
|
||||||
|
{name}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
surfsense_web/components/layout/ui/icon-rail/index.ts
Normal file
3
surfsense_web/components/layout/ui/icon-rail/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { IconRail } from "./IconRail";
|
||||||
|
export { NavIcon } from "./NavIcon";
|
||||||
|
export { WorkspaceAvatar } from "./WorkspaceAvatar";
|
||||||
16
surfsense_web/components/layout/ui/index.ts
Normal file
16
surfsense_web/components/layout/ui/index.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
export { Header } from "./header";
|
||||||
|
export { IconRail, NavIcon, WorkspaceAvatar } from "./icon-rail";
|
||||||
|
export { LayoutShell } from "./shell";
|
||||||
|
export {
|
||||||
|
ChatListItem,
|
||||||
|
MobileSidebar,
|
||||||
|
MobileSidebarTrigger,
|
||||||
|
NavSection,
|
||||||
|
NoteListItem,
|
||||||
|
PageUsageDisplay,
|
||||||
|
Sidebar,
|
||||||
|
SidebarCollapseButton,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarSection,
|
||||||
|
SidebarUserProfile,
|
||||||
|
} from "./sidebar";
|
||||||
203
surfsense_web/components/layout/ui/shell/LayoutShell.tsx
Normal file
203
surfsense_web/components/layout/ui/shell/LayoutShell.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useSidebarState } from "../../hooks";
|
||||||
|
import type {
|
||||||
|
ChatItem,
|
||||||
|
NavItem,
|
||||||
|
NoteItem,
|
||||||
|
PageUsage,
|
||||||
|
User,
|
||||||
|
Workspace,
|
||||||
|
} from "../../types/layout.types";
|
||||||
|
import { Header } from "../header";
|
||||||
|
import { IconRail } from "../icon-rail";
|
||||||
|
import { MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar";
|
||||||
|
|
||||||
|
interface LayoutShellProps {
|
||||||
|
workspaces: Workspace[];
|
||||||
|
activeWorkspaceId: number | null;
|
||||||
|
onWorkspaceSelect: (id: number) => void;
|
||||||
|
onAddWorkspace: () => void;
|
||||||
|
workspace: Workspace | null;
|
||||||
|
navItems: NavItem[];
|
||||||
|
onNavItemClick?: (item: NavItem) => void;
|
||||||
|
chats: ChatItem[];
|
||||||
|
activeChatId?: number | null;
|
||||||
|
onNewChat: () => void;
|
||||||
|
onChatSelect: (chat: ChatItem) => void;
|
||||||
|
onChatDelete?: (chat: ChatItem) => void;
|
||||||
|
onViewAllChats?: () => void;
|
||||||
|
notes: NoteItem[];
|
||||||
|
activeNoteId?: number | null;
|
||||||
|
onNoteSelect: (note: NoteItem) => void;
|
||||||
|
onNoteDelete?: (note: NoteItem) => void;
|
||||||
|
onAddNote?: () => void;
|
||||||
|
onViewAllNotes?: () => void;
|
||||||
|
user: User;
|
||||||
|
onSettings?: () => void;
|
||||||
|
onInviteMembers?: () => void;
|
||||||
|
onSeeAllWorkspaces?: () => void;
|
||||||
|
onLogout?: () => void;
|
||||||
|
pageUsage?: PageUsage;
|
||||||
|
breadcrumb?: React.ReactNode;
|
||||||
|
languageSwitcher?: React.ReactNode;
|
||||||
|
theme?: string;
|
||||||
|
onToggleTheme?: () => void;
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
|
isChatPage?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LayoutShell({
|
||||||
|
workspaces,
|
||||||
|
activeWorkspaceId,
|
||||||
|
onWorkspaceSelect,
|
||||||
|
onAddWorkspace,
|
||||||
|
workspace,
|
||||||
|
navItems,
|
||||||
|
onNavItemClick,
|
||||||
|
chats,
|
||||||
|
activeChatId,
|
||||||
|
onNewChat,
|
||||||
|
onChatSelect,
|
||||||
|
onChatDelete,
|
||||||
|
onViewAllChats,
|
||||||
|
notes,
|
||||||
|
activeNoteId,
|
||||||
|
onNoteSelect,
|
||||||
|
onNoteDelete,
|
||||||
|
onAddNote,
|
||||||
|
onViewAllNotes,
|
||||||
|
user,
|
||||||
|
onSettings,
|
||||||
|
onInviteMembers,
|
||||||
|
onSeeAllWorkspaces,
|
||||||
|
onLogout,
|
||||||
|
pageUsage,
|
||||||
|
breadcrumb,
|
||||||
|
languageSwitcher,
|
||||||
|
theme,
|
||||||
|
onToggleTheme,
|
||||||
|
defaultCollapsed = false,
|
||||||
|
isChatPage = false,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: LayoutShellProps) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
const { isCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed);
|
||||||
|
|
||||||
|
// Mobile layout
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<div className={cn("flex h-screen w-full flex-col bg-background", className)}>
|
||||||
|
<Header
|
||||||
|
breadcrumb={breadcrumb}
|
||||||
|
languageSwitcher={languageSwitcher}
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={onToggleTheme}
|
||||||
|
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MobileSidebar
|
||||||
|
isOpen={mobileMenuOpen}
|
||||||
|
onOpenChange={setMobileMenuOpen}
|
||||||
|
workspaces={workspaces}
|
||||||
|
activeWorkspaceId={activeWorkspaceId}
|
||||||
|
onWorkspaceSelect={onWorkspaceSelect}
|
||||||
|
onAddWorkspace={onAddWorkspace}
|
||||||
|
workspace={workspace}
|
||||||
|
navItems={navItems}
|
||||||
|
onNavItemClick={onNavItemClick}
|
||||||
|
chats={chats}
|
||||||
|
activeChatId={activeChatId}
|
||||||
|
onNewChat={onNewChat}
|
||||||
|
onChatSelect={onChatSelect}
|
||||||
|
onChatDelete={onChatDelete}
|
||||||
|
onViewAllChats={onViewAllChats}
|
||||||
|
notes={notes}
|
||||||
|
activeNoteId={activeNoteId}
|
||||||
|
onNoteSelect={onNoteSelect}
|
||||||
|
onNoteDelete={onNoteDelete}
|
||||||
|
onAddNote={onAddNote}
|
||||||
|
onViewAllNotes={onViewAllNotes}
|
||||||
|
user={user}
|
||||||
|
onSettings={onSettings}
|
||||||
|
onInviteMembers={onInviteMembers}
|
||||||
|
onSeeAllWorkspaces={onSeeAllWorkspaces}
|
||||||
|
onLogout={onLogout}
|
||||||
|
pageUsage={pageUsage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop layout
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<div className={cn("flex h-screen w-full gap-2 p-2 overflow-hidden bg-muted/40", className)}>
|
||||||
|
<div className="hidden md:flex overflow-hidden">
|
||||||
|
<IconRail
|
||||||
|
workspaces={workspaces}
|
||||||
|
activeWorkspaceId={activeWorkspaceId}
|
||||||
|
onWorkspaceSelect={onWorkspaceSelect}
|
||||||
|
onAddWorkspace={onAddWorkspace}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 rounded-xl border bg-background overflow-hidden">
|
||||||
|
<Sidebar
|
||||||
|
workspace={workspace}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onToggleCollapse={toggleCollapsed}
|
||||||
|
navItems={navItems}
|
||||||
|
onNavItemClick={onNavItemClick}
|
||||||
|
chats={chats}
|
||||||
|
activeChatId={activeChatId}
|
||||||
|
onNewChat={onNewChat}
|
||||||
|
onChatSelect={onChatSelect}
|
||||||
|
onChatDelete={onChatDelete}
|
||||||
|
onViewAllChats={onViewAllChats}
|
||||||
|
notes={notes}
|
||||||
|
activeNoteId={activeNoteId}
|
||||||
|
onNoteSelect={onNoteSelect}
|
||||||
|
onNoteDelete={onNoteDelete}
|
||||||
|
onAddNote={onAddNote}
|
||||||
|
onViewAllNotes={onViewAllNotes}
|
||||||
|
user={user}
|
||||||
|
onSettings={onSettings}
|
||||||
|
onInviteMembers={onInviteMembers}
|
||||||
|
onSeeAllWorkspaces={onSeeAllWorkspaces}
|
||||||
|
onLogout={onLogout}
|
||||||
|
pageUsage={pageUsage}
|
||||||
|
className="hidden md:flex border-r shrink-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="flex-1 flex flex-col min-w-0">
|
||||||
|
<Header
|
||||||
|
breadcrumb={breadcrumb}
|
||||||
|
languageSwitcher={languageSwitcher}
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={onToggleTheme}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
surfsense_web/components/layout/ui/shell/index.ts
Normal file
1
surfsense_web/components/layout/ui/shell/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { LayoutShell } from "./LayoutShell";
|
||||||
65
surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx
Normal file
65
surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MessageSquare, MoreHorizontal } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ChatListItemProps {
|
||||||
|
name: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatListItem({ name, isActive, onClick, onDelete }: ChatListItemProps) {
|
||||||
|
const t = useTranslations("sidebar");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group/item relative w-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors",
|
||||||
|
"[&>span:last-child]:truncate",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||||
|
isActive && "bg-accent text-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="w-[calc(100%-3rem)] ">{name}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Actions dropdown */}
|
||||||
|
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/item:opacity-100 transition-opacity">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">{t("more_options")}</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" side="right">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete?.();
|
||||||
|
}}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
{t("delete")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
Normal file
154
surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Menu } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||||
|
import type {
|
||||||
|
ChatItem,
|
||||||
|
NavItem,
|
||||||
|
NoteItem,
|
||||||
|
PageUsage,
|
||||||
|
User,
|
||||||
|
Workspace,
|
||||||
|
} from "../../types/layout.types";
|
||||||
|
import { IconRail } from "../icon-rail";
|
||||||
|
import { Sidebar } from "./Sidebar";
|
||||||
|
|
||||||
|
interface MobileSidebarProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
workspaces: Workspace[];
|
||||||
|
activeWorkspaceId: number | null;
|
||||||
|
onWorkspaceSelect: (id: number) => void;
|
||||||
|
onAddWorkspace: () => void;
|
||||||
|
workspace: Workspace | null;
|
||||||
|
navItems: NavItem[];
|
||||||
|
onNavItemClick?: (item: NavItem) => void;
|
||||||
|
chats: ChatItem[];
|
||||||
|
activeChatId?: number | null;
|
||||||
|
onNewChat: () => void;
|
||||||
|
onChatSelect: (chat: ChatItem) => void;
|
||||||
|
onChatDelete?: (chat: ChatItem) => void;
|
||||||
|
onViewAllChats?: () => void;
|
||||||
|
notes: NoteItem[];
|
||||||
|
activeNoteId?: number | null;
|
||||||
|
onNoteSelect: (note: NoteItem) => void;
|
||||||
|
onNoteDelete?: (note: NoteItem) => void;
|
||||||
|
onAddNote?: () => void;
|
||||||
|
onViewAllNotes?: () => void;
|
||||||
|
user: User;
|
||||||
|
onSettings?: () => void;
|
||||||
|
onInviteMembers?: () => void;
|
||||||
|
onSeeAllWorkspaces?: () => void;
|
||||||
|
onLogout?: () => void;
|
||||||
|
pageUsage?: PageUsage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileSidebarTrigger({ onClick }: { onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="icon" className="md:hidden h-8 w-8" onClick={onClick}>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Open menu</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileSidebar({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
workspaces,
|
||||||
|
activeWorkspaceId,
|
||||||
|
onWorkspaceSelect,
|
||||||
|
onAddWorkspace,
|
||||||
|
workspace,
|
||||||
|
navItems,
|
||||||
|
onNavItemClick,
|
||||||
|
chats,
|
||||||
|
activeChatId,
|
||||||
|
onNewChat,
|
||||||
|
onChatSelect,
|
||||||
|
onChatDelete,
|
||||||
|
onViewAllChats,
|
||||||
|
notes,
|
||||||
|
activeNoteId,
|
||||||
|
onNoteSelect,
|
||||||
|
onNoteDelete,
|
||||||
|
onAddNote,
|
||||||
|
onViewAllNotes,
|
||||||
|
user,
|
||||||
|
onSettings,
|
||||||
|
onInviteMembers,
|
||||||
|
onSeeAllWorkspaces,
|
||||||
|
onLogout,
|
||||||
|
pageUsage,
|
||||||
|
}: MobileSidebarProps) {
|
||||||
|
const handleWorkspaceSelect = (id: number) => {
|
||||||
|
onWorkspaceSelect(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNavItemClick = (item: NavItem) => {
|
||||||
|
onNavItemClick?.(item);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChatSelect = (chat: ChatItem) => {
|
||||||
|
onChatSelect(chat);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNoteSelect = (note: NoteItem) => {
|
||||||
|
onNoteSelect(note);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent side="left" className="w-[320px] p-0 flex">
|
||||||
|
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
||||||
|
|
||||||
|
<div className="shrink-0 border-r bg-muted/40">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<IconRail
|
||||||
|
workspaces={workspaces}
|
||||||
|
activeWorkspaceId={activeWorkspaceId}
|
||||||
|
onWorkspaceSelect={handleWorkspaceSelect}
|
||||||
|
onAddWorkspace={onAddWorkspace}
|
||||||
|
/>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<Sidebar
|
||||||
|
workspace={workspace}
|
||||||
|
isCollapsed={false}
|
||||||
|
navItems={navItems}
|
||||||
|
onNavItemClick={handleNavItemClick}
|
||||||
|
chats={chats}
|
||||||
|
activeChatId={activeChatId}
|
||||||
|
onNewChat={() => {
|
||||||
|
onNewChat();
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
onChatSelect={handleChatSelect}
|
||||||
|
onChatDelete={onChatDelete}
|
||||||
|
onViewAllChats={onViewAllChats}
|
||||||
|
notes={notes}
|
||||||
|
activeNoteId={activeNoteId}
|
||||||
|
onNoteSelect={handleNoteSelect}
|
||||||
|
onNoteDelete={onNoteDelete}
|
||||||
|
onAddNote={onAddNote}
|
||||||
|
onViewAllNotes={onViewAllNotes}
|
||||||
|
user={user}
|
||||||
|
onSettings={onSettings}
|
||||||
|
onInviteMembers={onInviteMembers}
|
||||||
|
onSeeAllWorkspaces={onSeeAllWorkspaces}
|
||||||
|
onLogout={onLogout}
|
||||||
|
pageUsage={pageUsage}
|
||||||
|
className="w-full border-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
surfsense_web/components/layout/ui/sidebar/NavSection.tsx
Normal file
73
surfsense_web/components/layout/ui/sidebar/NavSection.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { NavItem } from "../../types/layout.types";
|
||||||
|
|
||||||
|
interface NavSectionProps {
|
||||||
|
items: NavItem[];
|
||||||
|
onItemClick?: (item: NavItem) => void;
|
||||||
|
isCollapsed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavSection({ items, onItemClick, isCollapsed = false }: NavSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-0.5 py-2", isCollapsed && "items-center")}>
|
||||||
|
{items.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
// Add data-joyride for onboarding tour
|
||||||
|
const joyrideAttr =
|
||||||
|
item.title === "Documents" || item.title.toLowerCase().includes("documents")
|
||||||
|
? { "data-joyride": "documents-sidebar" }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
return (
|
||||||
|
<Tooltip key={item.url}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onItemClick?.(item)}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-10 items-center justify-center rounded-md transition-colors",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||||
|
item.isActive && "bg-accent text-accent-foreground"
|
||||||
|
)}
|
||||||
|
{...joyrideAttr}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span className="sr-only">{item.title}</span>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
{item.title}
|
||||||
|
{item.badge && ` (${item.badge})`}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.url}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onItemClick?.(item)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-md mx-2 px-2 py-1.5 text-sm transition-colors text-left",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||||
|
item.isActive && "bg-accent text-accent-foreground"
|
||||||
|
)}
|
||||||
|
{...joyrideAttr}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="flex-1 truncate">{item.title}</span>
|
||||||
|
{item.badge && <span className="text-xs text-muted-foreground">{item.badge}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
surfsense_web/components/layout/ui/sidebar/NoteListItem.tsx
Normal file
76
surfsense_web/components/layout/ui/sidebar/NoteListItem.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FileText, Loader2, MoreHorizontal } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface NoteListItemProps {
|
||||||
|
name: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
isReindexing?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoteListItem({
|
||||||
|
name,
|
||||||
|
isActive,
|
||||||
|
isReindexing,
|
||||||
|
onClick,
|
||||||
|
onDelete,
|
||||||
|
}: NoteListItemProps) {
|
||||||
|
const t = useTranslations("sidebar");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group/item relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors",
|
||||||
|
"[&>span:last-child]:truncate",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||||
|
isActive && "bg-accent text-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isReindexing ? (
|
||||||
|
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" />
|
||||||
|
) : (
|
||||||
|
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span>{name}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Actions dropdown */}
|
||||||
|
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/item:opacity-100 transition-opacity">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">{t("more_options")}</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" side="right">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete?.();
|
||||||
|
}}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
{t("delete")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Mail } from "lucide-react";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
|
||||||
|
interface PageUsageDisplayProps {
|
||||||
|
pagesUsed: number;
|
||||||
|
pagesLimit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) {
|
||||||
|
const usagePercentage = (pagesUsed / pagesLimit) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-3 py-3 border-t">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center text-xs">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={usagePercentage} className="h-1.5" />
|
||||||
|
<a
|
||||||
|
href="mailto:rohan@surfsense.com?subject=Request%20to%20Increase%20Page%20Limits"
|
||||||
|
className="flex items-center gap-1.5 text-[10px] text-muted-foreground hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Mail className="h-3 w-3 shrink-0" />
|
||||||
|
<span>Contact to increase limits</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
294
surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
Normal file
294
surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FileText, FolderOpen, MessageSquare, PenSquare, Plus } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type {
|
||||||
|
ChatItem,
|
||||||
|
NavItem,
|
||||||
|
NoteItem,
|
||||||
|
PageUsage,
|
||||||
|
User,
|
||||||
|
Workspace,
|
||||||
|
} from "../../types/layout.types";
|
||||||
|
import { ChatListItem } from "./ChatListItem";
|
||||||
|
import { NavSection } from "./NavSection";
|
||||||
|
import { NoteListItem } from "./NoteListItem";
|
||||||
|
import { PageUsageDisplay } from "./PageUsageDisplay";
|
||||||
|
import { SidebarCollapseButton } from "./SidebarCollapseButton";
|
||||||
|
import { SidebarHeader } from "./SidebarHeader";
|
||||||
|
import { SidebarSection } from "./SidebarSection";
|
||||||
|
import { SidebarUserProfile } from "./SidebarUserProfile";
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
workspace: Workspace | null;
|
||||||
|
isCollapsed?: boolean;
|
||||||
|
onToggleCollapse?: () => void;
|
||||||
|
navItems: NavItem[];
|
||||||
|
onNavItemClick?: (item: NavItem) => void;
|
||||||
|
chats: ChatItem[];
|
||||||
|
activeChatId?: number | null;
|
||||||
|
onNewChat: () => void;
|
||||||
|
onChatSelect: (chat: ChatItem) => void;
|
||||||
|
onChatDelete?: (chat: ChatItem) => void;
|
||||||
|
onViewAllChats?: () => void;
|
||||||
|
notes: NoteItem[];
|
||||||
|
activeNoteId?: number | null;
|
||||||
|
onNoteSelect: (note: NoteItem) => void;
|
||||||
|
onNoteDelete?: (note: NoteItem) => void;
|
||||||
|
onAddNote?: () => void;
|
||||||
|
onViewAllNotes?: () => void;
|
||||||
|
user: User;
|
||||||
|
onSettings?: () => void;
|
||||||
|
onInviteMembers?: () => void;
|
||||||
|
onSeeAllWorkspaces?: () => void;
|
||||||
|
onLogout?: () => void;
|
||||||
|
pageUsage?: PageUsage;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({
|
||||||
|
workspace,
|
||||||
|
isCollapsed = false,
|
||||||
|
onToggleCollapse,
|
||||||
|
navItems,
|
||||||
|
onNavItemClick,
|
||||||
|
chats,
|
||||||
|
activeChatId,
|
||||||
|
onNewChat,
|
||||||
|
onChatSelect,
|
||||||
|
onChatDelete,
|
||||||
|
onViewAllChats,
|
||||||
|
notes,
|
||||||
|
activeNoteId,
|
||||||
|
onNoteSelect,
|
||||||
|
onNoteDelete,
|
||||||
|
onAddNote,
|
||||||
|
onViewAllNotes,
|
||||||
|
user,
|
||||||
|
onSettings,
|
||||||
|
onInviteMembers,
|
||||||
|
onSeeAllWorkspaces,
|
||||||
|
onLogout,
|
||||||
|
pageUsage,
|
||||||
|
className,
|
||||||
|
}: SidebarProps) {
|
||||||
|
const t = useTranslations("sidebar");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-full flex-col bg-sidebar text-sidebar-foreground transition-all duration-200 overflow-hidden",
|
||||||
|
isCollapsed ? "w-[60px]" : "w-[240px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header - workspace name or collapse button when collapsed */}
|
||||||
|
{isCollapsed ? (
|
||||||
|
<div className="flex h-14 shrink-0 items-center justify-center border-b">
|
||||||
|
<SidebarCollapseButton
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onToggle={onToggleCollapse ?? (() => {})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-14 shrink-0 items-center justify-between px-1 border-b">
|
||||||
|
<SidebarHeader
|
||||||
|
workspace={workspace}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onSettings={onSettings}
|
||||||
|
onInviteMembers={onInviteMembers}
|
||||||
|
onSeeAllWorkspaces={onSeeAllWorkspaces}
|
||||||
|
/>
|
||||||
|
<div className="">
|
||||||
|
<SidebarCollapseButton
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onToggle={onToggleCollapse ?? (() => {})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New chat button */}
|
||||||
|
<div className="p-2">
|
||||||
|
{isCollapsed ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" className="w-full h-10" onClick={onNewChat}>
|
||||||
|
<PenSquare className="h-4 w-4" />
|
||||||
|
<span className="sr-only">{t("new_chat")}</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">{t("new_chat")}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" className="w-full justify-start gap-2" onClick={onNewChat}>
|
||||||
|
<PenSquare className="h-4 w-4" />
|
||||||
|
{t("new_chat")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Platform navigation */}
|
||||||
|
{navItems.length > 0 && (
|
||||||
|
<NavSection items={navItems} onItemClick={onNavItemClick} isCollapsed={isCollapsed} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scrollable content */}
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
{isCollapsed ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-2 w-[60px]">
|
||||||
|
{chats.length > 0 && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
onClick={() => onToggleCollapse?.()}
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
<span className="sr-only">{t("recent_chats")}</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
{t("recent_chats")} ({chats.length})
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{notes.length > 0 && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10"
|
||||||
|
onClick={() => onToggleCollapse?.()}
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
<span className="sr-only">{t("notes")}</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
{t("notes")} ({notes.length})
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1 py-2 w-[240px]">
|
||||||
|
<SidebarSection
|
||||||
|
title={t("recent_chats")}
|
||||||
|
defaultOpen={true}
|
||||||
|
action={
|
||||||
|
onViewAllChats && chats.length > 0 ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5"
|
||||||
|
onClick={onViewAllChats}
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">{t("view_all_chats")}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{chats.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{chats.map((chat) => (
|
||||||
|
<ChatListItem
|
||||||
|
key={chat.id}
|
||||||
|
name={chat.name}
|
||||||
|
isActive={chat.id === activeChatId}
|
||||||
|
onClick={() => onChatSelect(chat)}
|
||||||
|
onDelete={() => onChatDelete?.(chat)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="px-2 py-1 text-xs text-muted-foreground">{t("no_recent_chats")}</p>
|
||||||
|
)}
|
||||||
|
</SidebarSection>
|
||||||
|
|
||||||
|
<SidebarSection
|
||||||
|
title={t("notes")}
|
||||||
|
defaultOpen={true}
|
||||||
|
action={
|
||||||
|
onViewAllNotes && notes.length > 0 ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5"
|
||||||
|
onClick={onViewAllNotes}
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">{t("view_all_notes")}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
persistentAction={
|
||||||
|
onAddNote && notes.length > 0 ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={onAddNote}>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">{t("add_note")}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{notes.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{notes.map((note) => (
|
||||||
|
<NoteListItem
|
||||||
|
key={note.id}
|
||||||
|
name={note.name}
|
||||||
|
isActive={note.id === activeNoteId}
|
||||||
|
isReindexing={note.isReindexing}
|
||||||
|
onClick={() => onNoteSelect(note)}
|
||||||
|
onDelete={() => onNoteDelete?.(note)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : onAddNote ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAddNote}
|
||||||
|
className="flex items-center gap-2 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
{t("create_new_note")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<p className="px-2 py-1 text-xs text-muted-foreground">{t("no_notes")}</p>
|
||||||
|
)}
|
||||||
|
</SidebarSection>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-auto">
|
||||||
|
{pageUsage && !isCollapsed && (
|
||||||
|
<PageUsageDisplay pagesUsed={pageUsage.pagesUsed} pagesLimit={pageUsage.pagesLimit} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SidebarUserProfile user={user} onLogout={onLogout} isCollapsed={isCollapsed} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { PanelLeft, PanelLeftClose } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
interface SidebarCollapseButtonProps {
|
||||||
|
isCollapsed: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarCollapseButton({ isCollapsed, onToggle }: SidebarCollapseButtonProps) {
|
||||||
|
const t = useTranslations("sidebar");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8 shrink-0">
|
||||||
|
{isCollapsed ? <PanelLeft className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
|
||||||
|
<span className="sr-only">
|
||||||
|
{isCollapsed ? t("expand_sidebar") : t("collapse_sidebar")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side={isCollapsed ? "right" : "bottom"}>
|
||||||
|
{isCollapsed ? `${t("expand_sidebar")} (⌘B)` : `${t("collapse_sidebar")} (⌘B)`}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx
Normal file
69
surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronsUpDown, LayoutGrid, Settings, UserPlus } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { Workspace } from "../../types/layout.types";
|
||||||
|
|
||||||
|
interface SidebarHeaderProps {
|
||||||
|
workspace: Workspace | null;
|
||||||
|
isCollapsed?: boolean;
|
||||||
|
onSettings?: () => void;
|
||||||
|
onInviteMembers?: () => void;
|
||||||
|
onSeeAllWorkspaces?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarHeader({
|
||||||
|
workspace,
|
||||||
|
isCollapsed,
|
||||||
|
onSettings,
|
||||||
|
onInviteMembers,
|
||||||
|
onSeeAllWorkspaces,
|
||||||
|
className,
|
||||||
|
}: SidebarHeaderProps) {
|
||||||
|
const t = useTranslations("sidebar");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex shrink-0 items-center", className)}>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
"flex h-auto items-center justify-between gap-2 overflow-hidden py-1.5 font-semibold",
|
||||||
|
isCollapsed ? "w-10" : "w-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate text-base">{workspace?.name ?? t("select_workspace")}</span>
|
||||||
|
<ChevronsUpDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-56">
|
||||||
|
<DropdownMenuItem onClick={onInviteMembers}>
|
||||||
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
|
{t("invite_members")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={onSettings}>
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
{t("workspace_settings")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={onSeeAllWorkspaces}>
|
||||||
|
<LayoutGrid className="mr-2 h-4 w-4" />
|
||||||
|
{t("see_all_workspaces")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface SidebarSectionProps {
|
||||||
|
title: string;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
persistentAction?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarSection({
|
||||||
|
title,
|
||||||
|
defaultOpen = true,
|
||||||
|
children,
|
||||||
|
action,
|
||||||
|
persistentAction,
|
||||||
|
}: SidebarSectionProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="overflow-hidden">
|
||||||
|
<div className="flex items-center group/section">
|
||||||
|
<CollapsibleTrigger className="flex flex-1 items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors min-w-0">
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"h-3.5 w-3.5 shrink-0 transition-transform duration-200",
|
||||||
|
isOpen && "rotate-90"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="uppercase tracking-wider truncate">{title}</span>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
{/* Action button - visible on hover (always visible on mobile) */}
|
||||||
|
{action && (
|
||||||
|
<div className="shrink-0 opacity-0 group-hover/section:opacity-100 transition-opacity pr-1 flex items-center gap-0.5">
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Persistent action - always visible */}
|
||||||
|
{persistentAction && (
|
||||||
|
<div className="shrink-0 pr-1 flex items-center gap-0.5">{persistentAction}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CollapsibleContent className="overflow-hidden">
|
||||||
|
<div className="px-2 pb-2">{children}</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronUp, LogOut } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { User } from "../../types/layout.types";
|
||||||
|
|
||||||
|
interface SidebarUserProfileProps {
|
||||||
|
user: User;
|
||||||
|
onLogout?: () => void;
|
||||||
|
isCollapsed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a consistent color based on email
|
||||||
|
*/
|
||||||
|
function stringToColor(str: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
const colors = [
|
||||||
|
"#6366f1",
|
||||||
|
"#8b5cf6",
|
||||||
|
"#a855f7",
|
||||||
|
"#d946ef",
|
||||||
|
"#ec4899",
|
||||||
|
"#f43f5e",
|
||||||
|
"#ef4444",
|
||||||
|
"#f97316",
|
||||||
|
"#eab308",
|
||||||
|
"#84cc16",
|
||||||
|
"#22c55e",
|
||||||
|
"#14b8a6",
|
||||||
|
"#06b6d4",
|
||||||
|
"#0ea5e9",
|
||||||
|
"#3b82f6",
|
||||||
|
];
|
||||||
|
return colors[Math.abs(hash) % colors.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets initials from email
|
||||||
|
*/
|
||||||
|
function getInitials(email: string): string {
|
||||||
|
const name = email.split("@")[0];
|
||||||
|
const parts = name.split(/[._-]/);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||||
|
}
|
||||||
|
return name.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarUserProfile({
|
||||||
|
user,
|
||||||
|
onLogout,
|
||||||
|
isCollapsed = false,
|
||||||
|
}: SidebarUserProfileProps) {
|
||||||
|
const t = useTranslations("sidebar");
|
||||||
|
const bgColor = stringToColor(user.email);
|
||||||
|
const initials = getInitials(user.email);
|
||||||
|
const displayName = user.name || user.email.split("@")[0];
|
||||||
|
|
||||||
|
// Collapsed view - just show avatar with dropdown
|
||||||
|
if (isCollapsed) {
|
||||||
|
return (
|
||||||
|
<div className="border-t p-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full items-center justify-center rounded-md",
|
||||||
|
"hover:bg-accent transition-colors",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-lg text-xs font-semibold text-white"
|
||||||
|
style={{ backgroundColor: bgColor }}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<span className="sr-only">{displayName}</span>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">{displayName}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<DropdownMenuContent className="w-56" side="right" align="end" sideOffset={8}>
|
||||||
|
<DropdownMenuLabel className="font-normal">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white"
|
||||||
|
style={{ backgroundColor: bgColor }}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{displayName}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuItem onClick={onLogout}>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
{t("logout")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanded view
|
||||||
|
return (
|
||||||
|
<div className="border-t">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 px-2 py-3 text-left",
|
||||||
|
"hover:bg-accent transition-colors",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div
|
||||||
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white"
|
||||||
|
style={{ backgroundColor: bgColor }}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name and email */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{displayName}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chevron icon */}
|
||||||
|
<ChevronUp className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent className="w-56" side="top" align="start" sideOffset={4}>
|
||||||
|
<DropdownMenuLabel className="font-normal">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white"
|
||||||
|
style={{ backgroundColor: bgColor }}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{displayName}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuItem onClick={onLogout}>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
{t("logout")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
surfsense_web/components/layout/ui/sidebar/index.ts
Normal file
12
surfsense_web/components/layout/ui/sidebar/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
export { AllChatsSidebar } from "./AllChatsSidebar";
|
||||||
|
export { AllNotesSidebar } from "./AllNotesSidebar";
|
||||||
|
export { ChatListItem } from "./ChatListItem";
|
||||||
|
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
||||||
|
export { NavSection } from "./NavSection";
|
||||||
|
export { NoteListItem } from "./NoteListItem";
|
||||||
|
export { PageUsageDisplay } from "./PageUsageDisplay";
|
||||||
|
export { Sidebar } from "./Sidebar";
|
||||||
|
export { SidebarCollapseButton } from "./SidebarCollapseButton";
|
||||||
|
export { SidebarHeader } from "./SidebarHeader";
|
||||||
|
export { SidebarSection } from "./SidebarSection";
|
||||||
|
export { SidebarUserProfile } from "./SidebarUserProfile";
|
||||||
|
|
@ -407,7 +407,7 @@ export function OnboardingTour() {
|
||||||
|
|
||||||
// Fetch threads data
|
// Fetch threads data
|
||||||
const { data: threadsData } = useQuery({
|
const { data: threadsData } = useQuery({
|
||||||
queryKey: ["threads", searchSpaceId],
|
queryKey: ["threads", searchSpaceId, { limit: 1 }],
|
||||||
queryFn: () => fetchThreads(Number(searchSpaceId), 1), // Only need to check if any exist
|
queryFn: () => fetchThreads(Number(searchSpaceId), 1), // Only need to check if any exist
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!searchSpaceId,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,383 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
|
||||||
import { Trash2 } from "lucide-react";
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms";
|
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
|
||||||
import { AppSidebar } from "@/components/sidebar/app-sidebar";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { notesApiService } from "@/lib/apis/notes-api.service";
|
|
||||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
|
||||||
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
|
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|
||||||
|
|
||||||
interface AppSidebarProviderProps {
|
|
||||||
searchSpaceId: string;
|
|
||||||
navSecondary: {
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
icon: string;
|
|
||||||
}[];
|
|
||||||
navMain: {
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
icon: string;
|
|
||||||
isActive?: boolean;
|
|
||||||
items?: {
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
}[];
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppSidebarProvider({
|
|
||||||
searchSpaceId,
|
|
||||||
navSecondary,
|
|
||||||
navMain,
|
|
||||||
}: AppSidebarProviderProps) {
|
|
||||||
const t = useTranslations("dashboard");
|
|
||||||
const tCommon = useTranslations("common");
|
|
||||||
const router = useRouter();
|
|
||||||
const params = useParams();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
// Get current chat ID from URL params
|
|
||||||
const currentChatId = params?.chat_id
|
|
||||||
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
|
|
||||||
: null;
|
|
||||||
const [isDeletingThread, setIsDeletingThread] = useState(false);
|
|
||||||
|
|
||||||
// Editor state for handling unsaved changes
|
|
||||||
const hasUnsavedEditorChanges = useAtomValue(hasUnsavedEditorChangesAtom);
|
|
||||||
const setPendingNavigation = useSetAtom(pendingEditorNavigationAtom);
|
|
||||||
|
|
||||||
// Fetch new chat threads
|
|
||||||
const {
|
|
||||||
data: threadsData,
|
|
||||||
error: threadError,
|
|
||||||
refetch: refetchThreads,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["threads", searchSpaceId],
|
|
||||||
queryFn: () => fetchThreads(Number(searchSpaceId), 4),
|
|
||||||
enabled: !!searchSpaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: searchSpace,
|
|
||||||
isLoading: isLoadingSearchSpace,
|
|
||||||
error: searchSpaceError,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId),
|
|
||||||
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
|
|
||||||
enabled: !!searchSpaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: user } = useAtomValue(currentUserAtom);
|
|
||||||
|
|
||||||
// Fetch notes
|
|
||||||
const { data: notesData, refetch: refetchNotes } = useQuery({
|
|
||||||
queryKey: ["notes", searchSpaceId],
|
|
||||||
queryFn: () =>
|
|
||||||
notesApiService.getNotes({
|
|
||||||
search_space_id: Number(searchSpaceId),
|
|
||||||
page_size: 4, // Get 4 notes for compact sidebar
|
|
||||||
}),
|
|
||||||
enabled: !!searchSpaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
||||||
const [threadToDelete, setThreadToDelete] = useState<{ id: number; name: string } | null>(null);
|
|
||||||
const [showDeleteNoteDialog, setShowDeleteNoteDialog] = useState(false);
|
|
||||||
const [noteToDelete, setNoteToDelete] = useState<{
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
search_space_id: number;
|
|
||||||
} | null>(null);
|
|
||||||
const [isDeletingNote, setIsDeletingNote] = useState(false);
|
|
||||||
|
|
||||||
// Transform threads to the format expected by AppSidebar
|
|
||||||
const recentChats = useMemo(() => {
|
|
||||||
if (!threadsData?.threads) return [];
|
|
||||||
|
|
||||||
// Threads are already sorted by updated_at desc from the API
|
|
||||||
return threadsData.threads.map((thread) => ({
|
|
||||||
name: thread.title || `Chat ${thread.id}`,
|
|
||||||
url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`,
|
|
||||||
icon: "MessageCircleMore",
|
|
||||||
id: thread.id,
|
|
||||||
search_space_id: Number(searchSpaceId),
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
name: "Delete",
|
|
||||||
icon: "Trash2",
|
|
||||||
onClick: () => {
|
|
||||||
setThreadToDelete({
|
|
||||||
id: thread.id,
|
|
||||||
name: thread.title || `Chat ${thread.id}`,
|
|
||||||
});
|
|
||||||
setShowDeleteDialog(true);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
}, [threadsData, searchSpaceId]);
|
|
||||||
|
|
||||||
// Handle delete thread
|
|
||||||
const handleDeleteThread = useCallback(async () => {
|
|
||||||
if (!threadToDelete) return;
|
|
||||||
|
|
||||||
setIsDeletingThread(true);
|
|
||||||
try {
|
|
||||||
await deleteThread(threadToDelete.id);
|
|
||||||
// Invalidate threads query to refresh the list
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
|
||||||
// Only navigate to new-chat if the deleted chat is currently open
|
|
||||||
if (currentChatId === threadToDelete.id) {
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting thread:", error);
|
|
||||||
} finally {
|
|
||||||
setIsDeletingThread(false);
|
|
||||||
setShowDeleteDialog(false);
|
|
||||||
setThreadToDelete(null);
|
|
||||||
}
|
|
||||||
}, [threadToDelete, queryClient, searchSpaceId, router, currentChatId]);
|
|
||||||
|
|
||||||
// Handle delete note with confirmation
|
|
||||||
const handleDeleteNote = useCallback(async () => {
|
|
||||||
if (!noteToDelete) return;
|
|
||||||
|
|
||||||
setIsDeletingNote(true);
|
|
||||||
try {
|
|
||||||
await notesApiService.deleteNote({
|
|
||||||
search_space_id: noteToDelete.search_space_id,
|
|
||||||
note_id: noteToDelete.id,
|
|
||||||
});
|
|
||||||
refetchNotes();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting note:", error);
|
|
||||||
} finally {
|
|
||||||
setIsDeletingNote(false);
|
|
||||||
setShowDeleteNoteDialog(false);
|
|
||||||
setNoteToDelete(null);
|
|
||||||
}
|
|
||||||
}, [noteToDelete, refetchNotes]);
|
|
||||||
|
|
||||||
// Memoized fallback chats
|
|
||||||
const fallbackChats = useMemo(() => {
|
|
||||||
if (threadError) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: t("error_loading_chats"),
|
|
||||||
url: "#",
|
|
||||||
icon: "AlertCircle",
|
|
||||||
id: 0,
|
|
||||||
search_space_id: Number(searchSpaceId),
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
name: tCommon("retry"),
|
|
||||||
icon: "RefreshCw",
|
|
||||||
onClick: () => refetchThreads(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}, [threadError, searchSpaceId, refetchThreads, t, tCommon]);
|
|
||||||
|
|
||||||
// Use fallback chats if there's an error or no chats
|
|
||||||
const displayChats = recentChats.length > 0 ? recentChats : fallbackChats;
|
|
||||||
|
|
||||||
// Transform notes to the format expected by NavNotes
|
|
||||||
const recentNotes = useMemo(() => {
|
|
||||||
if (!notesData?.items) return [];
|
|
||||||
|
|
||||||
// Sort notes by updated_at (most recent first), fallback to created_at if updated_at is null
|
|
||||||
const sortedNotes = [...notesData.items].sort((a, b) => {
|
|
||||||
const dateA = a.updated_at
|
|
||||||
? new Date(a.updated_at).getTime()
|
|
||||||
: new Date(a.created_at).getTime();
|
|
||||||
const dateB = b.updated_at
|
|
||||||
? new Date(b.updated_at).getTime()
|
|
||||||
: new Date(b.created_at).getTime();
|
|
||||||
return dateB - dateA; // Descending order (most recent first)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Limit to 4 notes for compact sidebar
|
|
||||||
return sortedNotes.slice(0, 4).map((note) => ({
|
|
||||||
name: note.title,
|
|
||||||
url: `/dashboard/${note.search_space_id}/editor/${note.id}`,
|
|
||||||
icon: "FileText",
|
|
||||||
id: note.id,
|
|
||||||
search_space_id: note.search_space_id,
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
name: "Delete",
|
|
||||||
icon: "Trash2",
|
|
||||||
onClick: () => {
|
|
||||||
setNoteToDelete({
|
|
||||||
id: note.id,
|
|
||||||
name: note.title,
|
|
||||||
search_space_id: note.search_space_id,
|
|
||||||
});
|
|
||||||
setShowDeleteNoteDialog(true);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
}, [notesData]);
|
|
||||||
|
|
||||||
// Handle add note - check for unsaved changes first
|
|
||||||
const handleAddNote = useCallback(() => {
|
|
||||||
const newNoteUrl = `/dashboard/${searchSpaceId}/editor/new`;
|
|
||||||
|
|
||||||
if (hasUnsavedEditorChanges) {
|
|
||||||
// Set pending navigation - the editor will show the unsaved changes dialog
|
|
||||||
setPendingNavigation(newNoteUrl);
|
|
||||||
} else {
|
|
||||||
// No unsaved changes, navigate directly
|
|
||||||
router.push(newNoteUrl);
|
|
||||||
}
|
|
||||||
}, [router, searchSpaceId, hasUnsavedEditorChanges, setPendingNavigation]);
|
|
||||||
|
|
||||||
// Memoized updated navSecondary
|
|
||||||
const updatedNavSecondary = useMemo(() => {
|
|
||||||
const updated = [...navSecondary];
|
|
||||||
if (updated.length > 0) {
|
|
||||||
updated[0] = {
|
|
||||||
...updated[0],
|
|
||||||
title:
|
|
||||||
searchSpace?.name ||
|
|
||||||
(isLoadingSearchSpace
|
|
||||||
? tCommon("loading")
|
|
||||||
: searchSpaceError
|
|
||||||
? t("error_loading_space")
|
|
||||||
: t("unknown_search_space")),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
}, [navSecondary, searchSpace?.name, isLoadingSearchSpace, searchSpaceError, t, tCommon]);
|
|
||||||
|
|
||||||
// Prepare page usage data
|
|
||||||
const pageUsage = user
|
|
||||||
? {
|
|
||||||
pagesUsed: user.pages_used,
|
|
||||||
pagesLimit: user.pages_limit,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AppSidebar
|
|
||||||
searchSpaceId={searchSpaceId}
|
|
||||||
navSecondary={updatedNavSecondary}
|
|
||||||
navMain={navMain}
|
|
||||||
RecentChats={displayChats}
|
|
||||||
RecentNotes={recentNotes}
|
|
||||||
onAddNote={handleAddNote}
|
|
||||||
pageUsage={pageUsage}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
|
||||||
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<Trash2 className="h-5 w-5 text-destructive" />
|
|
||||||
<span>{t("delete_chat")}</span>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{t("delete_chat_confirm")} <span className="font-medium">{threadToDelete?.name}</span>
|
|
||||||
? {t("action_cannot_undone")}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowDeleteDialog(false)}
|
|
||||||
disabled={isDeletingThread}
|
|
||||||
>
|
|
||||||
{tCommon("cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleDeleteThread}
|
|
||||||
disabled={isDeletingThread}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
{isDeletingThread ? (
|
|
||||||
<>
|
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
|
||||||
{t("deleting")}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
{tCommon("delete")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Delete Note Confirmation Dialog */}
|
|
||||||
<Dialog open={showDeleteNoteDialog} onOpenChange={setShowDeleteNoteDialog}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<Trash2 className="h-5 w-5 text-destructive" />
|
|
||||||
<span>{t("delete_note")}</span>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{t("delete_note_confirm")} <span className="font-medium">{noteToDelete?.name}</span>?{" "}
|
|
||||||
{t("action_cannot_undone")}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowDeleteNoteDialog(false)}
|
|
||||||
disabled={isDeletingNote}
|
|
||||||
>
|
|
||||||
{tCommon("cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleDeleteNote}
|
|
||||||
disabled={isDeletingNote}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
{isDeletingNote ? (
|
|
||||||
<>
|
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
|
||||||
{t("deleting")}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
{tCommon("delete")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,473 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
ArrowLeftRight,
|
|
||||||
BookOpen,
|
|
||||||
Cable,
|
|
||||||
ChevronsUpDown,
|
|
||||||
Database,
|
|
||||||
ExternalLink,
|
|
||||||
FileStack,
|
|
||||||
FileText,
|
|
||||||
Info,
|
|
||||||
LogOut,
|
|
||||||
Logs,
|
|
||||||
type LucideIcon,
|
|
||||||
MessageCircle,
|
|
||||||
MessageCircleMore,
|
|
||||||
MoonIcon,
|
|
||||||
Podcast,
|
|
||||||
RefreshCw,
|
|
||||||
Settings2,
|
|
||||||
SquareLibrary,
|
|
||||||
SquareTerminal,
|
|
||||||
SunIcon,
|
|
||||||
Trash2,
|
|
||||||
Undo2,
|
|
||||||
UserPlus,
|
|
||||||
Users,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import { memo, useEffect, useMemo, useState } from "react";
|
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a consistent color based on a string (email)
|
|
||||||
*/
|
|
||||||
function stringToColor(str: string): string {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
|
||||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
|
||||||
}
|
|
||||||
const colors = [
|
|
||||||
"#6366f1", // indigo
|
|
||||||
"#8b5cf6", // violet
|
|
||||||
"#a855f7", // purple
|
|
||||||
"#d946ef", // fuchsia
|
|
||||||
"#ec4899", // pink
|
|
||||||
"#f43f5e", // rose
|
|
||||||
"#ef4444", // red
|
|
||||||
"#f97316", // orange
|
|
||||||
"#eab308", // yellow
|
|
||||||
"#84cc16", // lime
|
|
||||||
"#22c55e", // green
|
|
||||||
"#14b8a6", // teal
|
|
||||||
"#06b6d4", // cyan
|
|
||||||
"#0ea5e9", // sky
|
|
||||||
"#3b82f6", // blue
|
|
||||||
];
|
|
||||||
return colors[Math.abs(hash) % colors.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets initials from an email address
|
|
||||||
*/
|
|
||||||
function getInitials(email: string): string {
|
|
||||||
const name = email.split("@")[0];
|
|
||||||
const parts = name.split(/[._-]/);
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
|
||||||
}
|
|
||||||
return name.slice(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dynamic avatar component that generates an SVG based on email
|
|
||||||
*/
|
|
||||||
function UserAvatar({ email, size = 32 }: { email: string; size?: number }) {
|
|
||||||
const bgColor = stringToColor(email);
|
|
||||||
const initials = getInitials(email);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
viewBox="0 0 32 32"
|
|
||||||
className="rounded-lg"
|
|
||||||
role="img"
|
|
||||||
aria-labelledby="sidebar-avatar-title"
|
|
||||||
>
|
|
||||||
<title id="sidebar-avatar-title">Avatar for {email}</title>
|
|
||||||
<rect width="32" height="32" rx="6" fill={bgColor} />
|
|
||||||
<text
|
|
||||||
x="50%"
|
|
||||||
y="50%"
|
|
||||||
dominantBaseline="central"
|
|
||||||
textAnchor="middle"
|
|
||||||
fill="white"
|
|
||||||
fontSize="12"
|
|
||||||
fontWeight="600"
|
|
||||||
fontFamily="system-ui, sans-serif"
|
|
||||||
>
|
|
||||||
{initials}
|
|
||||||
</text>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
import { NavChats } from "@/components/sidebar/nav-chats";
|
|
||||||
import { NavMain } from "@/components/sidebar/nav-main";
|
|
||||||
import { NavNotes } from "@/components/sidebar/nav-notes";
|
|
||||||
import { NavSecondary } from "@/components/sidebar/nav-secondary";
|
|
||||||
import { PageUsageDisplay } from "@/components/sidebar/page-usage-display";
|
|
||||||
import {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
|
||||||
SidebarFooter,
|
|
||||||
SidebarHeader,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
} from "@/components/ui/sidebar";
|
|
||||||
|
|
||||||
// Map of icon names to their components
|
|
||||||
export const iconMap: Record<string, LucideIcon> = {
|
|
||||||
BookOpen,
|
|
||||||
Cable,
|
|
||||||
Database,
|
|
||||||
FileStack,
|
|
||||||
Undo2,
|
|
||||||
MessageCircleMore,
|
|
||||||
Settings2,
|
|
||||||
SquareLibrary,
|
|
||||||
FileText,
|
|
||||||
SquareTerminal,
|
|
||||||
AlertCircle,
|
|
||||||
Info,
|
|
||||||
ExternalLink,
|
|
||||||
Trash2,
|
|
||||||
Podcast,
|
|
||||||
Users,
|
|
||||||
RefreshCw,
|
|
||||||
MessageCircle,
|
|
||||||
Logs,
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultData = {
|
|
||||||
user: {
|
|
||||||
name: "Surf",
|
|
||||||
email: "m@example.com",
|
|
||||||
avatar: "/icon-128.svg",
|
|
||||||
},
|
|
||||||
navMain: [
|
|
||||||
{
|
|
||||||
title: "Chat",
|
|
||||||
url: "#",
|
|
||||||
icon: "SquareTerminal",
|
|
||||||
isActive: true,
|
|
||||||
items: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Sources",
|
|
||||||
url: "#",
|
|
||||||
icon: "Database",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "Manage Documents",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Manage Connectors",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
navSecondary: [
|
|
||||||
{
|
|
||||||
title: "SEARCH SPACE",
|
|
||||||
url: "#",
|
|
||||||
icon: "LifeBuoy",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
RecentChats: [
|
|
||||||
{
|
|
||||||
name: "Design Engineering",
|
|
||||||
url: "#",
|
|
||||||
icon: "MessageCircleMore",
|
|
||||||
id: 1001,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Sales & Marketing",
|
|
||||||
url: "#",
|
|
||||||
icon: "MessageCircleMore",
|
|
||||||
id: 1002,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Travel",
|
|
||||||
url: "#",
|
|
||||||
icon: "MessageCircleMore",
|
|
||||||
id: 1003,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
RecentNotes: [
|
|
||||||
{
|
|
||||||
name: "Meeting Notes",
|
|
||||||
url: "#",
|
|
||||||
icon: "FileText",
|
|
||||||
id: 2001,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Project Ideas",
|
|
||||||
url: "#",
|
|
||||||
icon: "FileText",
|
|
||||||
id: 2002,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
|
||||||
searchSpaceId?: string;
|
|
||||||
navMain?: {
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
icon: string;
|
|
||||||
isActive?: boolean;
|
|
||||||
items?: {
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
}[];
|
|
||||||
}[];
|
|
||||||
navSecondary?: {
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
icon: string;
|
|
||||||
}[];
|
|
||||||
RecentChats?: {
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
icon: string;
|
|
||||||
id?: number;
|
|
||||||
search_space_id?: number;
|
|
||||||
actions?: {
|
|
||||||
name: string;
|
|
||||||
icon: string;
|
|
||||||
onClick: () => void;
|
|
||||||
}[];
|
|
||||||
}[];
|
|
||||||
RecentNotes?: {
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
icon: string;
|
|
||||||
id?: number;
|
|
||||||
search_space_id?: number;
|
|
||||||
actions?: {
|
|
||||||
name: string;
|
|
||||||
icon: string;
|
|
||||||
onClick: () => void;
|
|
||||||
}[];
|
|
||||||
}[];
|
|
||||||
user?: {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
avatar: string;
|
|
||||||
};
|
|
||||||
pageUsage?: {
|
|
||||||
pagesUsed: number;
|
|
||||||
pagesLimit: number;
|
|
||||||
};
|
|
||||||
onAddNote?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Memoized AppSidebar component for better performance
|
|
||||||
export const AppSidebar = memo(function AppSidebar({
|
|
||||||
searchSpaceId,
|
|
||||||
navMain = defaultData.navMain,
|
|
||||||
navSecondary = defaultData.navSecondary,
|
|
||||||
RecentChats = defaultData.RecentChats,
|
|
||||||
RecentNotes = defaultData.RecentNotes,
|
|
||||||
pageUsage,
|
|
||||||
onAddNote,
|
|
||||||
...props
|
|
||||||
}: AppSidebarProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const { theme, setTheme } = useTheme();
|
|
||||||
const { data: user, isPending: isLoadingUser } = useAtomValue(currentUserAtom);
|
|
||||||
const [isClient, setIsClient] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsClient(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Process navMain to resolve icon names to components
|
|
||||||
const processedNavMain = useMemo(() => {
|
|
||||||
return navMain.map((item) => ({
|
|
||||||
...item,
|
|
||||||
icon: iconMap[item.icon] || SquareTerminal,
|
|
||||||
}));
|
|
||||||
}, [navMain]);
|
|
||||||
|
|
||||||
// Process navSecondary to resolve icon names to components
|
|
||||||
const processedNavSecondary = useMemo(() => {
|
|
||||||
return navSecondary.map((item) => ({
|
|
||||||
...item,
|
|
||||||
icon: iconMap[item.icon] || Undo2,
|
|
||||||
}));
|
|
||||||
}, [navSecondary]);
|
|
||||||
|
|
||||||
// Process RecentChats to resolve icon names to components
|
|
||||||
const processedRecentChats = useMemo(() => {
|
|
||||||
return (
|
|
||||||
RecentChats?.map((item) => ({
|
|
||||||
...item,
|
|
||||||
icon: iconMap[item.icon] || MessageCircleMore,
|
|
||||||
})) || []
|
|
||||||
);
|
|
||||||
}, [RecentChats]);
|
|
||||||
|
|
||||||
// Process RecentNotes to resolve icon names to components
|
|
||||||
const processedRecentNotes = useMemo(() => {
|
|
||||||
return (
|
|
||||||
RecentNotes?.map((item) => ({
|
|
||||||
...item,
|
|
||||||
icon: iconMap[item.icon] || FileText,
|
|
||||||
})) || []
|
|
||||||
);
|
|
||||||
}, [RecentNotes]);
|
|
||||||
|
|
||||||
// Get user display name from email
|
|
||||||
const userDisplayName = user?.email ? user.email.split("@")[0] : "User";
|
|
||||||
const userEmail = user?.email || (isLoadingUser ? "Loading..." : "Unknown");
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
try {
|
|
||||||
// Track logout event and reset PostHog identity
|
|
||||||
trackLogout();
|
|
||||||
resetUser();
|
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
localStorage.removeItem("surfsense_bearer_token");
|
|
||||||
router.push("/");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error during logout:", error);
|
|
||||||
router.push("/");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sidebar variant="inset" collapsible="icon" aria-label="Main navigation" {...props}>
|
|
||||||
<SidebarHeader>
|
|
||||||
<SidebarMenu>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<SidebarMenuButton
|
|
||||||
size="lg"
|
|
||||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
|
||||||
>
|
|
||||||
<div className="flex aspect-square size-8 items-center justify-center">
|
|
||||||
{user?.email ? (
|
|
||||||
<UserAvatar email={user.email} size={32} />
|
|
||||||
) : (
|
|
||||||
<div className="size-8 rounded-lg bg-sidebar-primary animate-pulse" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
||||||
<span className="truncate font-medium">{userDisplayName}</span>
|
|
||||||
<span className="truncate text-xs text-muted-foreground">{userEmail}</span>
|
|
||||||
</div>
|
|
||||||
<ChevronsUpDown className="ml-auto size-4" />
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
sideOffset={4}
|
|
||||||
>
|
|
||||||
<DropdownMenuLabel className="p-0 font-normal">
|
|
||||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
|
||||||
<div className="flex aspect-square size-8 items-center justify-center">
|
|
||||||
{user?.email ? (
|
|
||||||
<UserAvatar email={user.email} size={32} />
|
|
||||||
) : (
|
|
||||||
<div className="size-8 rounded-lg bg-sidebar-primary animate-pulse" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
||||||
<span className="truncate font-medium">{userDisplayName}</span>
|
|
||||||
<span className="truncate text-xs text-muted-foreground">{userEmail}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
{searchSpaceId && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/settings`)}
|
|
||||||
>
|
|
||||||
<Settings2 className="mr-2 h-4 w-4" />
|
|
||||||
Settings
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/team`)}
|
|
||||||
>
|
|
||||||
<UserPlus className="mr-2 h-4 w-4" />
|
|
||||||
Invite members
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem onClick={() => router.push("/dashboard")}>
|
|
||||||
<ArrowLeftRight className="mr-2 h-4 w-4" />
|
|
||||||
Switch workspace
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
{isClient && (
|
|
||||||
<DropdownMenuItem onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
|
|
||||||
{theme === "dark" ? (
|
|
||||||
<SunIcon className="mr-2 h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<MoonIcon className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{theme === "dark" ? "Light mode" : "Dark mode"}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onClick={handleLogout}>
|
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
|
||||||
Logout
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarHeader>
|
|
||||||
|
|
||||||
<SidebarContent className="gap-1">
|
|
||||||
<NavMain items={processedNavMain} />
|
|
||||||
|
|
||||||
<NavChats chats={processedRecentChats} searchSpaceId={searchSpaceId} />
|
|
||||||
|
|
||||||
<NavNotes
|
|
||||||
notes={processedRecentNotes}
|
|
||||||
onAddNote={onAddNote}
|
|
||||||
searchSpaceId={searchSpaceId}
|
|
||||||
/>
|
|
||||||
</SidebarContent>
|
|
||||||
<SidebarFooter>
|
|
||||||
{pageUsage && (
|
|
||||||
<PageUsageDisplay pagesUsed={pageUsage.pagesUsed} pagesLimit={pageUsage.pagesLimit} />
|
|
||||||
)}
|
|
||||||
<NavSecondary items={processedNavSecondary} className="mt-auto" />
|
|
||||||
</SidebarFooter>
|
|
||||||
</Sidebar>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -1,237 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ChevronRight,
|
|
||||||
FolderOpen,
|
|
||||||
Loader2,
|
|
||||||
type LucideIcon,
|
|
||||||
MessageCircleMore,
|
|
||||||
MoreHorizontal,
|
|
||||||
RefreshCw,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
useSidebar,
|
|
||||||
} from "@/components/ui/sidebar";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { AllChatsSidebar } from "./all-chats-sidebar";
|
|
||||||
|
|
||||||
interface ChatAction {
|
|
||||||
name: string;
|
|
||||||
icon: string;
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatItem {
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
icon: LucideIcon;
|
|
||||||
id?: number;
|
|
||||||
search_space_id?: number;
|
|
||||||
actions?: ChatAction[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NavChatsProps {
|
|
||||||
chats: ChatItem[];
|
|
||||||
defaultOpen?: boolean;
|
|
||||||
searchSpaceId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map of icon names to their components
|
|
||||||
const actionIconMap: Record<string, LucideIcon> = {
|
|
||||||
MessageCircleMore,
|
|
||||||
Trash2,
|
|
||||||
MoreHorizontal,
|
|
||||||
RefreshCw,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function NavChats({ chats, defaultOpen = true, searchSpaceId }: NavChatsProps) {
|
|
||||||
const t = useTranslations("sidebar");
|
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname();
|
|
||||||
const { setOpenMobile } = useSidebar();
|
|
||||||
const [isDeleting, setIsDeleting] = useState<number | null>(null);
|
|
||||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
|
||||||
const [isAllChatsSidebarOpen, setIsAllChatsSidebarOpen] = useState(false);
|
|
||||||
|
|
||||||
// Handle chat deletion with loading state
|
|
||||||
const handleDeleteChat = useCallback(async (chatId: number, deleteAction: () => void) => {
|
|
||||||
setIsDeleting(chatId);
|
|
||||||
try {
|
|
||||||
await deleteAction();
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(null);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle chat navigation
|
|
||||||
const handleChatClick = useCallback(
|
|
||||||
(url: string) => {
|
|
||||||
router.push(url);
|
|
||||||
},
|
|
||||||
[router]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
|
||||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<div className="flex items-center group/header">
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<SidebarGroupLabel className="cursor-pointer rounded-md px-2 py-1.5 -mx-2 transition-colors flex items-center gap-1.5 flex-1">
|
|
||||||
<ChevronRight
|
|
||||||
className={cn(
|
|
||||||
"h-3.5 w-3.5 text-muted-foreground transition-all duration-200 shrink-0",
|
|
||||||
isOpen && "rotate-90"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span>{t("recent_chats") || "Recent Chats"}</span>
|
|
||||||
</SidebarGroupLabel>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
|
|
||||||
{/* Action buttons - always visible on hover */}
|
|
||||||
<div className="flex items-center gap-0.5 md:opacity-0 md:group-hover/header:opacity-100 transition-opacity pr-1">
|
|
||||||
{searchSpaceId && chats.length > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-5 w-5"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsAllChatsSidebarOpen(true);
|
|
||||||
}}
|
|
||||||
aria-label={t("view_all_chats") || "View all chats"}
|
|
||||||
>
|
|
||||||
<FolderOpen className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CollapsibleContent>
|
|
||||||
{chats.length > 0 ? (
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu>
|
|
||||||
{chats.map((chat) => {
|
|
||||||
const isDeletingChat = isDeleting === chat.id;
|
|
||||||
const isActive = pathname === chat.url;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem key={chat.id || chat.name} className="group/chat">
|
|
||||||
{/* Main navigation button */}
|
|
||||||
<SidebarMenuButton
|
|
||||||
onClick={() => handleChatClick(chat.url)}
|
|
||||||
disabled={isDeletingChat}
|
|
||||||
className={cn(
|
|
||||||
"pr-8", // Make room for the action button
|
|
||||||
isActive && "bg-sidebar-accent text-sidebar-accent-foreground",
|
|
||||||
isDeletingChat && "opacity-50"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<chat.icon className="h-4 w-4 shrink-0" />
|
|
||||||
<span className="truncate">{chat.name}</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
|
|
||||||
{/* Actions dropdown - positioned absolutely */}
|
|
||||||
{chat.actions && chat.actions.length > 0 && (
|
|
||||||
<div className="absolute right-1 top-1/2 -translate-y-1/2">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={cn(
|
|
||||||
"h-6 w-6",
|
|
||||||
"md:opacity-0 md:group-hover/chat:opacity-100 md:focus:opacity-100",
|
|
||||||
"data-[state=open]:opacity-100",
|
|
||||||
"transition-opacity"
|
|
||||||
)}
|
|
||||||
disabled={isDeletingChat}
|
|
||||||
>
|
|
||||||
{isDeletingChat ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
<span className="sr-only">
|
|
||||||
{t("more_options") || "More options"}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" side="right" className="w-40">
|
|
||||||
{chat.actions.map((action, actionIndex) => {
|
|
||||||
const ActionIcon = actionIconMap[action.icon] || MessageCircleMore;
|
|
||||||
const isDeleteAction = action.name.toLowerCase().includes("delete");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={`${action.name}-${actionIndex}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (isDeleteAction) {
|
|
||||||
handleDeleteChat(chat.id || 0, action.onClick);
|
|
||||||
} else {
|
|
||||||
action.onClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isDeletingChat}
|
|
||||||
className={
|
|
||||||
isDeleteAction
|
|
||||||
? "text-destructive focus:text-destructive"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ActionIcon className="mr-2 h-4 w-4" />
|
|
||||||
<span>
|
|
||||||
{isDeletingChat && isDeleteAction
|
|
||||||
? t("deleting") || "Deleting..."
|
|
||||||
: action.name}
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SidebarMenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2 px-2 py-1 text-muted-foreground/60 text-xs">
|
|
||||||
<MessageCircleMore className="h-3.5 w-3.5" />
|
|
||||||
<span>{t("no_recent_chats") || "No recent chats"}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
{/* All Chats Sheet */}
|
|
||||||
{searchSpaceId && (
|
|
||||||
<AllChatsSidebar
|
|
||||||
open={isAllChatsSidebarOpen}
|
|
||||||
onOpenChange={setIsAllChatsSidebarOpen}
|
|
||||||
searchSpaceId={searchSpaceId}
|
|
||||||
onCloseMobileSidebar={() => setOpenMobile(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SidebarGroup>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,207 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { ChevronRight, type LucideIcon } from "lucide-react";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
|
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
||||||
import {
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuAction,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
SidebarMenuSub,
|
|
||||||
SidebarMenuSubButton,
|
|
||||||
SidebarMenuSubItem,
|
|
||||||
} from "@/components/ui/sidebar";
|
|
||||||
|
|
||||||
interface NavItem {
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
icon: LucideIcon;
|
|
||||||
isActive?: boolean;
|
|
||||||
items?: {
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NavMainProps {
|
|
||||||
items: NavItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NavMain({ items }: NavMainProps) {
|
|
||||||
const t = useTranslations("nav_menu");
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
// Translation function that handles both exact matches and fallback to original
|
|
||||||
const translateTitle = (title: string): string => {
|
|
||||||
const titleMap: Record<string, string> = {
|
|
||||||
Researcher: "researcher",
|
|
||||||
"Manage LLMs": "manage_llms",
|
|
||||||
Sources: "sources",
|
|
||||||
"Manage Documents": "manage_documents",
|
|
||||||
"Manage Connectors": "manage_connectors",
|
|
||||||
Podcasts: "podcasts",
|
|
||||||
Logs: "logs",
|
|
||||||
Platform: "platform",
|
|
||||||
Team: "team",
|
|
||||||
};
|
|
||||||
|
|
||||||
const key = titleMap[title];
|
|
||||||
return key ? t(key) : title;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if an item is active based on pathname
|
|
||||||
const isItemActive = useCallback(
|
|
||||||
(item: NavItem): boolean => {
|
|
||||||
if (!pathname) return false;
|
|
||||||
|
|
||||||
// For items without sub-items, check if pathname matches or starts with the URL
|
|
||||||
if (!item.items?.length) {
|
|
||||||
// Chat item: active ONLY when on new-chat page without a specific chat ID
|
|
||||||
// (i.e., exactly /dashboard/{id}/new-chat, not /dashboard/{id}/new-chat/123)
|
|
||||||
if (item.url.includes("/new-chat")) {
|
|
||||||
// Match exactly the new-chat base URL (ends with /new-chat)
|
|
||||||
return pathname.endsWith("/new-chat");
|
|
||||||
}
|
|
||||||
// Logs item: active when on logs page
|
|
||||||
if (item.url.includes("/logs")) {
|
|
||||||
return pathname.includes("/logs");
|
|
||||||
}
|
|
||||||
// Check exact match or prefix match
|
|
||||||
return pathname === item.url || pathname.startsWith(`${item.url}/`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For items with sub-items (like Sources), check if any sub-item URL matches
|
|
||||||
return item.items.some(
|
|
||||||
(subItem) => pathname === subItem.url || pathname.startsWith(subItem.url)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[pathname]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize items to prevent unnecessary re-renders
|
|
||||||
const memoizedItems = useMemo(() => items, [items]);
|
|
||||||
|
|
||||||
// Track expanded state for items with sub-menus (like Sources)
|
|
||||||
const [expandedItems, setExpandedItems] = useState<Record<string, boolean>>(() => {
|
|
||||||
const initial: Record<string, boolean> = {};
|
|
||||||
items.forEach((item) => {
|
|
||||||
if (item.items?.length) {
|
|
||||||
initial[item.title] = item.isActive ?? false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return initial;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle collapsible state change
|
|
||||||
const handleOpenChange = useCallback((title: string, isOpen: boolean) => {
|
|
||||||
setExpandedItems((prev) => ({ ...prev, [title]: isOpen }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarGroup>
|
|
||||||
<SidebarGroupLabel>{translateTitle("Platform")}</SidebarGroupLabel>
|
|
||||||
<SidebarMenu>
|
|
||||||
{memoizedItems.map((item, index) => {
|
|
||||||
const translatedTitle = translateTitle(item.title);
|
|
||||||
const hasSub = !!item.items?.length;
|
|
||||||
const isActive = isItemActive(item);
|
|
||||||
const isItemOpen = expandedItems[item.title] ?? isActive ?? false;
|
|
||||||
return (
|
|
||||||
<Collapsible
|
|
||||||
key={`${item.title}-${index}`}
|
|
||||||
asChild
|
|
||||||
open={hasSub ? isItemOpen : undefined}
|
|
||||||
onOpenChange={hasSub ? (open) => handleOpenChange(item.title, open) : undefined}
|
|
||||||
defaultOpen={!hasSub ? isActive : undefined}
|
|
||||||
>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
{hasSub ? (
|
|
||||||
// When the item has children, make the whole row a collapsible trigger
|
|
||||||
<>
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<SidebarMenuButton
|
|
||||||
asChild
|
|
||||||
tooltip={translatedTitle}
|
|
||||||
isActive={isActive}
|
|
||||||
aria-label={`${translatedTitle} with submenu`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex items-center gap-2 w-full text-left"
|
|
||||||
{...(item.title === "Sources" ? { "data-joyride": "sources-menu" } : {})}
|
|
||||||
>
|
|
||||||
<item.icon />
|
|
||||||
<span>{translatedTitle}</span>
|
|
||||||
</button>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<SidebarMenuAction
|
|
||||||
className="data-[state=open]:rotate-90 transition-transform duration-200"
|
|
||||||
aria-label={`Toggle ${translatedTitle} submenu`}
|
|
||||||
>
|
|
||||||
<ChevronRight />
|
|
||||||
<span className="sr-only">Toggle submenu</span>
|
|
||||||
</SidebarMenuAction>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
|
|
||||||
<CollapsibleContent className="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 duration-200">
|
|
||||||
<SidebarMenuSub>
|
|
||||||
{item.items?.map((subItem, subIndex) => {
|
|
||||||
const translatedSubTitle = translateTitle(subItem.title);
|
|
||||||
const isDocumentsLink =
|
|
||||||
subItem.title === "Manage Documents" ||
|
|
||||||
translatedSubTitle.toLowerCase().includes("documents");
|
|
||||||
return (
|
|
||||||
<SidebarMenuSubItem key={`${subItem.title}-${subIndex}`}>
|
|
||||||
<SidebarMenuSubButton asChild aria-label={translatedSubTitle}>
|
|
||||||
<a
|
|
||||||
href={subItem.url}
|
|
||||||
{...(isDocumentsLink
|
|
||||||
? { "data-joyride": "documents-sidebar" }
|
|
||||||
: {})}
|
|
||||||
>
|
|
||||||
<span>{translatedSubTitle}</span>
|
|
||||||
</a>
|
|
||||||
</SidebarMenuSubButton>
|
|
||||||
</SidebarMenuSubItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SidebarMenuSub>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
// Leaf item: treat as a normal link
|
|
||||||
<SidebarMenuButton
|
|
||||||
asChild
|
|
||||||
tooltip={translatedTitle}
|
|
||||||
isActive={isActive}
|
|
||||||
aria-label={translatedTitle}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={item.url}
|
|
||||||
{...(item.title === "Documents" ||
|
|
||||||
translatedTitle.toLowerCase() === "documents"
|
|
||||||
? { "data-joyride": "documents-sidebar" }
|
|
||||||
: {})}
|
|
||||||
>
|
|
||||||
<item.icon />
|
|
||||||
<span>{translatedTitle}</span>
|
|
||||||
</a>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
)}
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</Collapsible>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroup>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,287 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ChevronRight,
|
|
||||||
FileText,
|
|
||||||
FolderOpen,
|
|
||||||
Loader2,
|
|
||||||
type LucideIcon,
|
|
||||||
MoreHorizontal,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
useSidebar,
|
|
||||||
} from "@/components/ui/sidebar";
|
|
||||||
import { useLogsSummary } from "@/hooks/use-logs";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { AllNotesSidebar } from "./all-notes-sidebar";
|
|
||||||
|
|
||||||
interface NoteAction {
|
|
||||||
name: string;
|
|
||||||
icon: string;
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NoteItem {
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
icon: LucideIcon;
|
|
||||||
id?: number;
|
|
||||||
search_space_id?: number;
|
|
||||||
actions?: NoteAction[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NavNotesProps {
|
|
||||||
notes: NoteItem[];
|
|
||||||
onAddNote?: () => void;
|
|
||||||
defaultOpen?: boolean;
|
|
||||||
searchSpaceId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map of icon names to their components
|
|
||||||
const actionIconMap: Record<string, LucideIcon> = {
|
|
||||||
FileText,
|
|
||||||
Trash2,
|
|
||||||
MoreHorizontal,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId }: NavNotesProps) {
|
|
||||||
const t = useTranslations("sidebar");
|
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname();
|
|
||||||
const { setOpenMobile } = useSidebar();
|
|
||||||
const [isDeleting, setIsDeleting] = useState<number | null>(null);
|
|
||||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
|
||||||
const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false);
|
|
||||||
|
|
||||||
// Poll for active reindexing tasks to show inline loading indicators
|
|
||||||
// Smart polling: only polls when there are active tasks, stops when idle
|
|
||||||
const { summary } = useLogsSummary(searchSpaceId ? Number(searchSpaceId) : 0, 24, {
|
|
||||||
enablePolling: true,
|
|
||||||
refetchInterval: 5000, // Poll every 5 seconds when tasks are active
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a Set of document IDs that are currently being reindexed
|
|
||||||
const reindexingDocumentIds = useMemo(() => {
|
|
||||||
if (!summary?.active_tasks) return new Set<number>();
|
|
||||||
return new Set(
|
|
||||||
summary.active_tasks
|
|
||||||
.filter((task) => task.document_id != null)
|
|
||||||
.map((task) => task.document_id as number)
|
|
||||||
);
|
|
||||||
}, [summary?.active_tasks]);
|
|
||||||
|
|
||||||
// Handle note deletion with loading state
|
|
||||||
const handleDeleteNote = useCallback(async (noteId: number, deleteAction: () => void) => {
|
|
||||||
setIsDeleting(noteId);
|
|
||||||
try {
|
|
||||||
await deleteAction();
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(null);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle note navigation
|
|
||||||
const handleNoteClick = useCallback(
|
|
||||||
(url: string) => {
|
|
||||||
router.push(url);
|
|
||||||
},
|
|
||||||
[router]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
|
||||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<div className="flex items-center group/header">
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<SidebarGroupLabel className="cursor-pointer rounded-md px-2 py-1.5 -mx-2 transition-colors flex items-center gap-1.5 flex-1">
|
|
||||||
<ChevronRight
|
|
||||||
className={cn(
|
|
||||||
"h-3.5 w-3.5 text-muted-foreground transition-all duration-200 shrink-0",
|
|
||||||
isOpen && "rotate-90"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span>{t("notes") || "Notes"}</span>
|
|
||||||
</SidebarGroupLabel>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
|
|
||||||
{/* Action buttons - always visible on hover */}
|
|
||||||
<div className="flex items-center gap-0.5 md:opacity-0 md:group-hover/header:opacity-100 transition-opacity pr-1">
|
|
||||||
{searchSpaceId && notes.length > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-5 w-5"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsAllNotesSidebarOpen(true);
|
|
||||||
}}
|
|
||||||
aria-label={t("view_all_notes") || "View all notes"}
|
|
||||||
>
|
|
||||||
<FolderOpen className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{onAddNote && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-5 w-5"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAddNote();
|
|
||||||
}}
|
|
||||||
aria-label={t("add_note") || "Add note"}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CollapsibleContent>
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu>
|
|
||||||
{notes.length > 0 ? (
|
|
||||||
notes.map((note) => {
|
|
||||||
const isDeletingNote = isDeleting === note.id;
|
|
||||||
const isActive = pathname === note.url;
|
|
||||||
const isReindexing = note.id ? reindexingDocumentIds.has(note.id) : false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem key={note.id || note.name} className="group/note">
|
|
||||||
{/* Main navigation button */}
|
|
||||||
<SidebarMenuButton
|
|
||||||
onClick={() => handleNoteClick(note.url)}
|
|
||||||
disabled={isDeletingNote}
|
|
||||||
className={cn(
|
|
||||||
"pr-8", // Make room for the action button
|
|
||||||
isActive && "bg-sidebar-accent text-sidebar-accent-foreground",
|
|
||||||
isDeletingNote && "opacity-50"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isReindexing ? (
|
|
||||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" />
|
|
||||||
) : (
|
|
||||||
<note.icon className="h-4 w-4 shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="truncate">{note.name}</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
|
|
||||||
{/* Actions dropdown - positioned absolutely */}
|
|
||||||
{note.actions && note.actions.length > 0 && (
|
|
||||||
<div className="absolute right-1 top-1/2 -translate-y-1/2">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={cn(
|
|
||||||
"h-6 w-6",
|
|
||||||
"md:opacity-0 md:group-hover/note:opacity-100 md:focus:opacity-100",
|
|
||||||
"data-[state=open]:opacity-100",
|
|
||||||
"transition-opacity"
|
|
||||||
)}
|
|
||||||
disabled={isDeletingNote}
|
|
||||||
>
|
|
||||||
{isDeletingNote ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
<span className="sr-only">
|
|
||||||
{t("more_options") || "More options"}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" side="right" className="w-40">
|
|
||||||
{note.actions.map((action, actionIndex) => {
|
|
||||||
const ActionIcon = actionIconMap[action.icon] || FileText;
|
|
||||||
const isDeleteAction = action.name.toLowerCase().includes("delete");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={`${action.name}-${actionIndex}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (isDeleteAction) {
|
|
||||||
handleDeleteNote(note.id || 0, action.onClick);
|
|
||||||
} else {
|
|
||||||
action.onClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isDeletingNote}
|
|
||||||
className={
|
|
||||||
isDeleteAction
|
|
||||||
? "text-destructive focus:text-destructive"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ActionIcon className="mr-2 h-4 w-4" />
|
|
||||||
<span>
|
|
||||||
{isDeletingNote && isDeleteAction
|
|
||||||
? t("deleting") || "Deleting..."
|
|
||||||
: action.name}
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SidebarMenuItem>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<SidebarMenuItem>
|
|
||||||
{onAddNote ? (
|
|
||||||
<SidebarMenuButton
|
|
||||||
onClick={onAddNote}
|
|
||||||
className="text-muted-foreground hover:text-sidebar-foreground text-xs"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
<span>{t("create_new_note") || "Create a new note"}</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
) : (
|
|
||||||
<SidebarMenuButton disabled className="text-muted-foreground text-xs">
|
|
||||||
<FileText className="h-4 w-4" />
|
|
||||||
<span>{t("no_notes") || "No notes yet"}</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
)}
|
|
||||||
</SidebarMenuItem>
|
|
||||||
)}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
{/* All Notes Sheet */}
|
|
||||||
{searchSpaceId && (
|
|
||||||
<AllNotesSidebar
|
|
||||||
open={isAllNotesSidebarOpen}
|
|
||||||
onOpenChange={setIsAllNotesSidebarOpen}
|
|
||||||
searchSpaceId={searchSpaceId}
|
|
||||||
onAddNote={onAddNote}
|
|
||||||
onCloseMobileSidebar={() => setOpenMobile(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SidebarGroup>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import type * as React from "react";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
} from "@/components/ui/sidebar";
|
|
||||||
|
|
||||||
interface NavSecondaryItem {
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
icon: LucideIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NavSecondary({
|
|
||||||
items,
|
|
||||||
...props
|
|
||||||
}: {
|
|
||||||
items: NavSecondaryItem[];
|
|
||||||
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
|
|
||||||
const t = useTranslations("sidebar");
|
|
||||||
|
|
||||||
// Memoize items to prevent unnecessary re-renders
|
|
||||||
const memoizedItems = useMemo(() => items, [items]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarGroup {...props}>
|
|
||||||
<SidebarGroupLabel>{t("search_space")}</SidebarGroupLabel>
|
|
||||||
<SidebarMenu>
|
|
||||||
{memoizedItems.map((item, index) => (
|
|
||||||
<SidebarMenuItem key={`${item.title}-${index}`}>
|
|
||||||
{item.url === "#" ? (
|
|
||||||
// Non-interactive display item (e.g., search space name)
|
|
||||||
<div className="flex h-7 w-full items-center gap-2 rounded-md px-2 text-xs text-sidebar-foreground">
|
|
||||||
<item.icon className="h-4 w-4 shrink-0" />
|
|
||||||
<span className="truncate">{item.title}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Interactive link item
|
|
||||||
<SidebarMenuButton asChild size="sm" aria-label={item.title}>
|
|
||||||
<a href={item.url}>
|
|
||||||
<item.icon />
|
|
||||||
<span>{item.title}</span>
|
|
||||||
</a>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
)}
|
|
||||||
</SidebarMenuItem>
|
|
||||||
))}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroup>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Mail } from "lucide-react";
|
|
||||||
import { Progress } from "@/components/ui/progress";
|
|
||||||
import {
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
useSidebar,
|
|
||||||
} from "@/components/ui/sidebar";
|
|
||||||
|
|
||||||
interface PageUsageDisplayProps {
|
|
||||||
pagesUsed: number;
|
|
||||||
pagesLimit: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) {
|
|
||||||
const { state } = useSidebar();
|
|
||||||
const usagePercentage = (pagesUsed / pagesLimit) * 100;
|
|
||||||
const isCollapsed = state === "collapsed";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarGroup>
|
|
||||||
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">
|
|
||||||
Page Usage
|
|
||||||
</SidebarGroupLabel>
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<div className="space-y-2 px-2 py-2">
|
|
||||||
{isCollapsed ? (
|
|
||||||
// Show only a compact progress indicator when collapsed
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<Progress value={usagePercentage} className="h-2 w-8" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Show full details when expanded
|
|
||||||
<>
|
|
||||||
<div className="flex justify-between items-center text-xs">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages
|
|
||||||
</span>
|
|
||||||
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={usagePercentage} className="h-2" />
|
|
||||||
<a
|
|
||||||
href="mailto:rohan@surfsense.com?subject=Request%20to%20Increase%20Page%20Limits"
|
|
||||||
className="flex items-center gap-1.5 text-[10px] text-muted-foreground hover:text-primary transition-colors pt-1"
|
|
||||||
>
|
|
||||||
<Mail className="h-3 w-3 flex-shrink-0" />
|
|
||||||
<span>Contact to increase limits</span>
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -622,7 +622,15 @@
|
||||||
"chat_archived": "Chat archived",
|
"chat_archived": "Chat archived",
|
||||||
"chat_unarchived": "Chat restored",
|
"chat_unarchived": "Chat restored",
|
||||||
"no_archived_chats": "No archived chats",
|
"no_archived_chats": "No archived chats",
|
||||||
"error_archiving_chat": "Failed to archive chat"
|
"error_archiving_chat": "Failed to archive chat",
|
||||||
|
"new_chat": "New chat",
|
||||||
|
"select_workspace": "Select Workspace",
|
||||||
|
"invite_members": "Invite members",
|
||||||
|
"workspace_settings": "Workspace settings",
|
||||||
|
"see_all_workspaces": "See all search spaces",
|
||||||
|
"expand_sidebar": "Expand sidebar",
|
||||||
|
"collapse_sidebar": "Collapse sidebar",
|
||||||
|
"logout": "Logout"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"something_went_wrong": "Something went wrong",
|
"something_went_wrong": "Something went wrong",
|
||||||
|
|
|
||||||
|
|
@ -616,7 +616,15 @@
|
||||||
"more_options": "更多选项",
|
"more_options": "更多选项",
|
||||||
"clear_search": "清除搜索",
|
"clear_search": "清除搜索",
|
||||||
"view_all_notes": "查看所有笔记",
|
"view_all_notes": "查看所有笔记",
|
||||||
"add_note": "添加笔记"
|
"add_note": "添加笔记",
|
||||||
|
"new_chat": "新对话",
|
||||||
|
"select_workspace": "选择工作空间",
|
||||||
|
"invite_members": "邀请成员",
|
||||||
|
"workspace_settings": "工作空间设置",
|
||||||
|
"see_all_workspaces": "查看所有搜索空间",
|
||||||
|
"expand_sidebar": "展开侧边栏",
|
||||||
|
"collapse_sidebar": "收起侧边栏",
|
||||||
|
"logout": "退出登录"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"something_went_wrong": "出错了",
|
"something_went_wrong": "出错了",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue