mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
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:
parent
60d12b0a70
commit
b7d684ca8d
19 changed files with 646 additions and 483 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
21
surfsense_web/atoms/settings/settings-dialog.atoms.ts
Normal file
21
surfsense_web/atoms/settings/settings-dialog.atoms.ts
Normal 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",
|
||||
});
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
126
surfsense_web/components/settings/settings-dialog.tsx
Normal file
126
surfsense_web/components/settings/settings-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
surfsense_web/components/settings/user-settings-dialog.tsx
Normal file
39
surfsense_web/components/settings/user-settings-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue