Merge remote-tracking branch 'upstream/dev' into feat/config-connectors

This commit is contained in:
Anish Sarkar 2026-01-11 15:56:36 +05:30
commit 2037ce4ce5
65 changed files with 2636 additions and 1908 deletions

View file

@ -1,6 +1,6 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils";
import { trackLoginSuccess } from "@/lib/posthog/events";
@ -25,7 +25,6 @@ const TokenHandler = ({
tokenParamName = "token",
storageKey = "surfsense_bearer_token",
}: TokenHandlerProps) => {
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
@ -58,14 +57,14 @@ const TokenHandler = ({
const finalRedirectPath = savedRedirectPath || redirectPath;
// Redirect to the appropriate path
router.push(finalRedirectPath);
window.location.href = finalRedirectPath;
} catch (error) {
console.error("Error storing token in localStorage:", error);
// Even if there's an error, try to redirect to the default path
router.push(redirectPath);
window.location.href = redirectPath;
}
}
}, [searchParams, tokenParamName, storageKey, redirectPath, router]);
}, [searchParams, tokenParamName, storageKey, redirectPath]);
return (
<div className="flex items-center justify-center min-h-[200px]">

View file

@ -34,14 +34,14 @@ export function UserDropdown({
if (typeof window !== "undefined") {
localStorage.removeItem("surfsense_bearer_token");
router.push("/");
window.location.href = "/";
}
} catch (error) {
console.error("Error during logout:", error);
// Optionally, provide user feedback
if (typeof window !== "undefined") {
alert("Logout failed. Please try again.");
router.push("/");
window.location.href = "/";
}
}
};

View file

@ -1,6 +1,6 @@
"use client";
import { format, subDays, subYears } from "date-fns";
import { addDays, format, subDays, subYears } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
@ -14,6 +14,7 @@ interface DateRangeSelectorProps {
endDate: Date | undefined;
onStartDateChange: (date: Date | undefined) => void;
onEndDateChange: (date: Date | undefined) => void;
allowFutureDates?: boolean; // Allow future dates for calendar connectors
}
export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
@ -21,6 +22,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
endDate,
onStartDateChange,
onEndDateChange,
allowFutureDates = false,
}) => {
const handleLast30Days = () => {
const today = new Date();
@ -28,6 +30,12 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
onEndDateChange(today);
};
const handleNext30Days = () => {
const today = new Date();
onStartDateChange(today);
onEndDateChange(addDays(today, 30));
};
const handleLastYear = () => {
const today = new Date();
onStartDateChange(subYears(today, 1));
@ -43,8 +51,9 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<h3 className="font-medium text-sm sm:text-base mb-4">Select Date Range</h3>
<p className="text-xs sm:text-sm text-muted-foreground mb-6">
Choose how far back you want to sync your data. You can always re-index later with different
dates.
{allowFutureDates
? "Choose the date range to sync your data. You can select future dates to index upcoming events."
: "Choose how far back you want to sync your data. You can always re-index later with different dates."}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@ -72,7 +81,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
mode="single"
selected={startDate}
onSelect={onStartDateChange}
disabled={(date) => date > new Date()}
disabled={allowFutureDates ? false : (date) => date > new Date()}
/>
</PopoverContent>
</Popover>
@ -102,7 +111,11 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
mode="single"
selected={endDate}
onSelect={onEndDateChange}
disabled={(date) => date > new Date() || (startDate ? date < startDate : false)}
disabled={
allowFutureDates
? (date) => (startDate ? date < startDate : false)
: (date) => date > new Date() || (startDate ? date < startDate : false)
}
/>
</PopoverContent>
</Popover>
@ -129,6 +142,17 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
>
Last 30 Days
</Button>
{allowFutureDates && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleNext30Days}
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10"
>
Next 30 Days
</Button>
)}
<Button
type="button"
variant="outline"

View file

@ -173,6 +173,7 @@ export const LumaConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
allowFutureDates={true}
/>
{/* Periodic Sync Config */}

View file

@ -211,6 +211,10 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
endDate={endDate}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
allowFutureDates={
connector.connector_type === "GOOGLE_CALENDAR_CONNECTOR" ||
connector.connector_type === "LUMA_CONNECTOR"
}
/>
)}

View file

@ -159,6 +159,10 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
endDate={endDate}
onStartDateChange={onStartDateChange}
onEndDateChange={onEndDateChange}
allowFutureDates={
config.connectorType === "GOOGLE_CALENDAR_CONNECTOR" ||
config.connectorType === "LUMA_CONNECTOR"
}
/>
)}

View file

@ -80,6 +80,12 @@ export const useConnectorDialog = () => {
connectorTitle: string;
} | null>(null);
// Track if we came from accounts list when entering edit mode
const [cameFromAccountsList, setCameFromAccountsList] = useState<{
connectorType: string;
connectorTitle: string;
} | null>(null);
// Helper function to get frequency label
const getFrequencyLabel = useCallback((minutes: string): string => {
switch (minutes) {
@ -960,6 +966,14 @@ export const useConnectorDialog = () => {
return;
}
// Track if we came from accounts list view
// If viewingAccountsType matches this connector type, preserve it
if (viewingAccountsType && viewingAccountsType.connectorType === connector.connector_type) {
setCameFromAccountsList(viewingAccountsType);
} else {
setCameFromAccountsList(null);
}
// Track index with date range opened event
if (connector.is_indexable) {
trackIndexWithDateRangeOpened(
@ -989,7 +1003,7 @@ export const useConnectorDialog = () => {
url.searchParams.set("connectorId", connector.id.toString());
window.history.pushState({ modal: true }, "", url.toString());
},
[searchSpaceId]
[searchSpaceId, viewingAccountsType]
);
// Handle saving connector changes
@ -1252,13 +1266,30 @@ export const useConnectorDialog = () => {
// Handle going back from edit view
const handleBackFromEdit = useCallback(() => {
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("tab", "all");
url.searchParams.delete("view");
url.searchParams.delete("connectorId");
router.replace(url.pathname + url.search, { scroll: false });
}, [router]);
// If we came from accounts list view, go back there
if (cameFromAccountsList && editingConnector) {
// Restore accounts list view
setViewingAccountsType(cameFromAccountsList);
setCameFromAccountsList(null);
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "accounts");
url.searchParams.set("connectorType", cameFromAccountsList.connectorType);
url.searchParams.delete("connectorId");
router.replace(url.pathname + url.search, { scroll: false });
} else {
// Otherwise, go back to main connector popup
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("tab", "all");
url.searchParams.delete("view");
url.searchParams.delete("connectorId");
router.replace(url.pathname + url.search, { scroll: false });
}
setEditingConnector(null);
setConnectorName(null);
setConnectorConfig(null);
}, [router, cameFromAccountsList, editingConnector]);
// Handle dialog open/close
const handleOpenChange = useCallback(
@ -1289,6 +1320,7 @@ export const useConnectorDialog = () => {
setConnectorConfig(null);
setConnectingConnectorType(null);
setViewingAccountsType(null);
setCameFromAccountsList(null);
setStartDate(undefined);
setEndDate(undefined);
setPeriodicEnabled(false);

View file

@ -0,0 +1 @@
export { useSidebarState } from "./useSidebarState";

View 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,
};
}

View 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";

View 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>
</>
);
}

View file

@ -0,0 +1 @@
export { LayoutDataProvider } from "./LayoutDataProvider";

View 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;
}

View 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>
);
}

View file

@ -0,0 +1 @@
export { Header } from "./Header";

View 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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View file

@ -0,0 +1,3 @@
export { IconRail } from "./IconRail";
export { NavIcon } from "./NavIcon";
export { WorkspaceAvatar } from "./WorkspaceAvatar";

View 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";

View 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>
);
}

View file

@ -0,0 +1 @@
export { LayoutShell } from "./LayoutShell";

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View 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";

View file

@ -407,7 +407,7 @@ export function OnboardingTour() {
// Fetch threads data
const { data: threadsData } = useQuery({
queryKey: ["threads", searchSpaceId],
queryKey: ["threads", searchSpaceId, { limit: 1 }],
queryFn: () => fetchThreads(Number(searchSpaceId), 1), // Only need to check if any exist
enabled: !!searchSpaceId,
});

View file

@ -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>
</>
);
}

View file

@ -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>
);
});

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}