Merge pull request #687 from CREDO23/enhance-new-UX

[Improvement] New Layout UX Enhancements
This commit is contained in:
Rohan Verma 2026-01-12 12:46:11 -08:00 committed by GitHub
commit 61f220b862
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1142 additions and 751 deletions

View file

@ -87,7 +87,8 @@ function SettingsSidebar({
<aside
className={cn(
"fixed md:relative left-0 top-0 z-50 md:z-auto",
"w-72 shrink-0 bg-background md:bg-muted/20 h-full flex flex-col",
"w-72 shrink-0 bg-background md:bg-muted/30 h-full flex flex-col",
"md:border-r",
"transition-transform duration-300 ease-out",
"md:translate-x-0",
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
@ -177,7 +178,7 @@ function SettingsSidebar({
{/* Footer */}
<div className="p-4">
<p className="text-xs text-muted-foreground text-center">SurfSense Settings</p>
<p className="text-xs text-muted-foreground text-center">Search Space Settings</p>
</div>
</aside>
</>
@ -286,20 +287,24 @@ export default function SettingsPage() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="h-full flex bg-background"
className="fixed inset-0 z-50 flex bg-muted/40"
>
<SettingsSidebar
activeSection={activeSection}
onSectionChange={setActiveSection}
onBackToApp={handleBackToApp}
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
/>
<SettingsContent
activeSection={activeSection}
searchSpaceId={searchSpaceId}
onMenuClick={() => setIsSidebarOpen(true)}
/>
<div className="flex h-full w-full p-0 md:p-2">
<div className="flex h-full w-full overflow-hidden bg-background md:rounded-xl md:border md:shadow-sm">
<SettingsSidebar
activeSection={activeSection}
onSectionChange={setActiveSection}
onBackToApp={handleBackToApp}
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
/>
<SettingsContent
activeSection={activeSection}
searchSpaceId={searchSpaceId}
onMenuClick={() => setIsSidebarOpen(true)}
/>
</div>
</div>
</motion.div>
);
}

View file

@ -3,7 +3,6 @@
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import {
ArrowLeft,
Calendar,
Check,
Clock,
@ -27,7 +26,7 @@ import {
Users,
} from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
@ -144,7 +143,6 @@ const cardVariants = {
};
export default function TeamManagementPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
const [activeTab, setActiveTab] = useState("members");
@ -334,14 +332,6 @@ export default function TeamManagementPage() {
<div className="space-y-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-start space-x-3 md:items-center md:space-x-4">
<button
onClick={() => router.push(`/dashboard/${searchSpaceId}`)}
className="flex items-center justify-center h-9 w-9 md:h-10 md:w-10 rounded-lg bg-primary/10 hover:bg-primary/20 transition-colors shrink-0"
aria-label="Back to Dashboard"
type="button"
>
<ArrowLeft className="h-4 w-4 md:h-5 md:w-5 text-primary" />
</button>
<div className="flex h-10 w-10 md:h-12 md:w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary/20 to-primary/5 ring-1 ring-primary/10 shrink-0">
<Users className="h-5 w-5 md:h-6 md:w-6 text-primary" />
</div>

View file

@ -1,185 +0,0 @@
"use client";
import { IconCheck, IconCopy, IconKey } from "@tabler/icons-react";
import { ArrowLeft } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useRouter } from "next/navigation";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useApiKey } from "@/hooks/use-api-key";
const fadeIn = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.4 } },
};
const staggerContainer = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const ApiKeyClient = () => {
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
const router = useRouter();
return (
<div className="flex justify-center w-full min-h-screen py-10 px-4">
<motion.div
className="w-full max-w-3xl"
initial="hidden"
animate="visible"
variants={staggerContainer}
>
<motion.div className="mb-8 text-center" variants={fadeIn}>
<h1 className="text-3xl font-bold tracking-tight">API Key</h1>
<p className="text-muted-foreground mt-2">
Your API key for authenticating with the SurfSense API.
</p>
</motion.div>
<motion.div variants={fadeIn}>
<Alert className="mb-8">
<IconKey className="h-4 w-4" />
<AlertTitle>Important</AlertTitle>
<AlertDescription>
Your API key grants full access to your account. Never share it publicly or with
unauthorized users.
</AlertDescription>
</Alert>
</motion.div>
<motion.div variants={fadeIn}>
<Card>
<CardHeader className="text-center">
<CardTitle>Your API Key</CardTitle>
<CardDescription>Use this key to authenticate your API requests.</CardDescription>
</CardHeader>
<CardContent>
<AnimatePresence mode="wait">
{isLoading ? (
<motion.div
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="h-10 w-full bg-muted animate-pulse rounded-md"
/>
) : apiKey ? (
<motion.div
key="api-key"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="flex items-center space-x-2"
>
<div className="bg-muted p-3 rounded-md flex-1 font-mono text-sm overflow-x-auto whitespace-nowrap">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
>
{apiKey}
</motion.div>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={copyToClipboard}
className="flex-shrink-0"
>
<motion.div
whileTap={{ scale: 0.9 }}
animate={copied ? { scale: [1, 1.2, 1] } : {}}
transition={{ duration: 0.2 }}
>
{copied ? (
<IconCheck className="h-4 w-4" />
) : (
<IconCopy className="h-4 w-4" />
)}
</motion.div>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{copied ? "Copied!" : "Copy to clipboard"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</motion.div>
) : (
<motion.div
key="no-key"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="text-muted-foreground text-center"
>
No API key found.
</motion.div>
)}
</AnimatePresence>
</CardContent>
</Card>
</motion.div>
<motion.div
className="mt-8"
variants={fadeIn}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<h2 className="text-xl font-semibold mb-4 text-center">How to use your API key</h2>
<Card>
<CardContent className="pt-6">
<motion.div
className="space-y-4"
initial="hidden"
animate="visible"
variants={staggerContainer}
>
<motion.div variants={fadeIn}>
<h3 className="font-medium mb-2 text-center">Authentication</h3>
<p className="text-sm text-muted-foreground text-center">
Include your API key in the Authorization header of your requests:
</p>
<motion.pre
className="bg-muted p-3 rounded-md mt-2 overflow-x-auto"
whileHover={{ scale: 1.01 }}
transition={{ type: "spring", stiffness: 400, damping: 10 }}
>
<code className="text-xs">
Authorization: Bearer {apiKey || "YOUR_API_KEY"}
</code>
</motion.pre>
</motion.div>
</motion.div>
</CardContent>
</Card>
</motion.div>
</motion.div>
<div>
<button
onClick={() => router.push("/dashboard")}
className="flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 hover:bg-primary/30 transition-colors"
aria-label="Back to Dashboard"
type="button"
>
<ArrowLeft className="h-5 w-5 text-primary" />
</button>
</div>
</div>
);
};
export default ApiKeyClient;

View file

@ -1,32 +0,0 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
// Loading component with animation
const LoadingComponent = () => (
<div className="flex flex-col justify-center items-center min-h-screen">
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mb-4"></div>
<p className="text-muted-foreground">Loading API Key Management...</p>
</div>
);
// Dynamically import the ApiKeyClient component
const ApiKeyClient = dynamic(() => import("./api-key-client"), {
ssr: false,
loading: () => <LoadingComponent />,
});
export default function ClientWrapper() {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return <LoadingComponent />;
}
return <ApiKeyClient />;
}

View file

@ -1,7 +0,0 @@
"use client";
import ClientWrapper from "./client-wrapper";
export default function ApiKeyPage() {
return <ClientWrapper />;
}

View file

@ -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, Plus, Search } 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 (
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
<div className="flex min-h-screen flex-col items-center justify-center gap-4">
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
@ -72,7 +36,7 @@ const LoadingScreen = () => {
<CardContent className="flex justify-center py-6">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }}
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY, ease: "linear" }}
>
<Loader2 className="h-12 w-12 text-primary" />
</motion.div>
@ -84,23 +48,20 @@ const LoadingScreen = () => {
</motion.div>
</div>
);
};
}
/**
* Error screen component with animation
*/
const ErrorScreen = ({ message }: { message: string }) => {
function ErrorScreen({ message }: { message: string }) {
const t = useTranslations("dashboard");
const router = useRouter();
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
<div className="flex min-h-screen flex-col items-center justify-center gap-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Card className="w-full max-w-[400px] bg-background/60 backdrop-blur-sm border-destructive/20">
<Card className="w-full max-w-[400px] border-destructive/20 bg-background/60 backdrop-blur-sm">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-destructive" />
@ -109,7 +70,7 @@ const ErrorScreen = ({ message }: { message: string }) => {
<CardDescription>{t("something_wrong")}</CardDescription>
</CardHeader>
<CardContent>
<Alert variant="destructive" className="bg-destructive/10 border-destructive/30">
<Alert variant="destructive" className="border-destructive/30 bg-destructive/10">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t("error_details")}</AlertTitle>
<AlertDescription className="mt-2">{message}</AlertDescription>
@ -125,269 +86,68 @@ const ErrorScreen = ({ message }: { message: string }) => {
</motion.div>
</div>
);
};
}
const DashboardPage = () => {
const t = useTranslations("dashboard");
const tCommon = useTranslations("common");
function EmptyState({ onCreateClick }: { onCreateClick: () => void }) {
const t = useTranslations("searchSpace");
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="flex flex-col items-center gap-6 text-center"
>
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary/10">
<Search className="h-10 w-10 text-primary" />
</div>
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-bold">{t("welcome_title")}</h1>
<p className="max-w-md text-muted-foreground">
{t("welcome_description")}
</p>
</div>
<Button size="lg" onClick={onCreateClick} className="gap-2">
<Plus className="h-5 w-5" />
{t("create_first_button")}
</Button>
</motion.div>
</div>
);
}
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) {
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
};
// Show loading while loading or auto-redirecting (single search space)
if (loading || (searchSpaces.length === 1 && !error)) return <LoadingScreen />;
if (isLoading) return <LoadingScreen />;
if (error) return <ErrorScreen message={error?.message || "Failed to load search spaces"} />;
const handleDeleteSearchSpace = async (id: number) => {
await deleteSearchSpace({ id });
refreshSearchSpaces();
};
if (searchSpaces.length > 0) {
return <LoadingScreen />;
}
return (
<motion.div
className="container mx-auto py-6 md:py-10 px-4"
initial="hidden"
animate="visible"
variants={containerVariants}
>
<motion.div className="flex flex-col space-y-4 md:space-y-6" variants={itemVariants}>
<div className="flex flex-row items-center justify-between gap-2">
<div className="flex flex-row items-center md:space-x-4">
<Logo className="w-8 h-8 md:w-10 md:h-10 rounded-md shrink-0 hidden md:block" />
<div className="flex flex-col space-y-0.5 md:space-y-2">
<h1 className="text-xl md:text-4xl font-bold">{t("surfsense_dashboard")}</h1>
<p className="text-sm md:text-base text-muted-foreground">{t("welcome_message")}</p>
</div>
</div>
<div className="flex items-center space-x-2 md:space-x-3 shrink-0">
<UserDropdown user={customUser} />
<ThemeTogglerComponent />
</div>
</div>
<div className="flex flex-col space-y-6 mt-6">
<div className="flex justify-between items-center">
<h2 className="text-lg md:text-2xl font-semibold">{t("your_search_spaces")}</h2>
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<Link href="/dashboard/searchspaces">
<Button className="h-8 md:h-10 text-[11px] md:text-sm px-3 md:px-4">
<Plus className="mr-1 md:mr-2 h-3 w-3 md:h-4 md:w-4" />
{t("create_search_space")}
</Button>
</Link>
</motion.div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
{searchSpaces &&
searchSpaces.length > 0 &&
searchSpaces.map((space) => (
<motion.div key={space.id} variants={itemVariants} className="aspect-4/3">
<Tilt
rotationFactor={6}
isRevese
springOptions={{
stiffness: 26.7,
damping: 4.1,
mass: 0.2,
}}
className="group relative rounded-lg h-full"
>
<Spotlight
className="z-10 from-blue-500/20 via-blue-300/10 to-blue-200/5 blur-2xl"
size={248}
springOptions={{
stiffness: 26.7,
damping: 4.1,
mass: 0.2,
}}
/>
<div className="flex flex-col h-full justify-between overflow-hidden rounded-xl border bg-muted/30 backdrop-blur-sm transition-all hover:border-primary/50">
<div className="relative h-32 w-full overflow-hidden">
<Link href={`/dashboard/${space.id}/new-chat`} key={space.id}>
<Image
src="https://images.unsplash.com/photo-1519389950473-47ba0277781c?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1740&q=80"
alt={space.name}
className="h-full w-full object-cover grayscale duration-700 group-hover:grayscale-0"
width={248}
height={248}
/>
<div className="absolute inset-0 bg-gradient-to-t from-background/80 to-transparent" />
</Link>
<div className="absolute top-2 right-2">
<div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full bg-background/50 backdrop-blur-sm hover:bg-destructive/90 cursor-pointer"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("delete_search_space")}</AlertDialogTitle>
<AlertDialogDescription>
{t("delete_space_confirm", { name: space.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDeleteSearchSpace(space.id)}
className="bg-destructive hover:bg-destructive/90"
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
<Link
className="flex flex-1 flex-col p-4 cursor-pointer"
href={`/dashboard/${space.id}/new-chat`}
key={space.id}
>
<div className="flex flex-1 flex-col justify-between p-1">
<div>
<div className="flex items-center gap-2">
<h3 className="font-medium text-base md:text-lg">{space.name}</h3>
{!space.is_owner && (
<Badge
variant="secondary"
className="text-[10px] md:text-xs font-normal"
>
{t("shared")}
</Badge>
)}
</div>
<p className="mt-1 text-xs md:text-sm text-muted-foreground">
{space.description}
</p>
</div>
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
<span>
{t("created")} {formatDate(space.created_at)}
</span>
<div className="flex items-center gap-1">
{space.is_owner ? (
<UserCheck className="h-3.5 w-3.5" />
) : (
<Users className="h-3.5 w-3.5" />
)}
<span>{space.member_count}</span>
</div>
</div>
</div>
</Link>
</div>
</Tilt>
</motion.div>
))}
{searchSpaces.length === 0 && (
<motion.div
variants={itemVariants}
className="col-span-full flex flex-col items-center justify-center p-12 text-center"
>
<div className="rounded-full bg-muted/50 p-4 mb-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-base md:text-lg font-medium mb-2">{t("no_spaces_found")}</h3>
<p className="text-xs md:text-sm text-muted-foreground mb-6">
{t("create_first_space")}
</p>
<Link href="/dashboard/searchspaces">
<Button>
<Plus className="mr-2 h-4 w-4" />
{t("create_search_space")}
</Button>
</Link>
</motion.div>
)}
{searchSpaces.length > 0 && (
<motion.div variants={itemVariants} className="aspect-[4/3]">
<Tilt
rotationFactor={6}
isRevese
springOptions={{
stiffness: 26.7,
damping: 4.1,
mass: 0.2,
}}
className="group relative rounded-lg h-full"
>
<Link href="/dashboard/searchspaces" className="flex h-full">
<div className="flex flex-col items-center justify-center h-full w-full rounded-xl border border-dashed bg-muted/10 hover:border-primary/50 transition-colors">
<Plus className="h-8 w-8 md:h-10 md:w-10 mb-2 md:mb-3 text-muted-foreground" />
<span className="text-xs md:text-sm font-medium">
{t("add_new_search_space")}
</span>
</div>
</Link>
</Tilt>
</motion.div>
)}
</div>
</div>
</motion.div>
</motion.div>
<>
<EmptyState onCreateClick={() => setShowCreateDialog(true)} />
<CreateSearchSpaceDialog open={showCreateDialog} onOpenChange={setShowCreateDialog} />
</>
);
};
export default DashboardPage;
}

View file

@ -1,41 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { motion } from "motion/react";
import { useRouter } from "next/navigation";
import { createSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { SearchSpaceForm } from "@/components/search-space-form";
import { trackSearchSpaceCreated } from "@/lib/posthog/events";
export default function SearchSpacesPage() {
const router = useRouter();
const { mutateAsync: createSearchSpace } = useAtomValue(createSearchSpaceMutationAtom);
const handleCreateSearchSpace = async (data: { name: string; description?: string }) => {
const result = await createSearchSpace({
name: data.name,
description: data.description || "",
});
// Track search space creation
trackSearchSpaceCreated(result.id, data.name);
// Redirect to the newly created search space's onboarding
router.push(`/dashboard/${result.id}/onboard`);
return result;
};
return (
<motion.div
className="mx-auto max-w-5xl px-4 py-6 lg:py-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
>
<div className="mx-auto max-w-5xl">
<SearchSpaceForm onSubmit={handleCreateSearchSpace} />
</div>
</motion.div>
);
}

View file

@ -0,0 +1,328 @@
"use client";
import {
ArrowLeft,
Check,
ChevronRight,
Copy,
Key,
type LucideIcon,
Menu,
Shield,
X,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useApiKey } from "@/hooks/use-api-key";
import { cn } from "@/lib/utils";
interface SettingsNavItem {
id: string;
label: string;
description: string;
icon: LucideIcon;
}
function UserSettingsSidebar({
activeSection,
onSectionChange,
onBackToApp,
isOpen,
onClose,
navItems,
}: {
activeSection: string;
onSectionChange: (section: string) => void;
onBackToApp: () => void;
isOpen: boolean;
onClose: () => void;
navItems: SettingsNavItem[];
}) {
const t = useTranslations("userSettings");
const handleNavClick = (sectionId: string) => {
onSectionChange(sectionId);
onClose();
};
return (
<>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm md:hidden"
onClick={onClose}
/>
)}
</AnimatePresence>
<aside
className={cn(
"fixed left-0 top-0 z-50 md:relative md:z-auto",
"flex h-full w-72 shrink-0 flex-col bg-background md:bg-muted/30",
"md:border-r",
"transition-transform duration-300 ease-out",
"md:translate-x-0",
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)}
>
<div className="flex items-center justify-between p-4">
<Button
variant="ghost"
onClick={onBackToApp}
className="group h-11 flex-1 justify-start gap-3 px-3 hover:bg-muted"
>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 transition-colors group-hover:bg-primary/20">
<ArrowLeft className="h-4 w-4 text-primary" />
</div>
<span className="font-medium">{t("back_to_app")}</span>
</Button>
<Button variant="ghost" size="icon" onClick={onClose} className="h-9 w-9 md:hidden">
<X className="h-5 w-5" />
</Button>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-2">
{navItems.map((item, index) => {
const isActive = activeSection === item.id;
const Icon = item.icon;
return (
<motion.button
key={item.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 + index * 0.05, duration: 0.3 }}
onClick={() => handleNavClick(item.id)}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className={cn(
"relative flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition-all duration-200",
isActive ? "border border-border bg-muted shadow-sm" : "hover:bg-muted/60"
)}
>
{isActive && (
<motion.div
layoutId="userSettingsActiveIndicator"
className="absolute left-0 top-1/2 h-8 w-1 -translate-y-1/2 rounded-r-full bg-primary"
initial={false}
transition={{
type: "spring",
stiffness: 500,
damping: 35,
}}
/>
)}
<div
className={cn(
"flex h-9 w-9 items-center justify-center rounded-lg transition-colors",
isActive ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p
className={cn(
"truncate text-sm font-medium transition-colors",
isActive ? "text-foreground" : "text-muted-foreground"
)}
>
{item.label}
</p>
<p className="truncate text-xs text-muted-foreground/70">{item.description}</p>
</div>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 transition-all",
isActive
? "translate-x-0 text-primary opacity-100"
: "-translate-x-1 text-muted-foreground/40 opacity-0"
)}
/>
</motion.button>
);
})}
</nav>
<div className="p-4">
<p className="text-center text-xs text-muted-foreground">{t("footer")}</p>
</div>
</aside>
</>
);
}
function ApiKeyContent({ onMenuClick }: { onMenuClick: () => void }) {
const t = useTranslations("userSettings");
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.4 }}
className="h-full min-w-0 flex-1 overflow-hidden bg-background"
>
<div className="h-full overflow-y-auto">
<div className="mx-auto max-w-4xl p-4 md:p-6 lg:p-10">
<AnimatePresence mode="wait">
<motion.div
key="api-key-header"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="mb-6 md:mb-8"
>
<div className="flex items-center gap-3 md:gap-4">
<Button
variant="outline"
size="icon"
onClick={onMenuClick}
className="h-10 w-10 shrink-0 md:hidden"
>
<Menu className="h-5 w-5" />
</Button>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, duration: 0.3 }}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-primary/10 bg-gradient-to-br from-primary/20 to-primary/5 shadow-sm md:h-14 md:w-14 md:rounded-2xl"
>
<Key className="h-5 w-5 text-primary md:h-7 md:w-7" />
</motion.div>
<div className="min-w-0">
<h1 className="truncate text-lg font-bold tracking-tight md:text-2xl">
{t("api_key_title")}
</h1>
<p className="text-sm text-muted-foreground">{t("api_key_description")}</p>
</div>
</div>
</motion.div>
</AnimatePresence>
<AnimatePresence mode="wait">
<motion.div
key="api-key-content"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
className="space-y-6"
>
<Alert>
<Shield className="h-4 w-4" />
<AlertTitle>{t("api_key_warning_title")}</AlertTitle>
<AlertDescription>{t("api_key_warning_description")}</AlertDescription>
</Alert>
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 font-medium">{t("your_api_key")}</h3>
{isLoading ? (
<div className="h-12 w-full animate-pulse rounded-md bg-muted" />
) : apiKey ? (
<div className="flex items-center gap-2">
<div className="flex-1 overflow-x-auto rounded-md bg-muted p-3 font-mono text-sm">
{apiKey}
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={copyToClipboard}
className="shrink-0"
>
{copied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{copied ? t("copied") : t("copy")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : (
<p className="text-center text-muted-foreground">{t("no_api_key")}</p>
)}
</div>
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-2 font-medium">{t("usage_title")}</h3>
<p className="mb-4 text-sm text-muted-foreground">{t("usage_description")}</p>
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-sm">
<code>Authorization: Bearer {apiKey || "YOUR_API_KEY"}</code>
</pre>
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
</motion.div>
);
}
export default function UserSettingsPage() {
const t = useTranslations("userSettings");
const router = useRouter();
const [activeSection, setActiveSection] = useState("api-key");
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const navItems: SettingsNavItem[] = [
{
id: "api-key",
label: t("api_key_nav_label"),
description: t("api_key_nav_description"),
icon: Key,
},
];
const handleBackToApp = useCallback(() => {
router.back();
}, [router]);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="fixed inset-0 z-50 flex bg-muted/40"
>
<div className="flex h-full w-full p-0 md:p-2">
<div className="flex h-full w-full overflow-hidden bg-background md:rounded-xl md:border md:shadow-sm">
<UserSettingsSidebar
activeSection={activeSection}
onSectionChange={setActiveSection}
onBackToApp={handleBackToApp}
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
navItems={navItems}
/>
{activeSection === "api-key" && (
<ApiKeyContent onMenuClick={() => setIsSidebarOpen(true)} />
)}
</div>
</div>
</motion.div>
);
}

View file

@ -8,10 +8,12 @@ export type {
PageUsage,
SidebarSectionProps,
User,
Workspace,
SearchSpace,
} from "./types/layout.types";
export {
AllSearchSpacesSheet,
ChatListItem,
CreateSearchSpaceDialog,
Header,
IconRail,
LayoutShell,
@ -26,5 +28,5 @@ export {
SidebarHeader,
SidebarSection,
SidebarUserProfile,
WorkspaceAvatar,
SearchSpaceAvatar,
} from "./ui";

View file

@ -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";
@ -25,8 +26,10 @@ 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 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,8 +131,7 @@ export function LayoutDataProvider({
} | null>(null);
const [isDeletingNote, setIsDeletingNote] = useState(false);
// Transform workspaces (API returns array directly, not { items: [...] })
const workspaces: Workspace[] = useMemo(() => {
const searchSpaces: SearchSpace[] = useMemo(() => {
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
return searchSpacesData.map((space) => ({
id: space.id,
@ -132,19 +139,15 @@ export function LayoutDataProvider({
description: space.description,
isOwner: space.is_owner,
memberCount: space.member_count || 0,
createdAt: space.created_at,
}));
}, [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;
// Find active search space from list (has is_owner and member_count)
const activeSearchSpace: SearchSpace | null = useMemo(() => {
if (!searchSpaceId || !searchSpaces.length) return null;
return searchSpaces.find((s) => s.id === Number(searchSpaceId)) ?? null;
}, [searchSpaceId, searchSpaces]);
// Transform chats
const chats: ChatItem[] = useMemo(() => {
@ -196,20 +199,47 @@ export function LayoutDataProvider({
);
// Handlers
const handleWorkspaceSelect = useCallback(
const handleSearchSpaceSelect = useCallback(
(id: number) => {
router.push(`/dashboard/${id}/new-chat`);
},
[router]
);
const handleAddWorkspace = useCallback(() => {
router.push("/dashboard/searchspaces");
const handleAddSearchSpace = useCallback(() => {
setIsCreateSearchSpaceDialogOpen(true);
}, []);
const handleSeeAllSearchSpaces = useCallback(() => {
setIsAllSearchSpacesSheetOpen(true);
}, []);
const handleUserSettings = useCallback(() => {
router.push("/dashboard/user/settings");
}, [router]);
const handleSeeAllWorkspaces = useCallback(() => {
router.push("/dashboard");
}, [router]);
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) => {
@ -266,7 +296,7 @@ export function LayoutDataProvider({
router.push(`/dashboard/${searchSpaceId}/settings`);
}, [router, searchSpaceId]);
const handleInviteMembers = useCallback(() => {
const handleManageMembers = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/team`);
}, [router, searchSpaceId]);
@ -347,11 +377,11 @@ export function LayoutDataProvider({
return (
<>
<LayoutShell
workspaces={workspaces}
activeWorkspaceId={Number(searchSpaceId)}
onWorkspaceSelect={handleWorkspaceSelect}
onAddWorkspace={handleAddWorkspace}
workspace={activeWorkspace}
searchSpaces={searchSpaces}
activeSearchSpaceId={Number(searchSpaceId)}
onSearchSpaceSelect={handleSearchSpaceSelect}
onAddSearchSpace={handleAddSearchSpace}
searchSpace={activeSearchSpace}
navItems={navItems}
onNavItemClick={handleNavItemClick}
chats={chats}
@ -368,8 +398,9 @@ export function LayoutDataProvider({
onViewAllNotes={handleViewAllNotes}
user={{ email: user?.email || "", name: user?.email?.split("@")[0] }}
onSettings={handleSettings}
onInviteMembers={handleInviteMembers}
onSeeAllWorkspaces={handleSeeAllWorkspaces}
onManageMembers={handleManageMembers}
onSeeAllSearchSpaces={handleSeeAllSearchSpaces}
onUserSettings={handleUserSettings}
onLogout={handleLogout}
pageUsage={pageUsage}
breadcrumb={breadcrumb}
@ -439,6 +470,26 @@ export function LayoutDataProvider({
onAddNote={handleAddNote}
/>
{/* All Search Spaces Sheet */}
<AllSearchSpacesSheet
open={isAllSearchSpacesSheetOpen}
onOpenChange={setIsAllSearchSpacesSheetOpen}
searchSpaces={searchSpaces}
onSearchSpaceSelect={handleSearchSpaceSelect}
onCreateNew={() => {
setIsAllSearchSpacesSheetOpen(false);
setIsCreateSearchSpaceDialogOpen(true);
}}
onSettings={handleSearchSpaceSettings}
onDelete={handleDeleteSearchSpace}
/>
{/* Create Search Space Dialog */}
<CreateSearchSpaceDialog
open={isCreateSearchSpaceDialogOpen}
onOpenChange={setIsCreateSearchSpaceDialogOpen}
/>
{/* Delete Note Dialog */}
<Dialog open={showDeleteNoteDialog} onOpenChange={setShowDeleteNoteDialog}>
<DialogContent className="sm:max-w-md">

View file

@ -1,11 +1,12 @@
import type { LucideIcon } from "lucide-react";
export interface Workspace {
export interface SearchSpace {
id: number;
name: string;
description?: string | null;
isOwner: boolean;
memberCount: number;
createdAt?: string;
}
export interface User {
@ -42,15 +43,15 @@ export interface PageUsage {
}
export interface IconRailProps {
workspaces: Workspace[];
activeWorkspaceId: number | null;
onWorkspaceSelect: (id: number) => void;
onAddWorkspace: () => void;
searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void;
onAddSearchSpace: () => void;
className?: string;
}
export interface SidebarHeaderProps {
workspace: Workspace | null;
searchSpace: SearchSpace | null;
onSettings?: () => void;
}
@ -94,15 +95,15 @@ export interface SidebarUserProfileProps {
user: User;
searchSpaceId?: string;
onSettings?: () => void;
onInviteMembers?: () => void;
onSwitchWorkspace?: () => void;
onManageMembers?: () => void;
onSwitchSearchSpace?: () => void;
onToggleTheme?: () => void;
onLogout?: () => void;
theme?: string;
}
export interface SidebarProps {
workspace: Workspace | null;
searchSpace: SearchSpace | null;
searchSpaceId?: string;
navItems: NavItem[];
chats: ChatItem[];
@ -120,8 +121,8 @@ export interface SidebarProps {
user: User;
theme?: string;
onSettings?: () => void;
onInviteMembers?: () => void;
onSwitchWorkspace?: () => void;
onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
onToggleTheme?: () => void;
onLogout?: () => void;
pageUsage?: PageUsage;
@ -129,10 +130,10 @@ export interface SidebarProps {
}
export interface LayoutShellProps {
workspaces: Workspace[];
activeWorkspaceId: number | null;
onWorkspaceSelect: (id: number) => void;
onAddWorkspace: () => void;
searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void;
onAddSearchSpace: () => void;
sidebarProps: Omit<SidebarProps, "className">;
children: React.ReactNode;
className?: string;

View file

@ -0,0 +1,166 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { Loader2, Plus, Search } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { createSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { trackSearchSpaceCreated } from "@/lib/posthog/events";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
});
type FormValues = z.infer<typeof formSchema>;
interface CreateSearchSpaceDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpaceDialogProps) {
const t = useTranslations("searchSpace");
const tCommon = useTranslations("common");
const [isSubmitting, setIsSubmitting] = useState(false);
const { mutateAsync: createSearchSpace } = useAtomValue(createSearchSpaceMutationAtom);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
description: "",
},
});
const handleSubmit = async (values: FormValues) => {
setIsSubmitting(true);
try {
const result = await createSearchSpace({
name: values.name,
description: values.description || "",
});
trackSearchSpaceCreated(result.id, values.name);
// Hard redirect to ensure fresh state
window.location.href = `/dashboard/${result.id}/onboard`;
} catch (error) {
console.error("Failed to create search space:", error);
setIsSubmitting(false);
}
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
form.reset();
}
onOpenChange(newOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Search className="h-5 w-5 text-primary" />
</div>
<div>
<DialogTitle>{t("create_title")}</DialogTitle>
<DialogDescription>{t("create_description")}</DialogDescription>
</div>
</div>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="flex flex-col gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("name_label")}</FormLabel>
<FormControl>
<Input
placeholder={t("name_placeholder")}
{...field}
autoFocus
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("description_label")}{" "}
<span className="text-muted-foreground font-normal">
({tCommon("optional")})
</span>
</FormLabel>
<FormControl>
<Input placeholder={t("description_placeholder")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="flex gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
>
{tCommon("cancel")}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("creating")}
</>
) : (
<>
<Plus className="mr-2 h-4 w-4" />
{t("create_button")}
</>
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View file

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

View file

@ -5,34 +5,34 @@ 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";
import type { SearchSpace } from "../../types/layout.types";
import { SearchSpaceAvatar } from "./SearchSpaceAvatar";
interface IconRailProps {
workspaces: Workspace[];
activeWorkspaceId: number | null;
onWorkspaceSelect: (id: number) => void;
onAddWorkspace: () => void;
searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void;
onAddSearchSpace: () => void;
className?: string;
}
export function IconRail({
workspaces,
activeWorkspaceId,
onWorkspaceSelect,
onAddWorkspace,
searchSpaces,
activeSearchSpaceId,
onSearchSpaceSelect,
onAddSearchSpace,
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)}
{searchSpaces.map((searchSpace) => (
<SearchSpaceAvatar
key={searchSpace.id}
name={searchSpace.name}
isActive={searchSpace.id === activeSearchSpaceId}
onClick={() => onSearchSpaceSelect(searchSpace.id)}
size="md"
/>
))}
@ -42,15 +42,15 @@ export function IconRail({
<Button
variant="ghost"
size="icon"
onClick={onAddWorkspace}
onClick={onAddSearchSpace}
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>
<span className="sr-only">Add search space</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Add workspace
Add search space
</TooltipContent>
</Tooltip>
</div>

View file

@ -3,7 +3,7 @@
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
interface WorkspaceAvatarProps {
interface SearchSpaceAvatarProps {
name: string;
isActive?: boolean;
onClick?: () => void;
@ -11,7 +11,7 @@ interface WorkspaceAvatarProps {
}
/**
* Generates a consistent color based on workspace name
* Generates a consistent color based on search space name
*/
function stringToColor(str: string): string {
let hash = 0;
@ -32,7 +32,7 @@ function stringToColor(str: string): string {
}
/**
* Gets initials from workspace name (max 2 chars)
* Gets initials from search space name (max 2 chars)
*/
function getInitials(name: string): string {
const words = name.trim().split(/\s+/);
@ -42,7 +42,7 @@ function getInitials(name: string): string {
return name.slice(0, 2).toUpperCase();
}
export function WorkspaceAvatar({ name, isActive, onClick, size = "md" }: WorkspaceAvatarProps) {
export function SearchSpaceAvatar({ name, isActive, onClick, size = "md" }: SearchSpaceAvatarProps) {
const bgColor = stringToColor(name);
const initials = getInitials(name);
const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm";

View file

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

View file

@ -1,6 +1,8 @@
export { CreateSearchSpaceDialog } from "./dialogs";
export { Header } from "./header";
export { IconRail, NavIcon, WorkspaceAvatar } from "./icon-rail";
export { IconRail, NavIcon, SearchSpaceAvatar } from "./icon-rail";
export { LayoutShell } from "./shell";
export { AllSearchSpacesSheet } from "./sheets";
export {
ChatListItem,
MobileSidebar,

View file

@ -0,0 +1,236 @@
"use client";
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,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
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[];
onSearchSpaceSelect: (id: number) => void;
onCreateNew?: () => void;
onSettings?: (id: number) => void;
onDelete?: (id: number) => void;
}
export function AllSearchSpacesSheet({
open,
onOpenChange,
searchSpaces,
onSearchSpaceSelect,
onCreateNew,
onSettings,
onDelete,
}: AllSearchSpacesSheetProps) {
const t = useTranslations("searchSpace");
const tCommon = useTranslations("common");
const [spaceToDelete, setSpaceToDelete] = useState<SearchSpace | null>(null);
const handleSelect = (id: number) => {
onSearchSpaceSelect(id);
onOpenChange(false);
};
const handleSettings = (e: React.MouseEvent, space: SearchSpace) => {
e.stopPropagation();
onOpenChange(false);
onSettings?.(space.id);
};
const handleDeleteClick = (e: React.MouseEvent, space: SearchSpace) => {
e.stopPropagation();
setSpaceToDelete(space);
};
const confirmDelete = () => {
if (spaceToDelete) {
onDelete?.(spaceToDelete.id);
setSpaceToDelete(null);
}
};
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md">
<SheetHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Search className="h-5 w-5 text-primary" />
</div>
<div className="flex flex-col gap-0.5">
<SheetTitle>{t("all_search_spaces")}</SheetTitle>
<SheetDescription>
{t("search_spaces_count", { count: searchSpaces.length })}
</SheetDescription>
</div>
</div>
</SheetHeader>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 pb-4">
{searchSpaces.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-12 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<div className="flex flex-col gap-1">
<p className="font-medium">{t("no_search_spaces")}</p>
<p className="text-sm text-muted-foreground">
{t("create_first_search_space")}
</p>
</div>
{onCreateNew && (
<Button onClick={onCreateNew} className="mt-2">
{t("create_button")}
</Button>
)}
</div>
) : (
searchSpaces.map((space) => (
<button
key={space.id}
type="button"
onClick={() => handleSelect(space.id)}
className="flex w-full flex-col gap-2 rounded-lg border p-4 text-left transition-colors hover:bg-accent hover:border-accent-foreground/20"
>
<div className="flex items-start justify-between gap-2">
<div className="flex flex-1 flex-col gap-1">
<span className="font-medium leading-tight">
{space.name}
</span>
{space.description && (
<span className="text-sm text-muted-foreground line-clamp-2">
{space.description}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{space.memberCount > 1 && (
<Badge variant="outline" className="shrink-0">
<Share2 className="mr-1 h-3 w-3" />
{tCommon("shared")}
</Badge>
)}
{space.isOwner && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={(e) => handleSettings(e, space)}>
<Settings className="mr-2 h-4 w-4" />
{tCommon("settings")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleDeleteClick(e, space)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{tCommon("delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
{space.isOwner ? (
<UserCheck className="h-3.5 w-3.5" />
) : (
<Users className="h-3.5 w-3.5" />
)}
{t("members_count", { count: space.memberCount })}
</span>
{space.createdAt && (
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{formatDate(space.createdAt)}
</span>
)}
</div>
</button>
))
)}
</div>
{searchSpaces.length > 0 && onCreateNew && (
<div className="border-t p-4">
<Button onClick={onCreateNew} variant="outline" className="w-full">
{t("create_new_search_space")}
</Button>
</div>
)}
</SheetContent>
</Sheet>
<AlertDialog open={!!spaceToDelete} onOpenChange={(open) => !open && setSpaceToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("delete_title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("delete_confirm", { name: spaceToDelete?.name ?? "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

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

View file

@ -11,18 +11,18 @@ import type {
NoteItem,
PageUsage,
User,
Workspace,
SearchSpace,
} 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;
searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void;
onAddSearchSpace: () => void;
searchSpace: SearchSpace | null;
navItems: NavItem[];
onNavItemClick?: (item: NavItem) => void;
chats: ChatItem[];
@ -39,8 +39,9 @@ interface LayoutShellProps {
onViewAllNotes?: () => void;
user: User;
onSettings?: () => void;
onInviteMembers?: () => void;
onSeeAllWorkspaces?: () => void;
onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
onUserSettings?: () => void;
onLogout?: () => void;
pageUsage?: PageUsage;
breadcrumb?: React.ReactNode;
@ -54,11 +55,11 @@ interface LayoutShellProps {
}
export function LayoutShell({
workspaces,
activeWorkspaceId,
onWorkspaceSelect,
onAddWorkspace,
workspace,
searchSpaces,
activeSearchSpaceId,
onSearchSpaceSelect,
onAddSearchSpace,
searchSpace,
navItems,
onNavItemClick,
chats,
@ -75,8 +76,9 @@ export function LayoutShell({
onViewAllNotes,
user,
onSettings,
onInviteMembers,
onSeeAllWorkspaces,
onManageMembers,
onSeeAllSearchSpaces,
onUserSettings,
onLogout,
pageUsage,
breadcrumb,
@ -108,11 +110,11 @@ export function LayoutShell({
<MobileSidebar
isOpen={mobileMenuOpen}
onOpenChange={setMobileMenuOpen}
workspaces={workspaces}
activeWorkspaceId={activeWorkspaceId}
onWorkspaceSelect={onWorkspaceSelect}
onAddWorkspace={onAddWorkspace}
workspace={workspace}
searchSpaces={searchSpaces}
activeSearchSpaceId={activeSearchSpaceId}
onSearchSpaceSelect={onSearchSpaceSelect}
onAddSearchSpace={onAddSearchSpace}
searchSpace={searchSpace}
navItems={navItems}
onNavItemClick={onNavItemClick}
chats={chats}
@ -129,8 +131,9 @@ export function LayoutShell({
onViewAllNotes={onViewAllNotes}
user={user}
onSettings={onSettings}
onInviteMembers={onInviteMembers}
onSeeAllWorkspaces={onSeeAllWorkspaces}
onManageMembers={onManageMembers}
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
onUserSettings={onUserSettings}
onLogout={onLogout}
pageUsage={pageUsage}
/>
@ -149,16 +152,16 @@ export function LayoutShell({
<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}
searchSpaces={searchSpaces}
activeSearchSpaceId={activeSearchSpaceId}
onSearchSpaceSelect={onSearchSpaceSelect}
onAddSearchSpace={onAddSearchSpace}
/>
</div>
<div className="flex flex-1 rounded-xl border bg-background overflow-hidden">
<Sidebar
workspace={workspace}
searchSpace={searchSpace}
isCollapsed={isCollapsed}
onToggleCollapse={toggleCollapsed}
navItems={navItems}
@ -177,8 +180,9 @@ export function LayoutShell({
onViewAllNotes={onViewAllNotes}
user={user}
onSettings={onSettings}
onInviteMembers={onInviteMembers}
onSeeAllWorkspaces={onSeeAllWorkspaces}
onManageMembers={onManageMembers}
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
onUserSettings={onUserSettings}
onLogout={onLogout}
pageUsage={pageUsage}
className="hidden md:flex border-r shrink-0"

View file

@ -10,7 +10,7 @@ import type {
NoteItem,
PageUsage,
User,
Workspace,
SearchSpace,
} from "../../types/layout.types";
import { IconRail } from "../icon-rail";
import { Sidebar } from "./Sidebar";
@ -18,11 +18,11 @@ 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;
searchSpaces: SearchSpace[];
activeSearchSpaceId: number | null;
onSearchSpaceSelect: (id: number) => void;
onAddSearchSpace: () => void;
searchSpace: SearchSpace | null;
navItems: NavItem[];
onNavItemClick?: (item: NavItem) => void;
chats: ChatItem[];
@ -39,8 +39,9 @@ interface MobileSidebarProps {
onViewAllNotes?: () => void;
user: User;
onSettings?: () => void;
onInviteMembers?: () => void;
onSeeAllWorkspaces?: () => void;
onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
onUserSettings?: () => void;
onLogout?: () => void;
pageUsage?: PageUsage;
}
@ -57,11 +58,11 @@ export function MobileSidebarTrigger({ onClick }: { onClick: () => void }) {
export function MobileSidebar({
isOpen,
onOpenChange,
workspaces,
activeWorkspaceId,
onWorkspaceSelect,
onAddWorkspace,
workspace,
searchSpaces,
activeSearchSpaceId,
onSearchSpaceSelect,
onAddSearchSpace,
searchSpace,
navItems,
onNavItemClick,
chats,
@ -78,13 +79,14 @@ export function MobileSidebar({
onViewAllNotes,
user,
onSettings,
onInviteMembers,
onSeeAllWorkspaces,
onManageMembers,
onSeeAllSearchSpaces,
onUserSettings,
onLogout,
pageUsage,
}: MobileSidebarProps) {
const handleWorkspaceSelect = (id: number) => {
onWorkspaceSelect(id);
const handleSearchSpaceSelect = (id: number) => {
onSearchSpaceSelect(id);
};
const handleNavItemClick = (item: NavItem) => {
@ -110,17 +112,17 @@ export function MobileSidebar({
<div className="shrink-0 border-r bg-muted/40">
<ScrollArea className="h-full">
<IconRail
workspaces={workspaces}
activeWorkspaceId={activeWorkspaceId}
onWorkspaceSelect={handleWorkspaceSelect}
onAddWorkspace={onAddWorkspace}
searchSpaces={searchSpaces}
activeSearchSpaceId={activeSearchSpaceId}
onSearchSpaceSelect={handleSearchSpaceSelect}
onAddSearchSpace={onAddSearchSpace}
/>
</ScrollArea>
</div>
<div className="flex-1 overflow-hidden">
<Sidebar
workspace={workspace}
searchSpace={searchSpace}
isCollapsed={false}
navItems={navItems}
onNavItemClick={handleNavItemClick}
@ -141,8 +143,9 @@ export function MobileSidebar({
onViewAllNotes={onViewAllNotes}
user={user}
onSettings={onSettings}
onInviteMembers={onInviteMembers}
onSeeAllWorkspaces={onSeeAllWorkspaces}
onManageMembers={onManageMembers}
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
onUserSettings={onUserSettings}
onLogout={onLogout}
pageUsage={pageUsage}
className="w-full border-none"

View file

@ -12,7 +12,7 @@ import type {
NoteItem,
PageUsage,
User,
Workspace,
SearchSpace,
} from "../../types/layout.types";
import { ChatListItem } from "./ChatListItem";
import { NavSection } from "./NavSection";
@ -24,7 +24,7 @@ import { SidebarSection } from "./SidebarSection";
import { SidebarUserProfile } from "./SidebarUserProfile";
interface SidebarProps {
workspace: Workspace | null;
searchSpace: SearchSpace | null;
isCollapsed?: boolean;
onToggleCollapse?: () => void;
navItems: NavItem[];
@ -43,15 +43,16 @@ interface SidebarProps {
onViewAllNotes?: () => void;
user: User;
onSettings?: () => void;
onInviteMembers?: () => void;
onSeeAllWorkspaces?: () => void;
onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
onUserSettings?: () => void;
onLogout?: () => void;
pageUsage?: PageUsage;
className?: string;
}
export function Sidebar({
workspace,
searchSpace,
isCollapsed = false,
onToggleCollapse,
navItems,
@ -70,8 +71,9 @@ export function Sidebar({
onViewAllNotes,
user,
onSettings,
onInviteMembers,
onSeeAllWorkspaces,
onManageMembers,
onSeeAllSearchSpaces,
onUserSettings,
onLogout,
pageUsage,
className,
@ -86,7 +88,7 @@ export function Sidebar({
className
)}
>
{/* Header - workspace name or collapse button when collapsed */}
{/* Header - search space name or collapse button when collapsed */}
{isCollapsed ? (
<div className="flex h-14 shrink-0 items-center justify-center border-b">
<SidebarCollapseButton
@ -97,11 +99,11 @@ export function Sidebar({
) : (
<div className="flex h-14 shrink-0 items-center justify-between px-1 border-b">
<SidebarHeader
workspace={workspace}
searchSpace={searchSpace}
isCollapsed={isCollapsed}
onSettings={onSettings}
onInviteMembers={onInviteMembers}
onSeeAllWorkspaces={onSeeAllWorkspaces}
onManageMembers={onManageMembers}
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
/>
<div className="">
<SidebarCollapseButton
@ -287,7 +289,7 @@ export function Sidebar({
<PageUsageDisplay pagesUsed={pageUsage.pagesUsed} pagesLimit={pageUsage.pagesLimit} />
)}
<SidebarUserProfile user={user} onLogout={onLogout} isCollapsed={isCollapsed} />
<SidebarUserProfile user={user} onUserSettings={onUserSettings} onLogout={onLogout} isCollapsed={isCollapsed} />
</div>
</div>
);

View file

@ -1,6 +1,6 @@
"use client";
import { ChevronsUpDown, LayoutGrid, Settings, UserPlus } from "lucide-react";
import { ChevronsUpDown, LayoutGrid, Settings, Users } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import {
@ -11,23 +11,23 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import type { Workspace } from "../../types/layout.types";
import type { SearchSpace } from "../../types/layout.types";
interface SidebarHeaderProps {
workspace: Workspace | null;
searchSpace: SearchSpace | null;
isCollapsed?: boolean;
onSettings?: () => void;
onInviteMembers?: () => void;
onSeeAllWorkspaces?: () => void;
onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
className?: string;
}
export function SidebarHeader({
workspace,
searchSpace,
isCollapsed,
onSettings,
onInviteMembers,
onSeeAllWorkspaces,
onManageMembers,
onSeeAllSearchSpaces,
className,
}: SidebarHeaderProps) {
const t = useTranslations("sidebar");
@ -43,24 +43,24 @@ export function SidebarHeader({
isCollapsed ? "w-10" : "w-50"
)}
>
<span className="truncate text-base">{workspace?.name ?? t("select_workspace")}</span>
<span className="truncate text-base">{searchSpace?.name ?? t("select_search_space")}</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 onClick={onManageMembers}>
<Users className="mr-2 h-4 w-4" />
{t("manage_members")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onSettings}>
<Settings className="mr-2 h-4 w-4" />
{t("workspace_settings")}
{t("search_space_settings")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onSeeAllWorkspaces}>
<DropdownMenuItem onClick={onSeeAllSearchSpaces}>
<LayoutGrid className="mr-2 h-4 w-4" />
{t("see_all_workspaces")}
{t("see_all_search_spaces")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -1,6 +1,6 @@
"use client";
import { ChevronUp, LogOut } from "lucide-react";
import { ChevronUp, LogOut, Settings } from "lucide-react";
import { useTranslations } from "next-intl";
import {
DropdownMenu,
@ -16,6 +16,7 @@ import type { User } from "../../types/layout.types";
interface SidebarUserProfileProps {
user: User;
onUserSettings?: () => void;
onLogout?: () => void;
isCollapsed?: boolean;
}
@ -62,6 +63,7 @@ function getInitials(email: string): string {
export function SidebarUserProfile({
user,
onUserSettings,
onLogout,
isCollapsed = false,
}: SidebarUserProfileProps) {
@ -117,6 +119,13 @@ export function SidebarUserProfile({
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onUserSettings}>
<Settings className="mr-2 h-4 w-4" />
{t("user_settings")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout}>
<LogOut className="mr-2 h-4 w-4" />
{t("logout")}
@ -177,6 +186,13 @@ export function SidebarUserProfile({
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onUserSettings}>
<Settings className="mr-2 h-4 w-4" />
{t("user_settings")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout}>
<LogOut className="mr-2 h-4 w-4" />
{t("logout")}

View file

@ -28,7 +28,10 @@
"info": "Information",
"required": "Required",
"optional": "Optional",
"retry": "Retry"
"retry": "Retry",
"owner": "Owner",
"shared": "Shared",
"settings": "Settings"
},
"auth": {
"login": "Login",
@ -77,6 +80,45 @@
"creating_account_btn": "Creating account...",
"redirecting_login": "Redirecting to login page..."
},
"searchSpace": {
"create_title": "Create Search Space",
"create_description": "Create a new search space to organize your knowledge",
"name_label": "Name",
"name_placeholder": "Enter search space name",
"description_label": "Description",
"description_placeholder": "What is this search space for?",
"create_button": "Create",
"creating": "Creating...",
"all_search_spaces": "All Search Spaces",
"search_spaces_count": "{count, plural, =0 {No search spaces} =1 {1 search space} other {# search spaces}}",
"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",
"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.",
"welcome_title": "Welcome to SurfSense",
"welcome_description": "Create your first search space to start organizing your knowledge, connecting sources, and chatting with AI.",
"create_first_button": "Create your first search space"
},
"userSettings": {
"title": "User Settings",
"description": "Manage your account settings and API access",
"back_to_app": "Back to app",
"footer": "User Settings",
"api_key_nav_label": "API Key",
"api_key_nav_description": "Manage your API access token",
"api_key_title": "API Key",
"api_key_description": "Use this key to authenticate API requests",
"api_key_warning_title": "Keep it secret",
"api_key_warning_description": "Your API key grants full access to your account. Never share it publicly or commit it to version control.",
"your_api_key": "Your API Key",
"copied": "Copied!",
"copy": "Copy to clipboard",
"no_api_key": "No API key found",
"usage_title": "How to use",
"usage_description": "Include your API key in the Authorization header:"
},
"dashboard": {
"title": "Dashboard",
"search_spaces": "Search Spaces",
@ -624,12 +666,13 @@
"no_archived_chats": "No archived chats",
"error_archiving_chat": "Failed to archive chat",
"new_chat": "New chat",
"select_workspace": "Select Workspace",
"invite_members": "Invite members",
"workspace_settings": "Workspace settings",
"see_all_workspaces": "See all search spaces",
"select_search_space": "Select Search Space",
"manage_members": "Manage members",
"search_space_settings": "Search Space settings",
"see_all_search_spaces": "See all search spaces",
"expand_sidebar": "Expand sidebar",
"collapse_sidebar": "Collapse sidebar",
"user_settings": "User settings",
"logout": "Logout"
},
"errors": {

View file

@ -28,7 +28,10 @@
"info": "信息",
"required": "必填",
"optional": "可选",
"retry": "重试"
"retry": "重试",
"owner": "所有者",
"shared": "共享",
"settings": "设置"
},
"auth": {
"login": "登录",
@ -77,6 +80,45 @@
"creating_account_btn": "创建中...",
"redirecting_login": "正在跳转到登录页面..."
},
"searchSpace": {
"create_title": "创建搜索空间",
"create_description": "创建一个新的搜索空间来组织您的知识",
"name_label": "名称",
"name_placeholder": "输入搜索空间名称",
"description_label": "描述",
"description_placeholder": "这个搜索空间是做什么的?",
"create_button": "创建",
"creating": "创建中...",
"all_search_spaces": "所有搜索空间",
"search_spaces_count": "{count, plural, =0 {没有搜索空间} other {# 个搜索空间}}",
"no_search_spaces": "暂无搜索空间",
"create_first_search_space": "创建您的第一个搜索空间以开始使用",
"members_count": "{count, plural, other {# 位成员}}",
"create_new_search_space": "创建新的搜索空间",
"delete_title": "删除搜索空间",
"delete_confirm": "您确定要删除「{name}」吗?此操作无法撤销,将永久删除所有数据。",
"welcome_title": "欢迎使用 SurfSense",
"welcome_description": "创建您的第一个搜索空间开始组织知识、连接数据源并与AI对话。",
"create_first_button": "创建第一个搜索空间"
},
"userSettings": {
"title": "用户设置",
"description": "管理您的账户设置和API访问",
"back_to_app": "返回应用",
"footer": "用户设置",
"api_key_nav_label": "API密钥",
"api_key_nav_description": "管理您的API访问令牌",
"api_key_title": "API密钥",
"api_key_description": "使用此密钥验证API请求",
"api_key_warning_title": "请保密",
"api_key_warning_description": "您的API密钥可以完全访问您的账户。请勿公开分享或提交到版本控制。",
"your_api_key": "您的API密钥",
"copied": "已复制!",
"copy": "复制到剪贴板",
"no_api_key": "未找到API密钥",
"usage_title": "使用方法",
"usage_description": "在Authorization请求头中包含您的API密钥"
},
"dashboard": {
"title": "仪表盘",
"search_spaces": "搜索空间",
@ -618,12 +660,13 @@
"view_all_notes": "查看所有笔记",
"add_note": "添加笔记",
"new_chat": "新对话",
"select_workspace": "选择工作空间",
"invite_members": "邀请成员",
"workspace_settings": "工作空间设置",
"see_all_workspaces": "查看所有搜索空间",
"select_search_space": "选择搜索空间",
"manage_members": "管理成员",
"search_space_settings": "搜索空间设置",
"see_all_search_spaces": "查看所有搜索空间",
"expand_sidebar": "展开侧边栏",
"collapse_sidebar": "收起侧边栏",
"user_settings": "用户设置",
"logout": "退出登录"
},
"errors": {