refactor: rename Workspace to SearchSpace in layout components

This commit is contained in:
CREDO23 2026-01-12 15:47:56 +02:00
parent 9655db1995
commit eed04e9b27
17 changed files with 820 additions and 137 deletions

View file

@ -0,0 +1,385 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { DateRangeSelector } from "../../components/date-range-selector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
const clickupConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
api_token: z.string().min(10, {
message: "ClickUp API Token is required and must be valid.",
}),
});
type ClickUpConnectorFormValues = z.infer<typeof clickupConnectorFormSchema>;
export const ClickUpConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
const form = useForm<ClickUpConnectorFormValues>({
resolver: zodResolver(clickupConnectorFormSchema),
defaultValues: {
name: "ClickUp Connector",
api_token: "",
},
});
const handleSubmit = async (values: ClickUpConnectorFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.CLICKUP_CONNECTOR,
config: {
CLICKUP_API_TOKEN: values.api_token,
},
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
next_scheduled_at: null,
startDate,
endDate,
periodicEnabled,
frequencyMinutes,
});
} finally {
isSubmittingRef.current = false;
}
};
return (
<div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">API Token Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
You'll need a ClickUp API Token to use this connector. You can create one from{" "}
<a
href="https://app.clickup.com/settings/apps"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
ClickUp Settings
</a>
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="clickup-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My ClickUp Connector"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">ClickUp API Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="pk_..."
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Your ClickUp API Token will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Indexing Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
{/* Date Range Selector */}
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
{/* Periodic Sync Config */}
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch
checked={periodicEnabled}
onCheckedChange={setPeriodicEnabled}
disabled={isSubmitting}
/>
</div>
{periodicEnabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">
Sync Frequency
</Label>
<Select
value={frequencyMinutes}
onValueChange={setFrequencyMinutes}
disabled={isSubmitting}
>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="5" className="text-xs sm:text-sm">
Every 5 minutes
</SelectItem>
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">
Every hour
</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">
Every 6 hours
</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">
Every 12 hours
</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">
Daily
</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">
Weekly
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.CLICKUP_CONNECTOR) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">What you get with ClickUp integration:</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.CLICKUP_CONNECTOR)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The ClickUp connector uses the ClickUp API to fetch all tasks and projects that your
API token has access to within your workspace.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
For follow up indexing runs, the connector retrieves tasks that have been updated
since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within minutes.
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">API Token Required</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
You need a ClickUp personal API token to use this connector. The token will be
used to read your ClickUp data.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Get Your API Token
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>Log in to your ClickUp account</li>
<li>Click your avatar in the upper-right corner and select "Settings"</li>
<li>In the sidebar, click "Apps"</li>
<li>
Under "API Token", click <strong>Generate</strong> or{" "}
<strong>Regenerate</strong>
</li>
<li>Copy the generated token (it typically starts with "pk_")</li>
<li>
Paste it in the form above. You can also visit{" "}
<a
href="https://app.clickup.com/settings/apps"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
ClickUp API Settings
</a>{" "}
directly.
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Grant necessary access
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
The API Token will have access to all tasks and projects that your user
account can see. Make sure your account has appropriate permissions for the
workspaces you want to index.
</p>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Data Privacy</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
Only tasks, comments, and basic metadata will be indexed. ClickUp
attachments and linked files are not indexed by this connector.
</AlertDescription>
</Alert>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Navigate to the Connector Dashboard and select the <strong>ClickUp</strong>{" "}
Connector.
</li>
<li>
Place your <strong>API Token</strong> in the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your ClickUp tasks will be indexed automatically.</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The ClickUp connector indexes the following data:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Task names and descriptions</li>
<li>Task comments and discussion threads</li>
<li>Task status, priority, and assignee information</li>
<li>Project and workspace information</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -0,0 +1,29 @@
"use client";
import { Info } from "lucide-react";
import type { FC } from "react";
import type { ConnectorConfigProps } from "../index";
export interface TeamsConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void;
}
export const TeamsConfig: FC<TeamsConfigProps> = () => {
return (
<div className="space-y-6">
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
<Info className="size-4" />
</div>
<div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Microsoft Teams Access</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
SurfSense will index messages from Teams channels that you have access to. The app can
only read messages from teams and channels where you are a member. Make sure you're a
member of the teams you want to index before connecting.
</p>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,189 @@
"use client";
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
import { ArrowLeft, Loader2, Plus } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { cn } from "@/lib/utils";
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
interface ConnectorAccountsListViewProps {
connectorType: string;
connectorTitle: string;
connectors: SearchSourceConnector[];
indexingConnectorIds: Set<number>;
logsSummary: LogSummary | undefined;
onBack: () => void;
onManage: (connector: SearchSourceConnector) => void;
onAddAccount: () => void;
isConnecting?: boolean;
}
/**
* Format last indexed date with contextual messages
*/
function formatLastIndexedDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const minutesAgo = differenceInMinutes(now, date);
const daysAgo = differenceInDays(now, date);
if (minutesAgo < 1) {
return "Just now";
}
if (minutesAgo < 60) {
return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`;
}
if (isToday(date)) {
return `Today at ${format(date, "h:mm a")}`;
}
if (isYesterday(date)) {
return `Yesterday at ${format(date, "h:mm a")}`;
}
if (daysAgo < 7) {
return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`;
}
return format(date, "MMM d, yyyy");
}
export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
connectorType,
connectorTitle,
connectors,
indexingConnectorIds,
logsSummary,
onBack,
onManage,
onAddAccount,
isConnecting = false,
}) => {
// Filter connectors to only show those of this type
const typeConnectors = connectors.filter((c) => c.connector_type === connectorType);
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-4 sm:px-12 pt-6 sm:pt-10 pb-4 border-b border-border/50 bg-muted">
<div className="flex items-center justify-between gap-4 sm:pr-4">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
className="size-8 rounded-full shrink-0"
onClick={onBack}
>
<ArrowLeft className="size-4" />
</Button>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-slate-400/5 dark:bg-white/5 border border-slate-400/5 dark:border-white/5">
{getConnectorIcon(connectorType, "size-5")}
</div>
<div>
<h2 className="text-lg font-semibold">{connectorTitle} Accounts</h2>
<p className="text-xs text-muted-foreground">
{typeConnectors.length} connected account{typeConnectors.length !== 1 ? "s" : ""}
</p>
</div>
</div>
</div>
{/* Add Account Button with dashed border */}
<button
type="button"
onClick={onAddAccount}
disabled={isConnecting}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-lg mr-4 border-2 border-dashed border-border/70 text-left transition-all duration-200",
"border-primary/50 hover:bg-primary/5",
isConnecting && "opacity-50 cursor-not-allowed"
)}
>
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/10 shrink-0">
{isConnecting ? (
<Loader2 className="size-3.5 animate-spin text-primary" />
) : (
<Plus className="size-3.5 text-primary" />
)}
</div>
<span className="text-[12px] font-medium">
{isConnecting ? "Connecting..." : "Add Account"}
</span>
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-4 sm:px-12 py-6 sm:py-8">
{/* Connected Accounts Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{typeConnectors.map((connector) => {
const isIndexing = indexingConnectorIds.has(connector.id);
const activeTask = logsSummary?.active_tasks?.find(
(task: LogActiveTask) => task.connector_id === connector.id
);
return (
<div
key={connector.id}
className={cn(
"flex items-center gap-4 p-4 rounded-xl border border-border transition-all",
isIndexing
? "bg-primary/5 border-primary/20"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
)}
>
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-lg border shrink-0",
isIndexing
? "bg-primary/10 border-primary/20"
: "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
)}
>
{getConnectorIcon(connector.connector_type, "size-6")}
</div>
<div className="flex-1 min-w-0">
<p className="text-[14px] font-semibold leading-tight truncate">
{getConnectorDisplayName(connector.name)}
</p>
{isIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
Indexing...
{activeTask?.message && (
<span className="text-muted-foreground truncate max-w-[100px]">
{activeTask.message}
</span>
)}
</p>
) : (
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap truncate">
{connector.last_indexed_at
? `Last indexed: ${formatLastIndexedDate(connector.last_indexed_at)}`
: "Never indexed"}
</p>
)}
</div>
<Button
variant="secondary"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80 shrink-0"
onClick={() => onManage(connector)}
>
Manage
</Button>
</div>
);
})}
</div>
</div>
</div>
);
};

View file

@ -8,7 +8,7 @@ export type {
PageUsage,
SidebarSectionProps,
User,
Workspace,
SearchSpace,
} from "./types/layout.types";
export {
ChatListItem,
@ -26,5 +26,5 @@ export {
SidebarHeader,
SidebarSection,
SidebarUserProfile,
WorkspaceAvatar,
SearchSpaceAvatar,
} from "./ui";

View file

@ -25,7 +25,7 @@ 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 { LayoutShell } from "../ui/shell";
import { AllChatsSidebar } from "../ui/sidebar/AllChatsSidebar";
import { AllNotesSidebar } from "../ui/sidebar/AllNotesSidebar";
@ -123,8 +123,8 @@ export function LayoutDataProvider({
} | null>(null);
const [isDeletingNote, setIsDeletingNote] = useState(false);
// Transform workspaces (API returns array directly, not { items: [...] })
const workspaces: Workspace[] = useMemo(() => {
// Transform search spaces (API returns array directly, not { items: [...] })
const searchSpaces: SearchSpace[] = useMemo(() => {
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
return searchSpacesData.map((space) => ({
id: space.id,
@ -135,8 +135,8 @@ export function LayoutDataProvider({
}));
}, [searchSpacesData]);
// Use searchSpace query result for current workspace (more reliable than finding in list)
const activeWorkspace: Workspace | null = searchSpace
// Use searchSpace query result for active search space (more reliable than finding in list)
const activeSearchSpace: SearchSpace | null = searchSpace
? {
id: searchSpace.id,
name: searchSpace.name,
@ -196,18 +196,18 @@ export function LayoutDataProvider({
);
// Handlers
const handleWorkspaceSelect = useCallback(
const handleSearchSpaceSelect = useCallback(
(id: number) => {
router.push(`/dashboard/${id}/new-chat`);
},
[router]
);
const handleAddWorkspace = useCallback(() => {
const handleAddSearchSpace = useCallback(() => {
router.push("/dashboard/searchspaces");
}, [router]);
const handleSeeAllWorkspaces = useCallback(() => {
const handleSeeAllSearchSpaces = useCallback(() => {
router.push("/dashboard");
}, [router]);
@ -266,7 +266,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 +347,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 +368,8 @@ export function LayoutDataProvider({
onViewAllNotes={handleViewAllNotes}
user={{ email: user?.email || "", name: user?.email?.split("@")[0] }}
onSettings={handleSettings}
onInviteMembers={handleInviteMembers}
onSeeAllWorkspaces={handleSeeAllWorkspaces}
onManageMembers={handleManageMembers}
onSeeAllSearchSpaces={handleSeeAllSearchSpaces}
onLogout={handleLogout}
pageUsage={pageUsage}
breadcrumb={breadcrumb}

View file

@ -1,6 +1,6 @@
import type { LucideIcon } from "lucide-react";
export interface Workspace {
export interface SearchSpace {
id: number;
name: string;
description?: string | null;
@ -42,15 +42,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 +94,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 +120,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 +129,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

@ -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

@ -0,0 +1,72 @@
"use client";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
interface SearchSpaceAvatarProps {
name: string;
isActive?: boolean;
onClick?: () => void;
size?: "sm" | "md";
}
/**
* Generates a consistent color based on search space 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 search space 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 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";
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

@ -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,5 +1,5 @@
export { Header } from "./header";
export { IconRail, NavIcon, WorkspaceAvatar } from "./icon-rail";
export { IconRail, NavIcon, SearchSpaceAvatar } from "./icon-rail";
export { LayoutShell } from "./shell";
export {
ChatListItem,

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,8 @@ interface LayoutShellProps {
onViewAllNotes?: () => void;
user: User;
onSettings?: () => void;
onInviteMembers?: () => void;
onSeeAllWorkspaces?: () => void;
onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
onLogout?: () => void;
pageUsage?: PageUsage;
breadcrumb?: React.ReactNode;
@ -54,11 +54,11 @@ interface LayoutShellProps {
}
export function LayoutShell({
workspaces,
activeWorkspaceId,
onWorkspaceSelect,
onAddWorkspace,
workspace,
searchSpaces,
activeSearchSpaceId,
onSearchSpaceSelect,
onAddSearchSpace,
searchSpace,
navItems,
onNavItemClick,
chats,
@ -75,8 +75,8 @@ export function LayoutShell({
onViewAllNotes,
user,
onSettings,
onInviteMembers,
onSeeAllWorkspaces,
onManageMembers,
onSeeAllSearchSpaces,
onLogout,
pageUsage,
breadcrumb,
@ -108,11 +108,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 +129,8 @@ export function LayoutShell({
onViewAllNotes={onViewAllNotes}
user={user}
onSettings={onSettings}
onInviteMembers={onInviteMembers}
onSeeAllWorkspaces={onSeeAllWorkspaces}
onManageMembers={onManageMembers}
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
onLogout={onLogout}
pageUsage={pageUsage}
/>
@ -149,16 +149,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 +177,8 @@ export function LayoutShell({
onViewAllNotes={onViewAllNotes}
user={user}
onSettings={onSettings}
onInviteMembers={onInviteMembers}
onSeeAllWorkspaces={onSeeAllWorkspaces}
onManageMembers={onManageMembers}
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
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,8 @@ interface MobileSidebarProps {
onViewAllNotes?: () => void;
user: User;
onSettings?: () => void;
onInviteMembers?: () => void;
onSeeAllWorkspaces?: () => void;
onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
onLogout?: () => void;
pageUsage?: PageUsage;
}
@ -57,11 +57,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 +78,13 @@ export function MobileSidebar({
onViewAllNotes,
user,
onSettings,
onInviteMembers,
onSeeAllWorkspaces,
onManageMembers,
onSeeAllSearchSpaces,
onLogout,
pageUsage,
}: MobileSidebarProps) {
const handleWorkspaceSelect = (id: number) => {
onWorkspaceSelect(id);
const handleSearchSpaceSelect = (id: number) => {
onSearchSpaceSelect(id);
};
const handleNavItemClick = (item: NavItem) => {
@ -110,17 +110,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 +141,8 @@ export function MobileSidebar({
onViewAllNotes={onViewAllNotes}
user={user}
onSettings={onSettings}
onInviteMembers={onInviteMembers}
onSeeAllWorkspaces={onSeeAllWorkspaces}
onManageMembers={onManageMembers}
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
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,15 @@ interface SidebarProps {
onViewAllNotes?: () => void;
user: User;
onSettings?: () => void;
onInviteMembers?: () => void;
onSeeAllWorkspaces?: () => void;
onManageMembers?: () => void;
onSeeAllSearchSpaces?: () => void;
onLogout?: () => void;
pageUsage?: PageUsage;
className?: string;
}
export function Sidebar({
workspace,
searchSpace,
isCollapsed = false,
onToggleCollapse,
navItems,
@ -70,8 +70,8 @@ export function Sidebar({
onViewAllNotes,
user,
onSettings,
onInviteMembers,
onSeeAllWorkspaces,
onManageMembers,
onSeeAllSearchSpaces,
onLogout,
pageUsage,
className,
@ -86,7 +86,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 +97,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

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

@ -625,9 +625,13 @@
"error_archiving_chat": "Failed to archive chat",
"new_chat": "New chat",
"select_workspace": "Select Workspace",
"select_search_space": "Select Search Space",
"invite_members": "Invite members",
"manage_members": "Manage members",
"workspace_settings": "Workspace settings",
"search_space_settings": "Search space settings",
"see_all_workspaces": "See all search spaces",
"see_all_search_spaces": "See all search spaces",
"expand_sidebar": "Expand sidebar",
"collapse_sidebar": "Collapse sidebar",
"logout": "Logout"

View file

@ -619,9 +619,13 @@
"add_note": "添加笔记",
"new_chat": "新对话",
"select_workspace": "选择工作空间",
"select_search_space": "选择搜索空间",
"invite_members": "邀请成员",
"manage_members": "管理成员",
"workspace_settings": "工作空间设置",
"search_space_settings": "搜索空间设置",
"see_all_workspaces": "查看所有搜索空间",
"see_all_search_spaces": "查看所有搜索空间",
"expand_sidebar": "展开侧边栏",
"collapse_sidebar": "收起侧边栏",
"logout": "退出登录"