diff --git a/surfsense_web/app/dashboard/page.tsx b/surfsense_web/app/dashboard/page.tsx index ad1c6ad9d..aa6709af3 100644 --- a/surfsense_web/app/dashboard/page.tsx +++ b/surfsense_web/app/dashboard/page.tsx @@ -1,32 +1,14 @@ "use client"; import { useAtomValue } from "jotai"; -import { AlertCircle, Loader2, Plus, Search, Trash2, UserCheck, Users } from "lucide-react"; -import { motion, type Variants } from "motion/react"; -import Image from "next/image"; -import Link from "next/link"; +import { AlertCircle, Loader2 } from "lucide-react"; +import { motion } from "motion/react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useEffect } from "react"; -import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; +import { useEffect, useState } from "react"; import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; -import { currentUserAtom } from "@/atoms/user/user-query.atoms"; -import { Logo } from "@/components/Logo"; -import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"; -import { UserDropdown } from "@/components/UserDropdown"; +import { CreateSearchSpaceDialog } from "@/components/layout"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, @@ -36,29 +18,11 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { Spotlight } from "@/components/ui/spotlight"; -import { Tilt } from "@/components/ui/tilt"; -/** - * Formats a date string into a readable format - * @param dateString - The date string to format - * @returns Formatted date string (e.g., "Jan 1, 2023") - */ -const formatDate = (dateString: string): string => { - return new Date(dateString).toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }); -}; - -/** - * Loading screen component with animation - */ -const LoadingScreen = () => { +function LoadingScreen() { const t = useTranslations("dashboard"); return ( -
+
{ @@ -84,23 +48,20 @@ const LoadingScreen = () => {
); -}; +} -/** - * Error screen component with animation - */ -const ErrorScreen = ({ message }: { message: string }) => { +function ErrorScreen({ message }: { message: string }) { const t = useTranslations("dashboard"); const router = useRouter(); return ( -
+
- +
@@ -109,7 +70,7 @@ const ErrorScreen = ({ message }: { message: string }) => { {t("something_wrong")} - + {t("error_details")} {message} @@ -125,269 +86,42 @@ const ErrorScreen = ({ message }: { message: string }) => {
); -}; +} -const DashboardPage = () => { - const t = useTranslations("dashboard"); - const tCommon = useTranslations("common"); +export default function DashboardPage() { const router = useRouter(); - - // Animation variants - const containerVariants: Variants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: 0.1, - }, - }, - }; - - const itemVariants: Variants = { - hidden: { y: 20, opacity: 0 }, - visible: { - y: 0, - opacity: 1, - transition: { - type: "spring", - stiffness: 300, - damping: 24, - }, - }, - }; + const [showCreateDialog, setShowCreateDialog] = useState(false); const { data: searchSpaces = [], - isLoading: loading, + isLoading, error, - refetch: refreshSearchSpaces, } = useAtomValue(searchSpacesAtom); - const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom); - const { data: user, isPending: isLoadingUser, error: userError } = useAtomValue(currentUserAtom); - - // Auto-redirect to chat for users with exactly 1 search space useEffect(() => { - if (loading) return; + if (isLoading) return; - if (searchSpaces.length === 1) { + if (searchSpaces.length === 0) { + setShowCreateDialog(true); + } else { router.replace(`/dashboard/${searchSpaces[0].id}/new-chat`); } - }, [loading, searchSpaces, router]); + }, [isLoading, searchSpaces, router]); - // Create user object for UserDropdown - const customUser = { - name: user?.email ? user.email.split("@")[0] : "User", - email: - user?.email || - (isLoadingUser ? "Loading..." : userError ? "Error loading user" : "Unknown User"), - avatar: "/icon-128.svg", // Default avatar + const handleDialogChange = (open: boolean) => { + setShowCreateDialog(open); }; - // Show loading while loading or auto-redirecting (single search space) - if (loading || (searchSpaces.length === 1 && !error)) return ; + if (isLoading) return ; if (error) return ; - const handleDeleteSearchSpace = async (id: number) => { - await deleteSearchSpace({ id }); - refreshSearchSpaces(); - }; + if (searchSpaces.length > 0) { + return ; + } return ( - - -
-
- -
-

{t("surfsense_dashboard")}

-

{t("welcome_message")}

-
-
-
- - -
-
- -
-
-

{t("your_search_spaces")}

- - - - - -
- -
- {searchSpaces && - searchSpaces.length > 0 && - searchSpaces.map((space) => ( - - - -
-
- - {space.name} -
- -
-
- - - - - - - {t("delete_search_space")} - - {t("delete_space_confirm", { name: space.name })} - - - - {tCommon("cancel")} - handleDeleteSearchSpace(space.id)} - className="bg-destructive hover:bg-destructive/90" - > - {tCommon("delete")} - - - - -
-
-
- -
-
-
-

{space.name}

- {!space.is_owner && ( - - {t("shared")} - - )} -
-

- {space.description} -

-
-
- - {t("created")} {formatDate(space.created_at)} - -
- {space.is_owner ? ( - - ) : ( - - )} - {space.member_count} -
-
-
- -
- - - ))} - - {searchSpaces.length === 0 && ( - -
- -
-

{t("no_spaces_found")}

-

- {t("create_first_space")} -

- - - -
- )} - - {searchSpaces.length > 0 && ( - - - -
- - - {t("add_new_search_space")} - -
- -
-
- )} -
-
- - +
+ +
); -}; - -export default DashboardPage; +} diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index b54f2b2fd..27c3a227c 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -8,6 +8,7 @@ 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 { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.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"; @@ -26,7 +27,9 @@ 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, SearchSpace } from "../types/layout.types"; +import { CreateSearchSpaceDialog } from "../ui/dialogs"; import { LayoutShell } from "../ui/shell"; +import { AllSearchSpacesSheet } from "../ui/sheets"; import { AllChatsSidebar } from "../ui/sidebar/AllChatsSidebar"; import { AllNotesSidebar } from "../ui/sidebar/AllNotesSidebar"; @@ -53,7 +56,8 @@ export function LayoutDataProvider({ // Atoms const { data: user } = useAtomValue(currentUserAtom); - const { data: searchSpacesData } = useAtomValue(searchSpacesAtom); + const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom); + const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom); const hasUnsavedEditorChanges = useAtomValue(hasUnsavedEditorChangesAtom); const setPendingNavigation = useSetAtom(pendingEditorNavigationAtom); @@ -110,6 +114,10 @@ export function LayoutDataProvider({ const [isAllChatsSidebarOpen, setIsAllChatsSidebarOpen] = useState(false); const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false); + // Search space sheet and dialog state + const [isAllSearchSpacesSheetOpen, setIsAllSearchSpacesSheetOpen] = useState(false); + const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false); + // Delete dialogs state const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false); const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); @@ -123,7 +131,6 @@ export function LayoutDataProvider({ } | null>(null); const [isDeletingNote, setIsDeletingNote] = useState(false); - // Transform search spaces (API returns array directly, not { items: [...] }) const searchSpaces: SearchSpace[] = useMemo(() => { if (!searchSpacesData || !Array.isArray(searchSpacesData)) return []; return searchSpacesData.map((space) => ({ @@ -132,6 +139,7 @@ export function LayoutDataProvider({ description: space.description, isOwner: space.is_owner, memberCount: space.member_count || 0, + createdAt: space.created_at, })); }, [searchSpacesData]); @@ -204,12 +212,35 @@ export function LayoutDataProvider({ ); const handleAddSearchSpace = useCallback(() => { - router.push("/dashboard/searchspaces"); - }, [router]); + setIsCreateSearchSpaceDialogOpen(true); + }, []); const handleSeeAllSearchSpaces = useCallback(() => { - router.push("/dashboard"); - }, [router]); + setIsAllSearchSpacesSheetOpen(true); + }, []); + + const handleSearchSpaceSettings = useCallback( + (id: number) => { + router.push(`/dashboard/${id}/settings`); + }, + [router] + ); + + const handleDeleteSearchSpace = useCallback( + async (id: number) => { + await deleteSearchSpace({ id }); + refetchSearchSpaces(); + if (Number(searchSpaceId) === id && searchSpaces.length > 1) { + const remaining = searchSpaces.filter((s) => s.id !== id); + if (remaining.length > 0) { + router.push(`/dashboard/${remaining[0].id}/new-chat`); + } + } else if (searchSpaces.length === 1) { + router.push("/dashboard"); + } + }, + [deleteSearchSpace, refetchSearchSpaces, searchSpaceId, searchSpaces, router] + ); const handleNavItemClick = useCallback( (item: NavItem) => { @@ -439,6 +470,26 @@ export function LayoutDataProvider({ onAddNote={handleAddNote} /> + {/* All Search Spaces Sheet */} + { + setIsAllSearchSpacesSheetOpen(false); + setIsCreateSearchSpaceDialogOpen(true); + }} + onSettings={handleSearchSpaceSettings} + onDelete={handleDeleteSearchSpace} + /> + + {/* Create Search Space Dialog */} + + {/* Delete Note Dialog */} diff --git a/surfsense_web/components/layout/types/layout.types.ts b/surfsense_web/components/layout/types/layout.types.ts index 67ac8172e..34598b43e 100644 --- a/surfsense_web/components/layout/types/layout.types.ts +++ b/surfsense_web/components/layout/types/layout.types.ts @@ -6,6 +6,7 @@ export interface SearchSpace { description?: string | null; isOwner: boolean; memberCount: number; + createdAt?: string; } export interface User { diff --git a/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx b/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx index 29c1b8791..f91dda83a 100644 --- a/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx +++ b/surfsense_web/components/layout/ui/sheets/AllSearchSpacesSheet.tsx @@ -1,9 +1,27 @@ "use client"; -import { Crown, Search, Users } from "lucide-react"; +import { Calendar, MoreHorizontal, Search, Settings, Share2, Trash2, UserCheck, Users } from "lucide-react"; import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Sheet, SheetContent, @@ -11,120 +29,208 @@ import { SheetHeader, SheetTitle, } from "@/components/ui/sheet"; -import { cn } from "@/lib/utils"; import type { SearchSpace } from "../../types/layout.types"; +function formatDate(dateString: string): string { + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); +} + interface AllSearchSpacesSheetProps { open: boolean; onOpenChange: (open: boolean) => void; searchSpaces: SearchSpace[]; - activeSearchSpaceId: number | null; onSearchSpaceSelect: (id: number) => void; onCreateNew?: () => void; + onSettings?: (id: number) => void; + onDelete?: (id: number) => void; } export function AllSearchSpacesSheet({ open, onOpenChange, searchSpaces, - activeSearchSpaceId, onSearchSpaceSelect, onCreateNew, + onSettings, + onDelete, }: AllSearchSpacesSheetProps) { const t = useTranslations("searchSpace"); const tCommon = useTranslations("common"); + const [spaceToDelete, setSpaceToDelete] = useState(null); + const handleSelect = (id: number) => { onSearchSpaceSelect(id); onOpenChange(false); }; - return ( - - - -
-
- -
-
- {t("all_search_spaces")} - - {t("search_spaces_count", { count: searchSpaces.length })} - -
-
-
+ const handleSettings = (e: React.MouseEvent, space: SearchSpace) => { + e.stopPropagation(); + onOpenChange(false); + onSettings?.(space.id); + }; -
- {searchSpaces.length === 0 ? ( -
-
- + const handleDeleteClick = (e: React.MouseEvent, space: SearchSpace) => { + e.stopPropagation(); + setSpaceToDelete(space); + }; + + const confirmDelete = () => { + if (spaceToDelete) { + onDelete?.(spaceToDelete.id); + setSpaceToDelete(null); + } + }; + + return ( + <> + + + +
+
+
-
-

{t("no_search_spaces")}

-

- {t("create_first_search_space")} -

+
+ {t("all_search_spaces")} + + {t("search_spaces_count", { count: searchSpaces.length })} +
- {onCreateNew && ( - - )}
- ) : ( - searchSpaces.map((space) => ( - )} - > -
-
- - {space.name} +
+ ) : ( + searchSpaces.map((space) => ( + + + + handleSettings(e, space)}> + + {tCommon("settings")} + + + handleDeleteClick(e, space)} + className="text-destructive focus:text-destructive" + > + + {tCommon("delete")} + + + + )} +
+
+ +
+ + {space.isOwner ? ( + + ) : ( + + )} + {t("members_count", { count: space.memberCount })} - {space.description && ( - - {space.description} + {space.createdAt && ( + + + {formatDate(space.createdAt)} )}
- {space.isOwner && ( - - - {tCommon("owner")} - - )} -
-
- - - {t("members_count", { count: space.memberCount })} - -
- - )) - )} -
- - {searchSpaces.length > 0 && onCreateNew && ( -
- + + )) + )}
- )} - - + + {searchSpaces.length > 0 && onCreateNew && ( +
+ +
+ )} + + + + !open && setSpaceToDelete(null)}> + + + {t("delete_title")} + + {t("delete_confirm", { name: spaceToDelete?.name })} + + + + {tCommon("cancel")} + + {tCommon("delete")} + + + + + ); } - diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 2ab400102..65ff75978 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -29,7 +29,9 @@ "required": "Required", "optional": "Optional", "retry": "Retry", - "owner": "Owner" + "owner": "Owner", + "shared": "Shared", + "settings": "Settings" }, "auth": { "login": "Login", @@ -92,7 +94,9 @@ "no_search_spaces": "No search spaces yet", "create_first_search_space": "Create your first search space to get started", "members_count": "{count, plural, =1 {1 member} other {# members}}", - "create_new_search_space": "Create new search space" + "create_new_search_space": "Create new search space", + "delete_title": "Delete Search Space", + "delete_confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone and will permanently remove all data." }, "dashboard": { "title": "Dashboard", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index bcfeb1ef4..e6a61528b 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -29,7 +29,9 @@ "required": "必填", "optional": "可选", "retry": "重试", - "owner": "所有者" + "owner": "所有者", + "shared": "共享", + "settings": "设置" }, "auth": { "login": "登录", @@ -92,7 +94,9 @@ "no_search_spaces": "暂无搜索空间", "create_first_search_space": "创建您的第一个搜索空间以开始使用", "members_count": "{count, plural, other {# 位成员}}", - "create_new_search_space": "创建新的搜索空间" + "create_new_search_space": "创建新的搜索空间", + "delete_title": "删除搜索空间", + "delete_confirm": "您确定要删除「{name}」吗?此操作无法撤销,将永久删除所有数据。" }, "dashboard": { "title": "仪表盘",