mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-11 00:32:38 +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
|
|
@ -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