Workspaces in Workspaces all around
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Terminal, Eye, Trash2 } from 'lucide-react';
|
||||
import { Terminal, Eye, Trash2, Bot } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -10,7 +10,10 @@ import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Card } from '@/components/Card';
|
||||
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
|
||||
import { WorkspaceLayout } from '@/components/Workspace';
|
||||
import { appRegistry } from '../Workspaces/app-registry';
|
||||
import { createSettingsPanelComponents, type SettingsSection } from './SettingsPanel';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import {
|
||||
@@ -21,38 +24,47 @@ import {
|
||||
} from '@/state/useModels';
|
||||
import type { UserSettings } from '@/state/types/user-settings';
|
||||
|
||||
const GLOBAL_KEY = 'AI_SETTINGS_SELECTED';
|
||||
|
||||
const sections: SettingsSection[] = [
|
||||
{ key: 'chat-defaults', icon: Terminal, title: 'Chat Defaults', description: 'Model, prompt, and temperature', content: <ChatDefaultsSection /> },
|
||||
{ key: 'model-visibility', icon: Eye, title: 'Model Visibility', description: 'Enable or disable models', content: <ModelVisibilitySection /> },
|
||||
];
|
||||
|
||||
const { Sidebar, Content } = createSettingsPanelComponents({
|
||||
globalKey: GLOBAL_KEY,
|
||||
sidebarIcon: Bot,
|
||||
sidebarLabel: 'AI',
|
||||
sections,
|
||||
});
|
||||
|
||||
const layout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'ai-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'ai-left', appType: null }, size: 20 },
|
||||
{ node: { type: 'panel', id: 'ai-right', appType: null }, size: 80 },
|
||||
],
|
||||
};
|
||||
|
||||
export const AISettings = () => {
|
||||
const [mainTab, setMainTab] = useUserState('ai-settings-tab', 'chat-defaults');
|
||||
const panelComponents: PanelComponents = useMemo(
|
||||
() => ({
|
||||
'ai-left': Sidebar,
|
||||
'ai-right': Content,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center h-full px-4 py-8 overflow-y-auto">
|
||||
<Card className="w-full max-w-2xl h-fit p-6">
|
||||
<Tabs value={mainTab} onValueChange={setMainTab}>
|
||||
<TabsList className="w-full mb-6">
|
||||
<TabsTrigger value="chat-defaults" className="flex-1 gap-2 cursor-pointer">
|
||||
<Terminal className="h-4 w-4" />
|
||||
Chat Defaults
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="model-visibility" className="flex-1 gap-2 cursor-pointer">
|
||||
<Eye className="h-4 w-4" />
|
||||
Model Visibility
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="chat-defaults">
|
||||
<ChatDefaultsSection />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="model-visibility">
|
||||
<ModelVisibilitySection />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} registry={appRegistry} components={panelComponents} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatDefaultsSection = () => {
|
||||
function ChatDefaultsSection() {
|
||||
const { settings, saveSettings } = useSettings();
|
||||
const claudeModels = useVisibleClaudeModels();
|
||||
const openCodeModels = useVisibleOpenCodeModels();
|
||||
@@ -102,7 +114,7 @@ const ChatDefaultsSection = () => {
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Model</span>
|
||||
<Select value={model ?? ''} onValueChange={(v) => setModel(v || null)}>
|
||||
<SelectTrigger className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark">
|
||||
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark">
|
||||
<SelectValue placeholder="Default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[600]">
|
||||
@@ -119,7 +131,7 @@ const ChatDefaultsSection = () => {
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">System Prompt</span>
|
||||
<Textarea
|
||||
className="bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40 min-h-[100px]"
|
||||
className="bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40 min-h-[100px]"
|
||||
placeholder="Custom instructions for the AI..."
|
||||
value={systemPrompt}
|
||||
onChange={(ev) => setSystemPrompt(ev.target.value)}
|
||||
@@ -137,7 +149,7 @@ const ChatDefaultsSection = () => {
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Default Working Directory</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
value={defaultPwd}
|
||||
onChange={(ev) => setDefaultPwd(ev.target.value)}
|
||||
placeholder="~"
|
||||
@@ -154,9 +166,9 @@ const ChatDefaultsSection = () => {
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const ModelVisibilitySection = () => {
|
||||
function ModelVisibilitySection() {
|
||||
const { settings, saveSettings } = useSettings();
|
||||
const claudeModels = useClaudeModels();
|
||||
const openCodeModels = useOpenCodeModels();
|
||||
@@ -234,7 +246,7 @@ const ModelVisibilitySection = () => {
|
||||
{addingProvider ? (
|
||||
<>
|
||||
<Select value={selectedNewProvider} onValueChange={setSelectedNewProvider}>
|
||||
<SelectTrigger className="h-9 flex-1 bg-white/60 border-duck-dark/20 text-duck-dark text-sm">
|
||||
<SelectTrigger className="h-9 flex-1 bg-background/60 border-duck-dark/20 text-duck-dark text-sm">
|
||||
<SelectValue placeholder="Select provider..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[600]">
|
||||
@@ -282,7 +294,7 @@ const ModelVisibilitySection = () => {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
type ProviderGroup = { provider: string; models: { id: string; name: string }[] };
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ export const ChangePassword = () => {
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">{t('settings.profile.changePassword.currentPasswordLabel')}</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={t('settings.profile.changePassword.currentPasswordPlaceholder')}
|
||||
@@ -63,7 +63,7 @@ export const ChangePassword = () => {
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">{t('settings.profile.changePassword.newPasswordLabel')}</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="newPassword"
|
||||
placeholder={t('settings.profile.changePassword.newPasswordPlaceholder')}
|
||||
@@ -74,7 +74,7 @@ export const ChangePassword = () => {
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">{t('settings.profile.changePassword.confirmPasswordLabel')}</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
placeholder={t('settings.profile.changePassword.confirmPasswordPlaceholder')}
|
||||
|
||||
@@ -91,7 +91,7 @@ export const Languages = () => {
|
||||
<select
|
||||
value={addingLang}
|
||||
onChange={(ev) => addSpoken(ev.target.value)}
|
||||
className="text-sm border border-duck-dark/20 rounded-md px-3 py-1.5 bg-white/60 text-duck-dark cursor-pointer"
|
||||
className="text-sm border border-duck-dark/20 rounded-md px-3 py-1.5 bg-background/60 text-duck-dark cursor-pointer"
|
||||
>
|
||||
<option value="">{t('settings.profile.languages.addLanguage')}</option>
|
||||
{availableToAdd.map((l) => (
|
||||
@@ -110,7 +110,7 @@ export const Languages = () => {
|
||||
<select
|
||||
value={defaultLang}
|
||||
onChange={(ev) => save({ ...settings.languages, default: ev.target.value })}
|
||||
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-white/60 text-duck-dark cursor-pointer"
|
||||
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer"
|
||||
>
|
||||
{spoken.map((code) => (
|
||||
<option key={code} value={code}>
|
||||
@@ -127,7 +127,7 @@ export const Languages = () => {
|
||||
<select
|
||||
value={translateTo}
|
||||
onChange={(ev) => save({ ...settings.languages, translateTo: ev.target.value })}
|
||||
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-white/60 text-duck-dark cursor-pointer"
|
||||
className="text-sm border border-duck-dark/20 rounded-md px-3 py-2 bg-background/60 text-duck-dark cursor-pointer"
|
||||
>
|
||||
{LANGUAGES.map((l) => (
|
||||
<option key={l.code} value={l.code}>
|
||||
|
||||
@@ -58,7 +58,7 @@ export const TaskDefaults = () => {
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Default Model</span>
|
||||
<Select value={model ?? ''} onValueChange={(v) => setModel(v || null)}>
|
||||
<SelectTrigger className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark">
|
||||
<SelectTrigger className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark">
|
||||
<SelectValue placeholder="Same as chat default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[600] max-h-[300px]">
|
||||
|
||||
@@ -95,7 +95,7 @@ export const UserData = () => {
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Email</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="email"
|
||||
value={user?.email ?? ''}
|
||||
disabled
|
||||
@@ -105,7 +105,7 @@ export const UserData = () => {
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-duck-dark/70">Name</span>
|
||||
<Input
|
||||
className="h-11 bg-white/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Your name"
|
||||
|
||||
@@ -1,106 +1,52 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Search, User, Lock, Globe, ListChecks } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
|
||||
import { Card } from '@/components/Card';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { useMemo } from 'react';
|
||||
import { User, Lock, Globe, ListChecks } from 'lucide-react';
|
||||
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
|
||||
import { WorkspaceLayout } from '@/components/Workspace';
|
||||
import { appRegistry } from '../../Workspaces/app-registry';
|
||||
import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
|
||||
import { UserData } from './UserData';
|
||||
import { ChangePassword } from './ChangePassword';
|
||||
import { Languages } from './Languages';
|
||||
import { TaskDefaults } from './TaskDefaults';
|
||||
|
||||
export const ProfileSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [expanded, setExpanded] = useState<string[]>([]);
|
||||
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const GLOBAL_KEY = 'PROFILE_SETTINGS_SELECTED';
|
||||
|
||||
const sections = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'profile',
|
||||
icon: User,
|
||||
title: t('settings.profile.sections.profileTitle'),
|
||||
description: t('settings.profile.sections.profileDescription'),
|
||||
content: <UserData />,
|
||||
},
|
||||
{
|
||||
key: 'change-password',
|
||||
icon: Lock,
|
||||
title: t('settings.profile.sections.changePasswordTitle'),
|
||||
description: t('settings.profile.sections.changePasswordDescription'),
|
||||
content: <ChangePassword />,
|
||||
},
|
||||
{
|
||||
key: 'tasks',
|
||||
icon: ListChecks,
|
||||
title: t('settings.profile.sections.tasksTitle'),
|
||||
description: t('settings.profile.sections.tasksDescription'),
|
||||
content: <TaskDefaults />,
|
||||
},
|
||||
{
|
||||
key: 'languages',
|
||||
icon: Globe,
|
||||
title: t('settings.profile.sections.languagesTitle'),
|
||||
description: t('settings.profile.sections.languagesDescription'),
|
||||
content: <Languages />,
|
||||
},
|
||||
],
|
||||
[t],
|
||||
const sections: SettingsSection[] = [
|
||||
{ key: 'profile', icon: User, title: 'Profile', description: 'Avatar, name, and email', content: <UserData /> },
|
||||
{ key: 'change-password', icon: Lock, title: 'Change Password', description: 'Update your password', content: <ChangePassword /> },
|
||||
{ key: 'tasks', icon: ListChecks, title: 'Task Defaults', description: 'Default model for tasks', content: <TaskDefaults /> },
|
||||
{ key: 'languages', icon: Globe, title: 'Languages', description: 'Spoken, default, and translation', content: <Languages /> },
|
||||
];
|
||||
|
||||
const { Sidebar, Content } = createSettingsPanelComponents({
|
||||
globalKey: GLOBAL_KEY,
|
||||
sidebarIcon: User,
|
||||
sidebarLabel: 'Profile',
|
||||
sections,
|
||||
});
|
||||
|
||||
const layout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'profile-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'profile-left', appType: null }, size: 20 },
|
||||
{ node: { type: 'panel', id: 'profile-right', appType: null }, size: 80 },
|
||||
],
|
||||
};
|
||||
|
||||
export const ProfileSettings = () => {
|
||||
const panelComponents: PanelComponents = useMemo(
|
||||
() => ({
|
||||
'profile-left': Sidebar,
|
||||
'profile-right': Content,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const allKeys = useMemo(() => sections.map((s) => s.key), [sections]);
|
||||
|
||||
const matchingKeys = useMemo(() => {
|
||||
if (!search) return allKeys;
|
||||
const query = search.toLowerCase();
|
||||
return sections
|
||||
.filter((s) => {
|
||||
const el = sectionRefs.current[s.key];
|
||||
return (el?.textContent?.toLowerCase() ?? '').includes(query);
|
||||
})
|
||||
.map((s) => s.key);
|
||||
}, [search, allKeys, sections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (search) setExpanded(matchingKeys);
|
||||
}, [search, matchingKeys]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center h-full px-4 py-8 overflow-y-auto">
|
||||
<Card className="w-full max-w-2xl h-fit p-6">
|
||||
<div className="relative mb-6">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-duck-dark/40" />
|
||||
<Input
|
||||
placeholder={t('settings.profile.searchPlaceholder')}
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Accordion type="multiple" value={expanded} onValueChange={setExpanded}>
|
||||
{sections.map((section) => (
|
||||
<div
|
||||
key={section.key}
|
||||
ref={(el) => {
|
||||
sectionRefs.current[section.key] = el;
|
||||
}}
|
||||
className={search && !matchingKeys.includes(section.key) ? 'hidden' : ''}
|
||||
>
|
||||
<AccordionItem value={section.key}>
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center gap-3">
|
||||
<section.icon className="h-5 w-5 text-duck-forest shrink-0" />
|
||||
<div className="text-base font-bold text-duck-dark">{section.title}</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-1">{section.content}</AccordionContent>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
))}
|
||||
</Accordion>
|
||||
</Card>
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} registry={appRegistry} components={panelComponents} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -29,13 +29,13 @@ const CopyCommand = ({ command }: { command: string }) => {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<code className="flex-1 bg-duck-dark/5 rounded px-2 py-1 text-xs text-duck-dark/70">{command}</code>
|
||||
<code className="flex-1 bg-duck-dark/5 dark:bg-foreground/5 rounded px-2 py-1 text-xs text-duck-dark/70 dark:text-foreground/70">{command}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-600" /> : <Copy className="h-3.5 w-3.5 text-duck-dark/50" />}
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-600" /> : <Copy className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -77,9 +77,9 @@ export const Applications = () => {
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
<h2 className="text-lg font-bold text-duck-dark mb-4" title="System tools and dependencies used by Officer.dev">Applications</h2>
|
||||
<h2 className="text-lg font-bold text-duck-dark dark:text-foreground mb-4" title="System tools and dependencies used by Officer.dev">Applications</h2>
|
||||
|
||||
{isLoading && <p className="text-sm text-duck-dark/50">Checking applications...</p>}
|
||||
{isLoading && <p className="text-sm text-duck-dark/50 dark:text-foreground/50">Checking applications...</p>}
|
||||
|
||||
{apps && (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -88,18 +88,18 @@ export const Applications = () => {
|
||||
const canAutoRun = hasAutoAction(app);
|
||||
|
||||
return (
|
||||
<div key={app.id} className="flex flex-col rounded-lg border border-duck-dark/10 px-4 py-3">
|
||||
<div key={app.id} className="flex flex-col rounded-lg border border-duck-dark/10 dark:border-foreground/10 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-duck-dark">{app.name}</span>
|
||||
<span className="text-sm font-semibold text-duck-dark dark:text-foreground">{app.name}</span>
|
||||
{app.installed && (
|
||||
<span className="text-xs bg-green-100 text-green-700 rounded-full px-2 py-0.5">
|
||||
{app.version}
|
||||
</span>
|
||||
)}
|
||||
{!app.installed && (
|
||||
<span className="text-xs bg-duck-dark/5 text-duck-dark/40 rounded-full px-2 py-0.5">
|
||||
<span className="text-xs bg-duck-dark/5 dark:bg-foreground/5 text-duck-dark/40 dark:text-foreground/40 rounded-full px-2 py-0.5">
|
||||
Not installed
|
||||
</span>
|
||||
)}
|
||||
@@ -109,7 +109,7 @@ export const Applications = () => {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-duck-dark/50 mt-0.5">{app.description}</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-0.5">{app.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
@@ -147,7 +147,7 @@ export const Applications = () => {
|
||||
</div>
|
||||
|
||||
{manualCmd && (
|
||||
<div className="mt-2 text-xs text-duck-dark/50">
|
||||
<div className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
{app.installed ? 'Update' : 'Install'} manually:
|
||||
<CopyCommand command={manualCmd} />
|
||||
</div>
|
||||
|
||||
+4
-6
@@ -36,9 +36,8 @@ export const ResourceSidebar = () => {
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => setSelectedId(r.id)}
|
||||
className={`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${
|
||||
isActive ? 'bg-duck-teal/10 text-duck-dark' : 'text-duck-dark/70 hover:bg-duck-dark/5 hover:text-duck-dark'
|
||||
}`}
|
||||
className={`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${isActive ? 'bg-duck-teal/10 text-duck-dark' : 'text-duck-dark/70 hover:bg-duck-dark/5 hover:text-duck-dark'
|
||||
}`}
|
||||
>
|
||||
{r.port ? (
|
||||
<Server className="h-3.5 w-3.5 text-duck-teal shrink-0 mt-0.5" />
|
||||
@@ -50,9 +49,8 @@ export const ResourceSidebar = () => {
|
||||
<div className="text-xs text-duck-dark/40 truncate">{r.subtitle}</div>
|
||||
</div>
|
||||
<Circle
|
||||
className={`h-2 w-2 shrink-0 mt-1.5 ${
|
||||
r.installed ? 'fill-green-500 text-green-500' : 'fill-duck-dark/20 text-duck-dark/20'
|
||||
}`}
|
||||
className={`h-2 w-2 shrink-0 mt-1.5 ${r.installed ? 'fill-green-500 text-green-500' : 'fill-duck-dark/20 text-duck-dark/20'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,96 +1,48 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Search, Terminal, Shield } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
|
||||
import { Card } from '@/components/Card';
|
||||
import { useMemo } from 'react';
|
||||
import { Terminal, Shield, Server } from 'lucide-react';
|
||||
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
|
||||
import { WorkspaceLayout } from '@/components/Workspace';
|
||||
import { appRegistry } from '../../Workspaces/app-registry';
|
||||
import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel';
|
||||
import { AIHarnessesSection } from './AIHarnessesSection';
|
||||
// import { PluginsSection } from './PluginsSection';
|
||||
import { TerminalSection } from './TerminalSection';
|
||||
|
||||
const sections = [
|
||||
{
|
||||
key: 'ai-harnesses',
|
||||
icon: Terminal,
|
||||
title: 'AI Harnesses',
|
||||
description: 'Which AI coding tools do you use?',
|
||||
content: <AIHarnessesSection />,
|
||||
},
|
||||
// {
|
||||
// key: 'plugins',
|
||||
// icon: Puzzle,
|
||||
// title: 'Plugins',
|
||||
// description: 'Enable or disable installed plugins.',
|
||||
// content: <PluginsSection />,
|
||||
// },
|
||||
{
|
||||
key: 'terminal',
|
||||
icon: Shield,
|
||||
title: 'Terminal',
|
||||
description: 'Sandbox and access controls for the Terminal plugin.',
|
||||
content: <TerminalSection />,
|
||||
},
|
||||
const GLOBAL_KEY = 'SERVER_SETTINGS_SELECTED';
|
||||
|
||||
const sections: SettingsSection[] = [
|
||||
{ key: 'ai-harnesses', icon: Terminal, title: 'AI Harnesses', description: 'AI coding tools setup', content: <AIHarnessesSection /> },
|
||||
{ key: 'terminal', icon: Shield, title: 'Terminal', description: 'Sandbox and access controls', content: <TerminalSection /> },
|
||||
];
|
||||
|
||||
const allKeys = sections.map((s) => s.key);
|
||||
const { Sidebar, Content } = createSettingsPanelComponents({
|
||||
globalKey: GLOBAL_KEY,
|
||||
sidebarIcon: Server,
|
||||
sidebarLabel: 'Server',
|
||||
sections,
|
||||
});
|
||||
|
||||
const layout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'server-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'server-left', appType: null }, size: 20 },
|
||||
{ node: { type: 'panel', id: 'server-right', appType: null }, size: 80 },
|
||||
],
|
||||
};
|
||||
|
||||
export const ServerSettings = () => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [expanded, setExpanded] = useState<string[]>(allKeys);
|
||||
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const matchingKeys = useMemo(() => {
|
||||
if (!search) return allKeys;
|
||||
const query = search.toLowerCase();
|
||||
return sections
|
||||
.filter((s) => {
|
||||
const el = sectionRefs.current[s.key];
|
||||
return (el?.textContent?.toLowerCase() ?? '').includes(query);
|
||||
})
|
||||
.map((s) => s.key);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
setExpanded(search ? matchingKeys : allKeys);
|
||||
}, [search, matchingKeys]);
|
||||
const panelComponents: PanelComponents = useMemo(
|
||||
() => ({
|
||||
'server-left': Sidebar,
|
||||
'server-right': Content,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center h-full px-4 py-8 overflow-y-auto">
|
||||
<Card className="w-full max-w-2xl h-fit p-6">
|
||||
<div className="relative mb-6">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-duck-dark/40" />
|
||||
<Input
|
||||
placeholder="Search settings..."
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Accordion type="multiple" value={expanded} onValueChange={setExpanded}>
|
||||
{sections.map((section) => (
|
||||
<div
|
||||
key={section.key}
|
||||
ref={(el) => {
|
||||
sectionRefs.current[section.key] = el;
|
||||
}}
|
||||
className={search && !matchingKeys.includes(section.key) ? 'hidden' : ''}
|
||||
>
|
||||
<AccordionItem value={section.key}>
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center gap-3">
|
||||
<section.icon className="h-5 w-5 text-duck-forest shrink-0" />
|
||||
<div className="text-left">
|
||||
<div className="text-base font-bold text-duck-dark">{section.title}</div>
|
||||
<div className="text-sm font-normal text-duck-dark/70">{section.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent forceMount>{section.content}</AccordionContent>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
))}
|
||||
</Accordion>
|
||||
</Card>
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} registry={appRegistry} components={panelComponents} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
export type SettingsSection = {
|
||||
key: string;
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
type SettingsSidebarProps = {
|
||||
globalKey: string;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
sections: SettingsSection[];
|
||||
};
|
||||
|
||||
export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections }: SettingsSidebarProps) => {
|
||||
const [selectedKey, setSelectedKey] = useGlobal<string | null>(globalKey, sections[0]?.key ?? null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const query = search.toLowerCase();
|
||||
const filtered = sections.filter(
|
||||
(s) => s.title.toLowerCase().includes(query) || s.description.toLowerCase().includes(query),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-y-auto">
|
||||
<div className="p-3 pb-2">
|
||||
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 pb-2">
|
||||
<Input placeholder="Search..." value={search} onChange={(ev) => setSearch(ev.target.value)} className="h-8 text-xs" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 px-3 overflow-y-auto flex-1">
|
||||
{filtered.map((s) => {
|
||||
const isActive = selectedKey === s.key;
|
||||
return (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => setSelectedKey(s.key)}
|
||||
className={`flex items-start gap-2.5 py-2 px-3 rounded-lg text-left cursor-pointer transition-colors ${
|
||||
isActive ? 'bg-duck-teal/10 text-duck-dark dark:text-foreground' : 'text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 hover:text-duck-dark dark:hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<s.icon className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${isActive ? 'text-duck-teal' : 'text-duck-dark/40 dark:text-foreground/40'}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">{s.title}</div>
|
||||
<div className="text-xs text-duck-dark/40 dark:text-foreground/40 truncate">{s.description}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SettingsContentProps = {
|
||||
globalKey: string;
|
||||
sections: SettingsSection[];
|
||||
};
|
||||
|
||||
export const SettingsContent = ({ globalKey, sections }: SettingsContentProps) => {
|
||||
const [selectedKey] = useGlobal<string | null>(globalKey, sections[0]?.key ?? null);
|
||||
const section = sections.find((s) => s.key === selectedKey);
|
||||
|
||||
if (!section) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<p className="text-sm text-duck-dark/30 dark:text-foreground/30">Select a section</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
<div className="flex items-center gap-2.5 mb-1">
|
||||
<section.icon className="h-4.5 w-4.5 text-duck-teal shrink-0" />
|
||||
<h2 className="text-lg font-bold text-duck-dark dark:text-foreground">{section.title}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-duck-dark/50 dark:text-foreground/50 mb-6">{section.description}</p>
|
||||
<div className="max-w-xl">{section.content}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type CreateSettingsPanelParams = {
|
||||
globalKey: string;
|
||||
sidebarIcon: LucideIcon;
|
||||
sidebarLabel: string;
|
||||
sections: SettingsSection[];
|
||||
};
|
||||
|
||||
export const createSettingsPanelComponents = ({ globalKey, sidebarIcon, sidebarLabel, sections }: CreateSettingsPanelParams) => {
|
||||
const Sidebar: ComponentType = () => (
|
||||
<SettingsSidebar globalKey={globalKey} icon={sidebarIcon} label={sidebarLabel} sections={sections} />
|
||||
);
|
||||
const Content: ComponentType = () => <SettingsContent globalKey={globalKey} sections={sections} />;
|
||||
return { Sidebar, Content };
|
||||
};
|
||||
Reference in New Issue
Block a user