Files
zui/frontend/src/app/kosmos/SettingsDialog.tsx
2026-03-11 17:24:32 +01:00

195 lines
8.0 KiB
TypeScript

/**
* Settings dialog with sidebar-style nav (Appearance, AI). Reference: shadcn sidebar-13.
*/
import React, { useCallback, useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { useTheme } from '@/lib/themeContext'
import type { Theme } from '@/lib/themeContext'
import { usePlatform } from './KosmosContext'
import type { AiConnection, AiConnectionProvider } from './KosmosContext'
import { Sun, Sparkles } from 'lucide-react'
type SettingsSection = 'appearance' | 'ai'
export function SettingsDialog({
trigger,
}: {
trigger: React.ReactNode
}) {
const [open, setOpen] = useState(false)
const [section, setSection] = useState<SettingsSection>('appearance')
const { theme, setTheme } = useTheme()
const { showMinimap, setShowMinimap, aiConnection, setAiConnection } = usePlatform()
const updateAiConnection = useCallback(
(partial: Partial<AiConnection>) => {
setAiConnection({ ...aiConnection, ...partial })
},
[aiConnection, setAiConnection]
)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent
className="flex h-[min(85vh,28rem)] max-w-2xl p-0 gap-0 overflow-hidden"
aria-describedby={undefined}
>
<DialogHeader className="sr-only">
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
<div className="flex flex-1 min-h-0 w-full">
<nav
className="flex w-44 shrink-0 flex-col gap-1 border-r bg-muted/30 p-2"
aria-label="Settings sections"
>
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Settings
</div>
<Button
variant={section === 'appearance' ? 'secondary' : 'ghost'}
size="sm"
className="justify-start gap-2"
onClick={() => setSection('appearance')}
>
<Sun className="size-4 shrink-0 opacity-70" />
Appearance
</Button>
<Button
variant={section === 'ai' ? 'secondary' : 'ghost'}
size="sm"
className="justify-start gap-2"
onClick={() => setSection('ai')}
>
<Sparkles className="size-4 shrink-0 opacity-70" />
AI
</Button>
</nav>
<div className="flex-1 min-h-0 overflow-auto p-4">
{section === 'appearance' && (
<div className="space-y-6">
<h3 className="text-sm font-medium text-muted-foreground">Appearance</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<label htmlFor="settings-theme" className="text-sm font-medium">
Theme
</label>
<Select value={theme} onValueChange={(v) => setTheme(v as Theme)}>
<SelectTrigger id="settings-theme" className="w-full min-w-[8rem] sm:w-40">
<SelectValue placeholder="Theme" />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3 pt-2 border-t">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Canvas
</h4>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<label htmlFor="settings-minimap" className="text-sm font-medium">
Minimap
</label>
<Switch
id="settings-minimap"
checked={showMinimap}
onCheckedChange={setShowMinimap}
/>
</div>
</div>
</div>
)}
{section === 'ai' && (
<div className="space-y-6">
<h3 className="text-sm font-medium text-muted-foreground">AI connection</h3>
<p className="text-xs text-muted-foreground">
Used by the Agent node. Choose OpenAI or a local server (e.g. LM Studio).
</p>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<label htmlFor="settings-ai-provider" className="text-sm font-medium">
Provider
</label>
<Select
value={aiConnection.provider}
onValueChange={(v) => updateAiConnection({ provider: v as AiConnectionProvider })}
>
<SelectTrigger id="settings-ai-provider" className="w-full min-w-[10rem] sm:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">Local (LM Studio, Ollama, etc.)</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
</SelectContent>
</Select>
</div>
{aiConnection.provider === 'local' && (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_1fr] sm:items-center">
<label htmlFor="settings-ai-baseurl" className="text-sm font-medium">
Base URL
</label>
<Input
id="settings-ai-baseurl"
type="url"
placeholder="http://localhost:1234/v1"
value={aiConnection.baseURL}
onChange={(e) => updateAiConnection({ baseURL: e.target.value })}
className="font-mono text-xs"
/>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_1fr] sm:items-center">
<label htmlFor="settings-ai-model" className="text-sm font-medium">
Model
</label>
<Input
id="settings-ai-model"
type="text"
placeholder={aiConnection.provider === 'local' ? 'local-model' : 'gpt-4o-mini'}
value={aiConnection.model}
onChange={(e) => updateAiConnection({ model: e.target.value })}
className="font-mono text-xs"
/>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_1fr] sm:items-center">
<label htmlFor="settings-ai-apikey" className="text-sm font-medium">
API key
</label>
<Input
id="settings-ai-apikey"
type="password"
autoComplete="off"
placeholder={aiConnection.provider === 'openai' ? 'sk-...' : 'Optional for local'}
value={aiConnection.apiKey}
onChange={(e) => updateAiConnection({ apiKey: e.target.value })}
className="font-mono text-xs"
/>
</div>
</div>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}