feat: integrate search space settings dialog across various components

- Added `searchSpaceSettingsDialogAtom` to manage the state of the settings dialog.
- Updated multiple components (OnboardPage, TeamManagementPage, ConnectorIndicator, DocumentUploadPopupContent, etc.) to utilize the new dialog state for navigating to settings.
- Removed unnecessary animations from ApiKeyContent and ProfileContent components for improved performance.
- Enhanced button styles for better UI consistency across settings actions.
- Refactored error handling in LLMRoleManager and ModelConfigManager to simplify the UI structure.
This commit is contained in:
Anish Sarkar 2026-03-16 21:10:46 +05:30
parent 60d12b0a70
commit b7d684ca8d
19 changed files with 646 additions and 483 deletions

View file

@ -1,6 +1,6 @@
"use client";
import { useAtomValue } from "jotai";
import { useAtomValue, useSetAtom } from "jotai";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
@ -13,6 +13,7 @@ import {
globalNewLLMConfigsAtom,
llmPreferencesAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { Logo } from "@/components/Logo";
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@ -23,6 +24,7 @@ export default function OnboardPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
// Queries
const {
@ -259,7 +261,9 @@ export default function OnboardPage() {
You can add more configurations and customize settings anytime in{" "}
<button
type="button"
onClick={() => router.push(`/dashboard/${searchSpaceId}/settings?tab=general`)}
onClick={() =>
setSearchSpaceSettingsDialog({ open: true, initialTab: "general" })
}
className="text-violet-500 hover:underline"
>
Settings

View file

@ -1,7 +1,7 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { useAtomValue, useSetAtom } from "jotai";
import {
Calendar,
Check,
@ -22,7 +22,7 @@ import {
} from "lucide-react";
import { motion } from "motion/react";
import Image from "next/image";
import { useParams, useRouter } from "next/navigation";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
@ -34,6 +34,7 @@ import {
updateMemberMutationAtom,
} from "@/atoms/members/members-mutation.atoms";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import {
AlertDialog,
AlertDialogAction,
@ -384,7 +385,6 @@ export default function TeamManagementPage() {
canRemove={canRemove}
onUpdateRole={handleUpdateMember}
onRemoveMember={handleRemoveMember}
searchSpaceId={searchSpaceId}
index={index}
/>
))}
@ -397,7 +397,6 @@ export default function TeamManagementPage() {
canRemove={canRemove}
onUpdateRole={handleUpdateMember}
onRemoveMember={handleRemoveMember}
searchSpaceId={searchSpaceId}
index={owners.length + index}
/>
))}
@ -485,7 +484,6 @@ function MemberRow({
canRemove,
onUpdateRole,
onRemoveMember,
searchSpaceId,
index,
}: {
member: Membership;
@ -494,10 +492,9 @@ function MemberRow({
canRemove: boolean;
onUpdateRole: (membershipId: number, roleId: number | null) => Promise<Membership>;
onRemoveMember: (membershipId: number) => Promise<boolean>;
searchSpaceId: number;
index: number;
}) {
const router = useRouter();
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
const initials = getAvatarInitials(member);
const avatarColor = getAvatarColor(member.user_id);
const displayName = member.user_display_name || member.user_email || "Unknown";
@ -607,7 +604,12 @@ function MemberRow({
)}
<DropdownMenuSeparator className="dark:bg-white/5" />
<DropdownMenuItem
onClick={() => router.push(`/dashboard/${searchSpaceId}/settings?tab=team-roles`)}
onClick={() =>
setSearchSpaceSettingsDialog({
open: true,
initialTab: "team-roles",
})
}
>
Manage Roles
</DropdownMenuItem>

View file

@ -1,7 +1,6 @@
"use client";
import { Check, Copy, Info } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl";
import { useCallback, useRef, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@ -27,15 +26,7 @@ export function ApiKeyContent() {
}, [apiKey]);
return (
<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"
>
<div className="space-y-6 min-w-0 overflow-hidden">
<Alert className="border-border/60 bg-muted/30 text-muted-foreground">
<Info className="h-4 w-4 text-muted-foreground" />
<AlertTitle className="text-muted-foreground">{t("api_key_warning_title")}</AlertTitle>
@ -44,8 +35,8 @@ export function ApiKeyContent() {
</AlertDescription>
</Alert>
<div className="rounded-lg border border-border/60 bg-card p-6">
<h3 className="mb-4 text-sm font-semibold tracking-tight">{t("your_api_key")}</h3>
<div className="rounded-lg border border-border/60 bg-card p-6 min-w-0 overflow-hidden">
<h3 className="mb-4 text-sm font-semibold tracking-tight">{t("your_api_key")}</h3>
{isLoading ? (
<div className="h-12 w-full animate-pulse rounded-md border border-border/60 bg-muted/30" />
) : apiKey ? (
@ -80,8 +71,8 @@ export function ApiKeyContent() {
)}
</div>
<div className="rounded-lg border border-border/60 bg-card p-6">
<h3 className="mb-2 text-sm font-semibold tracking-tight">{t("usage_title")}</h3>
<div className="rounded-lg border border-border/60 bg-card p-6 min-w-0 overflow-hidden">
<h3 className="mb-2 text-sm font-semibold tracking-tight">{t("usage_title")}</h3>
<p className="mb-4 text-[11px] text-muted-foreground/60">{t("usage_description")}</p>
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
@ -110,7 +101,6 @@ export function ApiKeyContent() {
</TooltipProvider>
</div>
</div>
</motion.div>
</AnimatePresence>
</div>
);
}

View file

@ -1,7 +1,6 @@
"use client";
import { useAtomValue } from "jotai";
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
@ -72,14 +71,7 @@ export function ProfileContent() {
const hasChanges = displayName !== (user?.display_name || "");
return (
<AnimatePresence mode="wait">
<motion.div
key="profile-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] }}
>
<div>
{isUserLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner size="md" className="text-muted-foreground" />
@ -116,14 +108,18 @@ export function ProfileContent() {
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isPending || !hasChanges}>
{isPending && <Spinner size="sm" className="mr-2" />}
{t("profile_save")}
</Button>
</div>
<Button
type="submit"
variant="outline"
disabled={isPending || !hasChanges}
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
>
{isPending && <Spinner size="sm" className="mr-2" />}
{t("profile_save")}
</Button>
</div>
</form>
)}
</motion.div>
</AnimatePresence>
</div>
);
}

View file

@ -0,0 +1,21 @@
import { atom } from "jotai";
export interface SearchSpaceSettingsDialogState {
open: boolean;
initialTab: string;
}
export interface UserSettingsDialogState {
open: boolean;
initialTab: string;
}
export const searchSpaceSettingsDialogAtom = atom<SearchSpaceSettingsDialogState>({
open: false,
initialTab: "general",
});
export const userSettingsDialogAtom = atom<UserSettingsDialogState>({
open: false,
initialTab: "profile",
});

View file

@ -1,8 +1,7 @@
"use client";
import { useAtomValue } from "jotai";
import { useAtomValue, useSetAtom } from "jotai";
import { AlertTriangle, Cable, Settings } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { type FC, forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
@ -12,6 +11,7 @@ import {
llmPreferencesAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@ -50,6 +50,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
({ showTrigger = true }, ref) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const searchParams = useSearchParams();
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
const { data: currentUser } = useAtomValue(currentUserAtom);
const { data: preferences = {}, isFetching: preferencesLoading } =
useAtomValue(llmPreferencesAtom);
@ -417,12 +418,20 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
? "Auto mode is selected but no global LLM configurations are available. Please configure a custom LLM in Settings to process and summarize documents from your connected sources."
: "You need to configure a Document Summary LLM before adding connectors. This LLM is used to process and summarize documents from your connected sources."}
</p>
<Button asChild size="sm" variant="outline">
<Link href={`/dashboard/${searchSpaceId}/settings?tab=models`}>
<Settings className="mr-2 h-4 w-4" />
Go to Settings
</Link>
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
handleOpenChange(false);
setSearchSpaceSettingsDialog({
open: true,
initialTab: "models",
});
}}
>
<Settings className="mr-2 h-4 w-4" />
Go to Settings
</Button>
</AlertDescription>
</Alert>
)}

View file

@ -1,8 +1,7 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertTriangle, Settings, Upload } from "lucide-react";
import Link from "next/link";
import { useAtomValue, useSetAtom } from "jotai";
import { AlertTriangle, Settings } from "lucide-react";
import {
createContext,
type FC,
@ -17,6 +16,7 @@ import {
llmPreferencesAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { DocumentUploadTab } from "@/components/sources/DocumentUploadTab";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
@ -91,6 +91,7 @@ const DocumentUploadPopupContent: FC<{
onOpenChange: (open: boolean) => void;
}> = ({ isOpen, onOpenChange }) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
const { data: preferences = {}, isFetching: preferencesLoading } =
useAtomValue(llmPreferencesAtom);
const { data: globalConfigs = [], isFetching: globalConfigsLoading } =
@ -157,12 +158,20 @@ const DocumentUploadPopupContent: FC<{
? "Auto mode is selected but no global LLM configurations are available. Please configure a custom LLM in Settings to process and summarize your uploaded documents."
: "You need to configure a Document Summary LLM before uploading files. This LLM is used to process and summarize your uploaded documents."}
</p>
<Button asChild size="sm" variant="outline">
<Link href={`/dashboard/${searchSpaceId}/settings?tab=models`}>
<Settings className="mr-2 h-4 w-4" />
Go to Settings
</Link>
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
onOpenChange(false);
setSearchSpaceSettingsDialog({
open: true,
initialTab: "models",
});
}}
>
<Settings className="mr-2 h-4 w-4" />
Go to Settings
</Button>
</AlertDescription>
</Alert>
) : (

View file

@ -14,6 +14,10 @@ import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import {
searchSpaceSettingsDialogAtom,
userSettingsDialogAtom,
} from "@/atoms/settings/settings-dialog.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import {
AlertDialog,
@ -47,6 +51,8 @@ import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-pers
import { cleanupElectric } from "@/lib/electric/client";
import { resetUser, trackLogout } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog";
import { UserSettingsDialog } from "@/components/settings/user-settings-dialog";
import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types";
import { CreateSearchSpaceDialog } from "../ui/dialogs";
import { LayoutShell } from "../ui/shell";
@ -390,15 +396,18 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
setIsCreateSearchSpaceDialogOpen(true);
}, []);
const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom);
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
const handleUserSettings = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/user-settings?tab=profile`);
}, [router, searchSpaceId]);
setUserSettingsDialog({ open: true, initialTab: "profile" });
}, [setUserSettingsDialog]);
const handleSearchSpaceSettings = useCallback(
(space: SearchSpace) => {
router.push(`/dashboard/${space.id}/settings?tab=general`);
(_space: SearchSpace) => {
setSearchSpaceSettingsDialog({ open: true, initialTab: "general" });
},
[router]
[setSearchSpaceSettingsDialog]
);
const handleSearchSpaceDeleteClick = useCallback((space: SearchSpace) => {
@ -582,8 +591,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
);
const handleSettings = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/settings?tab=general`);
}, [router, searchSpaceId]);
setSearchSpaceSettingsDialog({ open: true, initialTab: "general" });
}, [setSearchSpaceSettingsDialog]);
const handleManageMembers = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/team`);
@ -934,6 +943,10 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
open={isCreateSearchSpaceDialogOpen}
onOpenChange={setIsCreateSearchSpaceDialogOpen}
/>
{/* Settings Dialogs */}
<SearchSpaceSettingsDialog searchSpaceId={Number(searchSpaceId)} />
<UserSettingsDialog />
</>
);
}

View file

@ -3,12 +3,14 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { Earth, User, Users } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
import { createPublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -48,9 +50,8 @@ const visibilityOptions: {
export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) {
const queryClient = useQueryClient();
const router = useRouter();
const params = useParams();
const [open, setOpen] = useState(false);
const setSearchSpaceSettingsDialog = useSetAtom(searchSpaceSettingsDialogAtom);
// Use Jotai atom for visibility (single source of truth)
const currentThreadState = useAtomValue(currentThreadAtom);
@ -148,7 +149,10 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
<button
type="button"
onClick={() =>
router.push(`/dashboard/${params.search_space_id}/settings?tab=public-links`)
setSearchSpaceSettingsDialog({
open: true,
initialTab: "public-links",
})
}
className="flex items-center justify-center h-8 w-8 rounded-md bg-muted/50 hover:bg-muted transition-colors"
>

View file

@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types";
import { useMediaQuery } from "@/hooks/use-media-query";
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/);
@ -36,6 +37,7 @@ export function PublicChatSnapshotRow({
}: PublicChatSnapshotRowProps) {
const [copied, setCopied] = useState(false);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
const isDesktop = useMediaQuery("(min-width: 768px)");
const handleCopyClick = useCallback(() => {
onCopy(snapshot);
@ -66,42 +68,42 @@ export function PublicChatSnapshotRow({
</h4>
</div>
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
asChild
className="h-7 w-7 text-muted-foreground hover:text-foreground"
>
<a href={snapshot.public_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-3 w-3" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>Open link</TooltipContent>
</Tooltip>
</TooltipProvider>
{canDelete && (
<TooltipProvider>
<Tooltip>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
asChild
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => onDelete(snapshot)}
disabled={isDeleting}
className="h-7 w-7 text-muted-foreground hover:text-destructive"
>
<a href={snapshot.public_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-3 w-3" />
</a>
<Trash2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Open link</TooltipContent>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</TooltipProvider>
{canDelete && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(snapshot)}
disabled={isDeleting}
className="h-7 w-7 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
</div>
</div>
{/* Message count badge */}
@ -125,25 +127,25 @@ export function PublicChatSnapshotRow({
{snapshot.public_url}
</p>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleCopyClick}
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{copied ? "Copied!" : "Copy link"}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleCopyClick}
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{copied ? "Copied!" : "Copy link"}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{/* Footer: Date + Creator */}
@ -152,33 +154,33 @@ export function PublicChatSnapshotRow({
{member && (
<>
<span className="text-muted-foreground/30">·</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 cursor-default">
{member.avatarUrl ? (
<Image
src={member.avatarUrl}
alt={member.name}
width={18}
height={18}
className="h-4.5 w-4.5 rounded-full object-cover shrink-0"
/>
) : (
<div className="flex h-4.5 w-4.5 items-center justify-center rounded-full bg-gradient-to-br from-primary/20 to-primary/5 shrink-0">
<span className="text-[9px] font-semibold text-primary">
{getInitials(member.name)}
</span>
</div>
)}
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]">
{member.name}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">{member.email || member.name}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 cursor-default">
{member.avatarUrl ? (
<Image
src={member.avatarUrl}
alt={member.name}
width={18}
height={18}
className="h-4.5 w-4.5 rounded-full object-cover shrink-0"
/>
) : (
<div className="flex h-4.5 w-4.5 items-center justify-center rounded-full bg-gradient-to-br from-primary/20 to-primary/5 shrink-0">
<span className="text-[9px] font-semibold text-primary">
{getInitials(member.name)}
</span>
</div>
)}
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]">
{member.name}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">{member.email || member.name}</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
)}
</div>

View file

@ -160,26 +160,27 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
</CardContent>
</Card>
{/* Action Buttons */}
<div className="flex items-center justify-between pt-3 md:pt-4 gap-2">
<Button
variant="outline"
onClick={handleReset}
disabled={!hasChanges || saving}
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
>
<RotateCcw className="h-3.5 w-3.5 md:h-4 md:w-4" />
{t("general_reset")}
</Button>
<Button
onClick={handleSave}
disabled={!hasChanges || saving || !name.trim()}
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
>
<Save className="h-3.5 w-3.5 md:h-4 md:w-4" />
{saving ? t("general_saving") : t("general_save")}
</Button>
</div>
{/* Action Buttons */}
<div className="flex items-center justify-between pt-3 md:pt-4 gap-2">
<Button
variant="secondary"
onClick={handleReset}
disabled={!hasChanges || saving}
className="gap-2"
>
<RotateCcw className="h-3.5 w-3.5 md:h-4 md:w-4" />
{t("general_reset")}
</Button>
<Button
variant="outline"
onClick={handleSave}
disabled={!hasChanges || saving || !name.trim()}
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
>
<Save className="h-3.5 w-3.5 md:h-4 md:w-4" />
{saving ? t("general_saving") : t("general_save")}
</Button>
</div>
{hasChanges && (
<Alert

View file

@ -13,7 +13,6 @@ import {
Trash2,
Wand2,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
@ -70,6 +69,7 @@ import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useMediaQuery } from "@/hooks/use-media-query";
import {
getImageGenModelsByProvider,
IMAGE_GEN_PROVIDERS,
@ -82,16 +82,6 @@ interface ImageModelManagerProps {
searchSpaceId: number;
}
const container = {
hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.05 } },
};
const item = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 },
};
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
@ -101,6 +91,7 @@ function getInitials(name: string): string {
}
export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
const isDesktop = useMediaQuery("(min-width: 768px)");
// Image gen config atoms
const {
mutateAsync: createConfig,
@ -281,46 +272,40 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
<div className="space-y-4 md:space-y-6">
{/* Header */}
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<Button
variant="secondary"
size="sm"
onClick={() => refreshConfigs()}
disabled={isLoading}
className="gap-2"
>
<RefreshCw className={cn("h-3.5 w-3.5", configsLoading && "animate-spin")} />
Refresh
</Button>
{canCreate && (
<Button
variant="outline"
size="sm"
onClick={() => refreshConfigs()}
disabled={isLoading}
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
onClick={openNewDialog}
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
>
<RefreshCw className={cn("h-3 w-3 md:h-4 md:w-4", configsLoading && "animate-spin")} />
Refresh
Add Image Model
</Button>
{canCreate && (
<Button
onClick={openNewDialog}
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
>
Add Image Model
</Button>
)}
)}
</div>
{/* Errors */}
<AnimatePresence>
{errors.map((err) => (
<motion.div
key={err?.message}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<Alert variant="destructive" className="py-3">
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">{err?.message}</AlertDescription>
</Alert>
</motion.div>
))}
</AnimatePresence>
{errors.map((err) => (
<div key={err?.message}>
<Alert variant="destructive" className="py-3">
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">{err?.message}</AlertDescription>
</Alert>
</div>
))}
{/* Read-only / Limited permissions notice */}
{access && !isLoading && isReadOnly && (
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
<div>
<Alert className="bg-muted/50 py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
@ -328,10 +313,10 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
configurations. Contact a space owner to request additional permissions.
</AlertDescription>
</Alert>
</motion.div>
</div>
)}
{access && !isLoading && !isReadOnly && (!canCreate || !canDelete) && (
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
<div>
<Alert className="bg-muted/50 py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
@ -343,7 +328,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{!canDelete && ", but cannot delete them"}.
</AlertDescription>
</Alert>
</motion.div>
</div>
)}
{/* Global info */}
@ -429,23 +414,12 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
</CardContent>
</Card>
) : (
<motion.div
variants={container}
initial="hidden"
animate="show"
className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3"
>
<AnimatePresence mode="popLayout">
{userConfigs?.map((config) => {
const member = config.user_id ? memberMap.get(config.user_id) : null;
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
{userConfigs?.map((config) => {
const member = config.user_id ? memberMap.get(config.user_id) : null;
return (
<motion.div
key={config.id}
variants={item}
layout
exit={{ opacity: 0, scale: 0.95 }}
>
return (
<div key={config.id}>
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
<CardContent className="p-4 flex flex-col gap-3 h-full">
{/* Header: Name + Actions */}
@ -464,7 +438,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
{canUpdate && (
<TooltipProvider>
<Tooltip>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<Button
variant="ghost"
@ -481,7 +455,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
)}
{canDelete && (
<TooltipProvider>
<Tooltip>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<Button
variant="ghost"
@ -521,7 +495,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
<>
<span className="text-muted-foreground/30">·</span>
<TooltipProvider>
<Tooltip>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 cursor-default">
{member.avatarUrl ? (
@ -554,11 +528,10 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
</div>
</CardContent>
</Card>
</motion.div>
);
})}
</AnimatePresence>
</motion.div>
</div>
);
})}
</div>
)}
</div>
)}
@ -732,22 +705,20 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-4 border-t">
<Button
variant="outline"
className="flex-1"
onClick={() => {
setIsDialogOpen(false);
setEditingConfig(null);
resetForm();
}}
>
Cancel
</Button>
<Button
className="flex-1"
onClick={handleFormSubmit}
{/* Actions */}
<div className="flex justify-end gap-3 pt-4 border-t">
<Button
variant="secondary"
onClick={() => {
setIsDialogOpen(false);
setEditingConfig(null);
resetForm();
}}
>
Cancel
</Button>
<Button
onClick={handleFormSubmit}
disabled={
isSubmitting ||
!formData.name ||

View file

@ -13,7 +13,6 @@ import {
Save,
Shuffle,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import {
@ -228,15 +227,15 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
<div className="space-y-5 md:space-y-6">
{/* Header actions */}
<div className="flex items-center justify-between">
<Button
variant="outline"
size="sm"
onClick={() => refreshConfigs()}
disabled={isLoading}
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
>
<RefreshCw className="h-3 w-3 md:h-4 md:w-4" />
Refresh
<Button
variant="secondary"
size="sm"
onClick={() => refreshConfigs()}
disabled={isLoading}
className="gap-2"
>
<RefreshCw className="h-3.5 w-3.5" />
Refresh
</Button>
{isAssignmentComplete && !isLoading && !hasError && (
<Badge
@ -250,25 +249,18 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
</div>
{/* Error Alert */}
<AnimatePresence>
{hasError && (
<motion.div
key="error-alert"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<Alert variant="destructive" className="py-3 md:py-4">
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
{(configsError?.message ?? "Failed to load LLM configurations") ||
(preferencesError?.message ?? "Failed to load preferences") ||
(globalConfigsError?.message ?? "Failed to load global configurations")}
</AlertDescription>
</Alert>
</motion.div>
)}
</AnimatePresence>
{hasError && (
<div>
<Alert variant="destructive" className="py-3 md:py-4">
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
{(configsError?.message ?? "Failed to load LLM configurations") ||
(preferencesError?.message ?? "Failed to load preferences") ||
(globalConfigsError?.message ?? "Failed to load global configurations")}
</AlertDescription>
</Alert>
</div>
)}
{/* Loading Skeleton */}
{isLoading && (
@ -322,13 +314,8 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
{/* Role Assignment Cards */}
{!isLoading && !hasError && hasAnyConfigs && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="grid gap-4 grid-cols-1 lg:grid-cols-2"
>
{Object.entries(ROLE_DESCRIPTIONS).map(([key, role], index) => {
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
{Object.entries(ROLE_DESCRIPTIONS).map(([key, role]) => {
const IconComponent = role.icon;
const isImageRole = role.configType === "image";
const currentAssignment = assignments[role.prefKey as keyof typeof assignments];
@ -349,12 +336,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
assignedConfig && "is_auto_mode" in assignedConfig && assignedConfig.is_auto_mode;
return (
<motion.div
key={key}
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.08, duration: 0.3 }}
>
<div key={key}>
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
<CardContent className="p-4 md:p-5 space-y-4">
{/* Role Header */}
@ -542,47 +524,39 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
)}
</CardContent>
</Card>
</motion.div>
</div>
);
})}
</motion.div>
</div>
)}
{/* Save / Reset Bar */}
<AnimatePresence>
{hasChanges && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/50 p-3 md:p-4"
>
<p className="text-xs md:text-sm text-muted-foreground">You have unsaved changes</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={isSaving}
className="h-8 text-xs gap-1.5"
>
<RotateCcw className="w-3 h-3" />
Reset
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={isSaving}
className="h-8 text-xs gap-1.5"
>
<Save className="w-3 h-3" />
{isSaving ? "Saving…" : "Save Changes"}
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
{hasChanges && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/50 p-3 md:p-4">
<p className="text-xs md:text-sm text-muted-foreground">You have unsaved changes</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={isSaving}
className="h-8 text-xs gap-1.5"
>
<RotateCcw className="w-3 h-3" />
Reset
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={isSaving}
className="h-8 text-xs gap-1.5"
>
<Save className="w-3 h-3" />
{isSaving ? "Saving…" : "Save Changes"}
</Button>
</div>
</div>
)}
</div>
);
}

View file

@ -12,7 +12,6 @@ import {
Trash2,
Wand2,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { useCallback, useMemo, useState } from "react";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
@ -51,6 +50,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { NewLLMConfig } from "@/contracts/types/new-llm-config.types";
import { useMediaQuery } from "@/hooks/use-media-query";
import { getProviderIcon } from "@/lib/provider-icons";
import { cn } from "@/lib/utils";
@ -58,21 +58,6 @@ interface ModelConfigManagerProps {
searchSpaceId: number;
}
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05,
},
},
};
const item = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 },
};
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
@ -82,6 +67,7 @@ function getInitials(name: string): string {
}
export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
const isDesktop = useMediaQuery("(min-width: 768px)");
// Mutations
const { mutateAsync: createConfig, isPending: isCreating } = useAtomValue(
createNewLLMConfigMutationAtom
@ -194,49 +180,42 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
<div className="space-y-5 md:space-y-6">
{/* Header actions */}
<div className="flex items-center justify-between">
<Button
variant="secondary"
size="sm"
onClick={() => refreshConfigs()}
disabled={isLoading}
className="gap-2"
>
<RefreshCw className={cn("h-3.5 w-3.5", isLoading && "animate-spin")} />
Refresh
</Button>
{canCreate && (
<Button
variant="outline"
size="sm"
onClick={() => refreshConfigs()}
disabled={isLoading}
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
onClick={openNewDialog}
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
>
<RefreshCw className={cn("h-3 w-3 md:h-4 md:w-4", isLoading && "animate-spin")} />
Refresh
Add Configuration
</Button>
{canCreate && (
<Button
onClick={openNewDialog}
size="sm"
className="flex items-center gap-2 text-xs md:text-sm h-8 md:h-9"
>
Add Configuration
</Button>
)}
</div>
{/* Fetch Error Alert */}
<AnimatePresence>
{fetchError && (
<motion.div
key="fetch-error"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<Alert variant="destructive" className="py-3 md:py-4">
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
{fetchError?.message ?? "Failed to load configurations"}
</AlertDescription>
</Alert>
</motion.div>
)}
</AnimatePresence>
{fetchError && (
<div>
<Alert variant="destructive" className="py-3 md:py-4">
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
{fetchError?.message ?? "Failed to load configurations"}
</AlertDescription>
</Alert>
</div>
)}
{/* Read-only / Limited permissions notice */}
{access && !isLoading && isReadOnly && (
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
<div>
<Alert className="bg-muted/50 py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
@ -244,10 +223,10 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
Contact a space owner to request additional permissions.
</AlertDescription>
</Alert>
</motion.div>
</div>
)}
{access && !isLoading && !isReadOnly && (!canCreate || !canUpdate || !canDelete) && (
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
<div>
<Alert className="bg-muted/50 py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
@ -259,12 +238,12 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
{!canDelete && ", but cannot delete them"}.
</AlertDescription>
</Alert>
</motion.div>
</div>
)}
{/* Global Configs Info */}
{globalConfigs.length > 0 && (
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }}>
<div>
<Alert className="bg-muted/50 py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
@ -275,7 +254,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
</span>
</AlertDescription>
</Alert>
</motion.div>
</div>
)}
{/* Loading Skeleton */}
@ -317,7 +296,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
{!isLoading && (
<div className="space-y-4">
{configs?.length === 0 ? (
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}>
<div>
<Card className="border-dashed border-2 border-muted-foreground/25">
<CardContent className="flex flex-col items-center justify-center py-10 md:py-16 text-center">
<div className="rounded-full bg-gradient-to-br from-violet-500/10 to-purple-500/10 p-4 md:p-6 mb-4 md:mb-6">
@ -343,25 +322,14 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
)}
</CardContent>
</Card>
</motion.div>
</div>
) : (
<motion.div
variants={container}
initial="hidden"
animate="show"
className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3"
>
<AnimatePresence mode="popLayout">
{configs?.map((config) => {
const member = config.user_id ? memberMap.get(config.user_id) : null;
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
{configs?.map((config) => {
const member = config.user_id ? memberMap.get(config.user_id) : null;
return (
<motion.div
key={config.id}
variants={item}
layout
exit={{ opacity: 0, scale: 0.95 }}
>
return (
<div key={config.id}>
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
<CardContent className="p-4 flex flex-col gap-3 h-full">
{/* Header: Name + Actions */}
@ -380,7 +348,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
{canUpdate && (
<TooltipProvider>
<Tooltip>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<Button
variant="ghost"
@ -397,7 +365,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
)}
{canDelete && (
<TooltipProvider>
<Tooltip>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<Button
variant="ghost"
@ -460,7 +428,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
<>
<span className="text-muted-foreground/30">·</span>
<TooltipProvider>
<Tooltip>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 cursor-default">
{member.avatarUrl ? (
@ -493,11 +461,10 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
</div>
</CardContent>
</Card>
</motion.div>
</div>
);
})}
</AnimatePresence>
</motion.div>
</div>
)}
</div>
)}

View file

@ -183,26 +183,27 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
</CardContent>
</Card>
{/* Action Buttons */}
<div className="flex items-center justify-between pt-3 md:pt-4 gap-2">
<Button
variant="outline"
onClick={handleReset}
disabled={!hasChanges || saving}
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
>
<RotateCcw className="h-3.5 w-3.5 md:h-4 md:w-4" />
Reset Changes
</Button>
<Button
onClick={handleSave}
disabled={!hasChanges || saving}
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
>
<Save className="h-3.5 w-3.5 md:h-4 md:w-4" />
{saving ? "Saving" : "Save Instructions"}
</Button>
</div>
{/* Action Buttons */}
<div className="flex items-center justify-between pt-3 md:pt-4 gap-2">
<Button
variant="secondary"
onClick={handleReset}
disabled={!hasChanges || saving}
className="gap-2"
>
<RotateCcw className="h-3.5 w-3.5 md:h-4 md:w-4" />
Reset Changes
</Button>
<Button
variant="outline"
onClick={handleSave}
disabled={!hasChanges || saving}
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
>
<Save className="h-3.5 w-3.5 md:h-4 md:w-4" />
{saving ? "Saving" : "Save Instructions"}
</Button>
</div>
{hasChanges && (
<Alert

View file

@ -14,13 +14,11 @@ import {
Mic,
MoreHorizontal,
Plug,
Plus,
Settings,
Shield,
Trash2,
Users,
} from "lucide-react";
import { motion } from "motion/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
@ -477,12 +475,7 @@ function RolesContent({
const editingRole = editingRoleId !== null ? roles.find((r) => r.id === editingRoleId) : null;
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-6"
>
<div className="space-y-6">
{canCreate && (
<div className="flex justify-end">
<Button
@ -490,7 +483,6 @@ function RolesContent({
onClick={() => setShowCreateRole(true)}
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
>
<Plus className="h-4 w-4" />
Create Custom Role
</Button>
</div>
@ -516,13 +508,8 @@ function RolesContent({
)}
<div className="space-y-3">
{roles.map((role, index) => (
<motion.div
key={role.id}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.04 }}
>
{roles.map((role) => (
<div key={role.id}>
<RolePermissionsDialog permissions={role.permissions} roleName={role.name}>
<button
type="button"
@ -610,10 +597,10 @@ function RolesContent({
)}
</button>
</RolePermissionsDialog>
</motion.div>
</div>
))}
</div>
</motion.div>
</div>
);
}
@ -695,18 +682,11 @@ function PermissionsEditor({
return (
<div key={category} className="rounded-lg border border-border/60 overflow-hidden">
<div
role="button"
tabIndex={0}
className="w-full flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-muted/40 transition-colors"
onClick={() => toggleCategoryExpanded(category)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleCategoryExpanded(category);
}
}}
>
<button
type="button"
className="w-full flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-muted/40 transition-colors"
onClick={() => toggleCategoryExpanded(category)}
>
<div className="flex items-center gap-2.5">
<IconComponent className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="font-medium text-sm">{config.label}</span>
@ -721,9 +701,11 @@ function PermissionsEditor({
onClick={(e) => e.stopPropagation()}
aria-label={`Select all ${config.label} permissions`}
/>
<motion.div
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: 0.2 }}
<div
className={cn(
"transition-transform duration-200",
isExpanded && "rotate-180"
)}
>
<svg
className="h-4 w-4 text-muted-foreground"
@ -740,18 +722,12 @@ function PermissionsEditor({
d="M19 9l-7 7-7-7"
/>
</svg>
</motion.div>
</div>
</div>
</div>
</button>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="border-t border-border/60"
>
<div className="border-t border-border/60">
<div className="p-2 space-y-0.5">
{perms.map((perm) => {
const action = perm.value.split(":")[1];
@ -759,21 +735,14 @@ function PermissionsEditor({
const isSelected = selectedPermissions.includes(perm.value);
return (
<div
<button
key={perm.value}
role="button"
tabIndex={0}
type="button"
className={cn(
"w-full flex items-center justify-between gap-3 px-2.5 py-2 rounded-md cursor-pointer transition-colors",
isSelected ? "bg-muted/60 hover:bg-muted/80" : "hover:bg-muted/40"
)}
onClick={() => onTogglePermission(perm.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onTogglePermission(perm.value);
}
}}
>
<div className="flex-1 min-w-0 text-left">
<span className="text-sm font-medium">{actionLabel}</span>
@ -787,11 +756,11 @@ function PermissionsEditor({
onClick={(e) => e.stopPropagation()}
className="shrink-0"
/>
</div>
</button>
);
})}
</div>
</motion.div>
</div>
)}
</div>
);
@ -964,11 +933,11 @@ function CreateRoleDialog({
/>
</div>
</div>
<div className="flex items-center justify-end gap-3 px-5 py-3 shrink-0">
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={creating || !name.trim()}>
<div className="flex items-center justify-end gap-3 px-5 py-3 shrink-0">
<Button variant="secondary" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={creating || !name.trim()}>
{creating ? (
<>
<Spinner size="sm" className="mr-2" />
@ -1122,10 +1091,10 @@ function EditRoleDialog({
/>
</div>
</div>
<div className="flex items-center justify-end gap-3 px-5 py-3 border-t shrink-0">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<div className="flex items-center justify-end gap-3 px-5 py-3 border-t shrink-0">
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving || !name.trim()}>
{saving ? (
<>

View file

@ -0,0 +1,65 @@
"use client";
import { useAtom } from "jotai";
import { Bot, Brain, FileText, Globe, ImageIcon, MessageSquare, Shield } from "lucide-react";
import { useTranslations } from "next-intl";
import type React from "react";
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { PublicChatSnapshotsManager } from "@/components/public-chat-snapshots/public-chat-snapshots-manager";
import { GeneralSettingsManager } from "@/components/settings/general-settings-manager";
import { ImageModelManager } from "@/components/settings/image-model-manager";
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
import { ModelConfigManager } from "@/components/settings/model-config-manager";
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
import { RolesManager } from "@/components/settings/roles-manager";
import { SettingsDialog } from "@/components/settings/settings-dialog";
interface SearchSpaceSettingsDialogProps {
searchSpaceId: number;
}
export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettingsDialogProps) {
const t = useTranslations("searchSpaceSettings");
const [state, setState] = useAtom(searchSpaceSettingsDialogAtom);
const navItems = [
{ value: "general", label: t("nav_general"), icon: <FileText className="h-4 w-4" /> },
{ value: "models", label: t("nav_agent_configs"), icon: <Bot className="h-4 w-4" /> },
{ value: "roles", label: t("nav_role_assignments"), icon: <Brain className="h-4 w-4" /> },
{
value: "image-models",
label: t("nav_image_models"),
icon: <ImageIcon className="h-4 w-4" />,
},
{ value: "team-roles", label: t("nav_team_roles"), icon: <Shield className="h-4 w-4" /> },
{
value: "prompts",
label: t("nav_system_instructions"),
icon: <MessageSquare className="h-4 w-4" />,
},
{ value: "public-links", label: t("nav_public_links"), icon: <Globe className="h-4 w-4" /> },
];
const content: Record<string, React.ReactNode> = {
general: <GeneralSettingsManager searchSpaceId={searchSpaceId} />,
models: <ModelConfigManager searchSpaceId={searchSpaceId} />,
roles: <LLMRoleManager searchSpaceId={searchSpaceId} />,
"image-models": <ImageModelManager searchSpaceId={searchSpaceId} />,
"team-roles": <RolesManager searchSpaceId={searchSpaceId} />,
prompts: <PromptConfigManager searchSpaceId={searchSpaceId} />,
"public-links": <PublicChatSnapshotsManager searchSpaceId={searchSpaceId} />,
};
return (
<SettingsDialog
open={state.open}
onOpenChange={(open) => setState((prev) => ({ ...prev, open }))}
title={t("title")}
navItems={navItems}
activeItem={state.initialTab}
onItemChange={(tab) => setState((prev) => ({ ...prev, initialTab: tab }))}
>
<div className="pt-4">{content[state.initialTab]}</div>
</SettingsDialog>
);
}

View file

@ -0,0 +1,126 @@
"use client";
import type * as React from "react";
import { useCallback, useRef, useState } from "react";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
interface NavItem {
value: string;
label: string;
icon: React.ReactNode;
}
interface SettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
navItems: NavItem[];
activeItem: string;
onItemChange: (value: string) => void;
children: React.ReactNode;
}
export function SettingsDialog({
open,
onOpenChange,
title,
navItems,
activeItem,
onItemChange,
children,
}: SettingsDialogProps) {
const activeRef = useRef<HTMLButtonElement>(null);
const [tabScrollPos, setTabScrollPos] = useState<"start" | "middle" | "end">("start");
const handleTabScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const atStart = el.scrollLeft <= 2;
const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2;
setTabScrollPos(atStart ? "start" : atEnd ? "end" : "middle");
}, []);
const handleItemChange = (value: string) => {
onItemChange(value);
activeRef.current?.scrollIntoView({ inline: "center", block: "nearest", behavior: "smooth" });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="select-none max-w-[900px] w-[95vw] md:w-[90vw] h-[90vh] md:h-[80vh] max-h-[640px] flex flex-col md:flex-row p-0 gap-0 overflow-hidden [--card:var(--background)] dark:[--card:oklch(0.205_0_0)] dark:[--background:oklch(0.205_0_0)]">
<DialogTitle className="sr-only">{title}</DialogTitle>
{/* Desktop: Left sidebar */}
<nav className="hidden md:flex w-[220px] shrink-0 flex-col border-r border-border p-3 pt-6">
<div className="flex flex-col gap-0.5">
{navItems.map((item) => (
<button
key={item.value}
type="button"
onClick={() => onItemChange(item.value)}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors text-left focus:outline-none focus-visible:outline-none",
activeItem === item.value
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
)}
>
{item.icon}
{item.label}
</button>
))}
</div>
</nav>
{/* Mobile: Top header + horizontal tabs */}
<div className="flex md:hidden flex-col shrink-0">
<div className="px-4 pt-4 pb-2">
<h2 className="text-base font-semibold">{title}</h2>
</div>
<div
className="overflow-x-auto scrollbar-hide border-b border-border"
onScroll={handleTabScroll}
style={{
maskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to right, ${tabScrollPos === "start" ? "black" : "transparent"}, black 24px, black calc(100% - 24px), ${tabScrollPos === "end" ? "black" : "transparent"})`,
}}
>
<div className="flex gap-1 px-4 pb-2">
{navItems.map((item) => (
<button
key={item.value}
ref={activeItem === item.value ? activeRef : undefined}
type="button"
onClick={() => handleItemChange(item.value)}
className={cn(
"flex items-center gap-2 whitespace-nowrap rounded-full px-3 py-1.5 text-xs font-medium transition-colors shrink-0 focus:outline-none focus-visible:outline-none",
activeItem === item.value
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
)}
>
{item.icon}
{item.label}
</button>
))}
</div>
</div>
</div>
{/* Content area */}
<div className="flex flex-1 flex-col overflow-hidden min-w-0">
<div className="hidden md:block px-8 pt-6 pb-2">
<h2 className="text-lg font-semibold">
{navItems.find((i) => i.value === activeItem)?.label ?? title}
</h2>
<Separator className="mt-4" />
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden">
<div className="px-4 md:px-8 pb-6 pt-4 md:pt-0 min-w-0">{children}</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,39 @@
"use client";
import { useAtom } from "jotai";
import { KeyRound, User } from "lucide-react";
import { useTranslations } from "next-intl";
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { ApiKeyContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent";
import { ProfileContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ProfileContent";
import { SettingsDialog } from "@/components/settings/settings-dialog";
export function UserSettingsDialog() {
const t = useTranslations("userSettings");
const [state, setState] = useAtom(userSettingsDialogAtom);
const navItems = [
{ value: "profile", label: t("profile_nav_label"), icon: <User className="h-4 w-4" /> },
{
value: "api-key",
label: t("api_key_nav_label"),
icon: <KeyRound className="h-4 w-4" />,
},
];
return (
<SettingsDialog
open={state.open}
onOpenChange={(open) => setState((prev) => ({ ...prev, open }))}
title={t("title")}
navItems={navItems}
activeItem={state.initialTab}
onItemChange={(tab) => setState((prev) => ({ ...prev, initialTab: tab }))}
>
<div className="pt-4">
{state.initialTab === "profile" && <ProfileContent />}
{state.initialTab === "api-key" && <ApiKeyContent />}
</div>
</SettingsDialog>
);
}