feat: settings page
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
'use client';
|
||||
|
||||
import { use, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { IconTrash } from '@tabler/icons-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import type { AgentProfile, AgentType } from '@/lib/settings/types';
|
||||
|
||||
const AGENT_LABELS: Record<AgentType, string> = {
|
||||
'claude-code': 'Claude Code',
|
||||
'codex': 'Codex',
|
||||
'auggie': 'Auggie',
|
||||
};
|
||||
|
||||
const MODEL_OPTIONS: Record<AgentType, { value: string; label: string }[]> = {
|
||||
'claude-code': [
|
||||
{ value: 'claude-sonnet-4.5', label: 'Claude Sonnet 4.5' },
|
||||
{ value: 'claude-opus-4', label: 'Claude Opus 4' },
|
||||
{ value: 'claude-haiku-4', label: 'Claude Haiku 4' },
|
||||
],
|
||||
'codex': [
|
||||
{ value: 'codex-v2', label: 'Codex v2' },
|
||||
{ value: 'codex-v1', label: 'Codex v1' },
|
||||
],
|
||||
'auggie': [
|
||||
{ value: 'auggie-v1', label: 'Auggie v1' },
|
||||
{ value: 'auggie-lite', label: 'Auggie Lite' },
|
||||
],
|
||||
};
|
||||
|
||||
export default function AgentEditPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [agent, setAgent] = useState<AgentProfile | undefined>(
|
||||
SETTINGS_DATA.agents.find((a) => a.id === id)
|
||||
);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">Agent not found</p>
|
||||
<Button className="mt-4" onClick={() => router.push('/settings/agents')}>
|
||||
Go to Agents
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDeleteAgent = () => {
|
||||
if (deleteConfirmText === 'delete') {
|
||||
router.push('/settings/agents');
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{agent.name}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Configure agent behavior and capabilities
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Agent Name</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Input
|
||||
value={agent.name}
|
||||
onChange={(e) => setAgent({ ...agent, name: e.target.value })}
|
||||
placeholder="Code Assistant"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Agent Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Agent Type</Label>
|
||||
<Select
|
||||
value={agent.agent}
|
||||
onValueChange={(value) => {
|
||||
const agentType = value as AgentType;
|
||||
setAgent({
|
||||
...agent,
|
||||
agent: agentType,
|
||||
model: MODEL_OPTIONS[agentType][0].value
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="claude-code">Claude Code</SelectItem>
|
||||
<SelectItem value="codex">Codex</SelectItem>
|
||||
<SelectItem value="auggie">Auggie</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Model</Label>
|
||||
<Select
|
||||
value={agent.model}
|
||||
onValueChange={(value) => setAgent({ ...agent, model: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MODEL_OPTIONS[agent.agent].map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Advanced Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Temperature</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Controls randomness in responses (0 = deterministic, 1 = creative)
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm font-medium">{agent.temperature.toFixed(1)}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[agent.temperature]}
|
||||
onValueChange={([value]) => setAgent({ ...agent, temperature: value })}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Auto-approve Actions</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically execute tool calls without confirmation
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={agent.autoApprove}
|
||||
onCheckedChange={(checked) => setAgent({ ...agent, autoApprove: checked })}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="border-destructive">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
||||
<CardDescription>
|
||||
Irreversible actions that will permanently delete this agent
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Delete this agent</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Once deleted, all configuration will be permanently removed
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={() => setDeleteDialogOpen(true)}>
|
||||
<IconTrash className="h-4 w-4 mr-2" />
|
||||
Delete Agent
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Agent</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the agent "
|
||||
{agent.name}" and all its data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<p className="text-sm">
|
||||
Please type <span className="font-mono font-bold">delete</span> to confirm:
|
||||
</p>
|
||||
<Input
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
||||
placeholder="delete"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteAgent}
|
||||
disabled={deleteConfirmText !== 'delete'}
|
||||
>
|
||||
Delete Agent
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { AgentCard } from '@/components/settings/agent-card';
|
||||
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
|
||||
import type { AgentProfile, AgentType } from '@/lib/settings/types';
|
||||
|
||||
const AGENT_OPTIONS: { value: AgentType; label: string }[] = [
|
||||
{ value: 'claude-code', label: 'Claude Code' },
|
||||
{ value: 'codex', label: 'Codex' },
|
||||
{ value: 'auggie', label: 'Auggie' },
|
||||
];
|
||||
|
||||
const MODEL_OPTIONS: Record<AgentType, { value: string; label: string }[]> = {
|
||||
'claude-code': [
|
||||
{ value: 'claude-sonnet-4.5', label: 'Claude Sonnet 4.5' },
|
||||
{ value: 'claude-opus-4', label: 'Claude Opus 4' },
|
||||
{ value: 'claude-haiku-4', label: 'Claude Haiku 4' },
|
||||
],
|
||||
'codex': [
|
||||
{ value: 'codex-v2', label: 'Codex v2' },
|
||||
{ value: 'codex-v1', label: 'Codex v1' },
|
||||
],
|
||||
'auggie': [
|
||||
{ value: 'auggie-v1', label: 'Auggie v1' },
|
||||
{ value: 'auggie-lite', label: 'Auggie Lite' },
|
||||
],
|
||||
};
|
||||
|
||||
export default function AgentsSettingsPage() {
|
||||
const [agents, setAgents] = useState<AgentProfile[]>(SETTINGS_DATA.agents);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newAgent, setNewAgent] = useState<{
|
||||
agent: AgentType;
|
||||
name: string;
|
||||
model: string;
|
||||
autoApprove: boolean;
|
||||
temperature: number;
|
||||
}>({
|
||||
agent: 'claude-code',
|
||||
name: '',
|
||||
model: 'claude-sonnet-4.5',
|
||||
autoApprove: false,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
const handleUpdate = (agent: AgentProfile) => {
|
||||
setAgents(agents.map((a) => (a.id === agent.id ? agent : a)));
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setAgents(agents.filter((a) => a.id !== id));
|
||||
};
|
||||
|
||||
const handleAdd = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const profile: AgentProfile = {
|
||||
id: crypto.randomUUID(),
|
||||
...newAgent,
|
||||
};
|
||||
setAgents([...agents, profile]);
|
||||
setIsAdding(false);
|
||||
setNewAgent({
|
||||
agent: 'claude-code',
|
||||
name: '',
|
||||
model: 'claude-sonnet-4.5',
|
||||
autoApprove: false,
|
||||
temperature: 0.7,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAgentTypeChange = (type: AgentType) => {
|
||||
const defaultModel = MODEL_OPTIONS[type][0].value;
|
||||
setNewAgent({ ...newAgent, agent: type, model: defaultModel });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Agents</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Configure agent profiles and their behavior
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setIsAdding(true)}>
|
||||
<IconPlus className="h-4 w-4 mr-2" />
|
||||
Add Agent Profile
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3">
|
||||
{isAdding && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleAdd} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-name">Profile Name</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={newAgent.name}
|
||||
onChange={(e) => setNewAgent({ ...newAgent, name: e.target.value })}
|
||||
placeholder="My Profile"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-type">Agent Type</Label>
|
||||
<Select
|
||||
value={newAgent.agent}
|
||||
onValueChange={(value) => handleAgentTypeChange(value as AgentType)}
|
||||
>
|
||||
<SelectTrigger id="agent-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AGENT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model">Model</Label>
|
||||
<Select
|
||||
value={newAgent.model}
|
||||
onValueChange={(value) => setNewAgent({ ...newAgent, model: value })}
|
||||
>
|
||||
<SelectTrigger id="model">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MODEL_OPTIONS[newAgent.agent].map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="temperature">Temperature: {newAgent.temperature}</Label>
|
||||
<input
|
||||
id="temperature"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={newAgent.temperature}
|
||||
onChange={(e) =>
|
||||
setNewAgent({ ...newAgent, temperature: parseFloat(e.target.value) })
|
||||
}
|
||||
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>Precise</span>
|
||||
<span>Creative</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 border rounded-md">
|
||||
<div>
|
||||
<Label htmlFor="auto-approve" className="text-base font-medium">
|
||||
Auto-approve all actions
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically approve agent actions without prompting
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
id="auto-approve"
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={newAgent.autoApprove}
|
||||
onClick={() =>
|
||||
setNewAgent({ ...newAgent, autoApprove: !newAgent.autoApprove })
|
||||
}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
newAgent.autoApprove ? 'bg-primary' : 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
|
||||
newAgent.autoApprove ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => setIsAdding(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Add Profile</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{agents.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
/>
|
||||
))}
|
||||
|
||||
{agents.length === 0 && !isAdding && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No agent profiles configured. Add your first profile to get started.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
'use client';
|
||||
|
||||
import { use, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { IconPlus, IconTrash, IconX } from '@tabler/icons-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
|
||||
import type { Environment, EnvironmentType, BaseDocker } from '@/lib/settings/types';
|
||||
|
||||
export default function EnvironmentEditPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [environment, setEnvironment] = useState<Environment | undefined>(
|
||||
SETTINGS_DATA.environments.find((e) => e.id === id)
|
||||
);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
||||
const [newEnvKey, setNewEnvKey] = useState('');
|
||||
const [newEnvValue, setNewEnvValue] = useState('');
|
||||
const [newSecretKey, setNewSecretKey] = useState('');
|
||||
const [newSecretValue, setNewSecretValue] = useState('');
|
||||
|
||||
if (!environment) {
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">Environment not found</p>
|
||||
<Button className="mt-4" onClick={() => router.push('/settings/environments')}>
|
||||
Go to Environments
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleAddEnvVar = () => {
|
||||
if (newEnvKey.trim()) {
|
||||
setEnvironment({
|
||||
...environment,
|
||||
envVariables: [
|
||||
...environment.envVariables,
|
||||
{ id: crypto.randomUUID(), key: newEnvKey, value: newEnvValue },
|
||||
],
|
||||
});
|
||||
setNewEnvKey('');
|
||||
setNewEnvValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveEnvVar = (id: string) => {
|
||||
setEnvironment({
|
||||
...environment,
|
||||
envVariables: environment.envVariables.filter((v) => v.id !== id),
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddSecret = () => {
|
||||
if (newSecretKey.trim()) {
|
||||
setEnvironment({
|
||||
...environment,
|
||||
secrets: [
|
||||
...environment.secrets,
|
||||
{ id: crypto.randomUUID(), key: newSecretKey, value: newSecretValue },
|
||||
],
|
||||
});
|
||||
setNewSecretKey('');
|
||||
setNewSecretValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveSecret = (id: string) => {
|
||||
setEnvironment({
|
||||
...environment,
|
||||
secrets: environment.secrets.filter((s) => s.id !== id),
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleAgent = (agentId: string) => {
|
||||
const installed = environment.installedAgents || [];
|
||||
if (installed.includes(agentId)) {
|
||||
setEnvironment({
|
||||
...environment,
|
||||
installedAgents: installed.filter((id) => id !== agentId),
|
||||
});
|
||||
} else {
|
||||
setEnvironment({
|
||||
...environment,
|
||||
installedAgents: [...installed, agentId],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEnvironment = () => {
|
||||
if (deleteConfirmText === 'delete') {
|
||||
router.push('/settings/environments');
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{environment.name}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Configure Docker environment and runtime settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Environment Name</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Input
|
||||
value={environment.name}
|
||||
onChange={(e) => setEnvironment({ ...environment, name: e.target.value })}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Docker Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Environment Type</Label>
|
||||
<Select
|
||||
value={environment.type}
|
||||
onValueChange={(value) => setEnvironment({ ...environment, type: value as EnvironmentType })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="local-docker">Local Docker</SelectItem>
|
||||
<SelectItem value="remote-docker">Remote Docker</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Base Docker Image</Label>
|
||||
<Select
|
||||
value={environment.baseDocker}
|
||||
onValueChange={(value) => setEnvironment({ ...environment, baseDocker: value as BaseDocker })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="universal">Universal</SelectItem>
|
||||
<SelectItem value="golang">Golang</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Setup Script</Label>
|
||||
<Textarea
|
||||
value={environment.setupScript}
|
||||
onChange={(e) => setEnvironment({ ...environment, setupScript: e.target.value })}
|
||||
placeholder="#!/bin/bash apt-get update"
|
||||
rows={5}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Environment Variables</CardTitle>
|
||||
<CardDescription>Key-value pairs available in the container</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
{environment.envVariables.map((envVar) => (
|
||||
<div key={envVar.id} className="flex items-center gap-2">
|
||||
<Input value={envVar.key} disabled className="flex-1" />
|
||||
<Input value={envVar.value} disabled className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveEnvVar(envVar.id)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<IconTrash className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="KEY"
|
||||
value={newEnvKey}
|
||||
onChange={(e) => setNewEnvKey(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
placeholder="value"
|
||||
value={newEnvValue}
|
||||
onChange={(e) => setNewEnvValue(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleAddEnvVar} className="shrink-0">
|
||||
<IconPlus className="h-4 w-4 mr-2" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Secrets</CardTitle>
|
||||
<CardDescription>Sensitive values (not shown in plain text)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
{environment.secrets.map((secret) => (
|
||||
<div key={secret.id} className="flex items-center gap-2">
|
||||
<Input value={secret.key} disabled className="flex-1" />
|
||||
<Input value="••••••••" type="password" disabled className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveSecret(secret.id)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<IconTrash className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="SECRET_KEY"
|
||||
value={newSecretKey}
|
||||
onChange={(e) => setNewSecretKey(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
placeholder="secret value"
|
||||
type="password"
|
||||
value={newSecretValue}
|
||||
onChange={(e) => setNewSecretValue(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleAddSecret} className="shrink-0">
|
||||
<IconPlus className="h-4 w-4 mr-2" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Installed Agents</CardTitle>
|
||||
<CardDescription>Select agents available in this environment</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{SETTINGS_DATA.agents.map((agent) => (
|
||||
<label
|
||||
key={agent.id}
|
||||
className="flex items-center gap-3 p-3 border rounded-lg cursor-pointer hover:bg-accent"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(environment.installedAgents || []).includes(agent.id)}
|
||||
onChange={() => handleToggleAgent(agent.id)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{agent.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{agent.model}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="border-destructive">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
||||
<CardDescription>
|
||||
Irreversible actions that will permanently delete this environment
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Delete this environment</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Once deleted, all configuration will be permanently removed
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={() => setDeleteDialogOpen(true)}>
|
||||
<IconTrash className="h-4 w-4 mr-2" />
|
||||
Delete Environment
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Environment</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the environment "
|
||||
{environment.name}" and all its data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<p className="text-sm">
|
||||
Please type <span className="font-mono font-bold">delete</span> to confirm:
|
||||
</p>
|
||||
<Input
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
||||
placeholder="delete"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteEnvironment}
|
||||
disabled={deleteConfirmText !== 'delete'}
|
||||
>
|
||||
Delete Environment
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { EnvironmentCard } from '@/components/settings/environment-card';
|
||||
import { KeyValueInput } from '@/components/settings/key-value-input';
|
||||
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
|
||||
import type { Environment, EnvironmentType, BaseDocker, KeyValue, AgentType } from '@/lib/settings/types';
|
||||
|
||||
export default function EnvironmentsSettingsPage() {
|
||||
const [environments, setEnvironments] = useState<Environment[]>(SETTINGS_DATA.environments);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newEnv, setNewEnv] = useState<{
|
||||
name: string;
|
||||
type: EnvironmentType;
|
||||
baseDocker: BaseDocker;
|
||||
envVariables: KeyValue[];
|
||||
secrets: KeyValue[];
|
||||
setupScript: string;
|
||||
installedAgents: AgentType[];
|
||||
}>({
|
||||
name: '',
|
||||
type: 'local-docker',
|
||||
baseDocker: 'universal',
|
||||
envVariables: [],
|
||||
secrets: [],
|
||||
setupScript: '',
|
||||
installedAgents: [],
|
||||
});
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setEnvironments(environments.filter((e) => e.id !== id));
|
||||
};
|
||||
|
||||
const handleAdd = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const environment: Environment = {
|
||||
id: crypto.randomUUID(),
|
||||
...newEnv,
|
||||
};
|
||||
setEnvironments([...environments, environment]);
|
||||
setIsAdding(false);
|
||||
setNewEnv({
|
||||
name: '',
|
||||
type: 'local-docker',
|
||||
baseDocker: 'universal',
|
||||
envVariables: [],
|
||||
secrets: [],
|
||||
setupScript: '',
|
||||
installedAgents: [],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Environments</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Configure execution environments for your agents
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setIsAdding(true)}>
|
||||
<IconPlus className="h-4 w-4 mr-2" />
|
||||
Add Environment
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3">
|
||||
{isAdding && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleAdd} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="env-name">Environment Name</Label>
|
||||
<Input
|
||||
id="env-name"
|
||||
value={newEnv.name}
|
||||
onChange={(e) => setNewEnv({ ...newEnv, name: e.target.value })}
|
||||
placeholder="Development"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="env-type">Environment Type</Label>
|
||||
<Select
|
||||
value={newEnv.type}
|
||||
onValueChange={(value) =>
|
||||
setNewEnv({ ...newEnv, type: value as EnvironmentType })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="env-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="local-docker">Local Docker</SelectItem>
|
||||
<SelectItem value="remote-docker">Remote Docker</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="base-docker">Base Docker Image</Label>
|
||||
<Select
|
||||
value={newEnv.baseDocker}
|
||||
onValueChange={(value) =>
|
||||
setNewEnv({ ...newEnv, baseDocker: value as BaseDocker })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="base-docker">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="universal">Universal</SelectItem>
|
||||
<SelectItem value="golang">Golang</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Environment Variables</Label>
|
||||
<KeyValueInput
|
||||
items={newEnv.envVariables}
|
||||
onChange={(items) => setNewEnv({ ...newEnv, envVariables: items })}
|
||||
keyPlaceholder="Variable name"
|
||||
valuePlaceholder="Value"
|
||||
addButtonLabel="Add Variable"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Secrets</Label>
|
||||
<KeyValueInput
|
||||
items={newEnv.secrets}
|
||||
onChange={(items) => setNewEnv({ ...newEnv, secrets: items })}
|
||||
keyPlaceholder="Secret name"
|
||||
valuePlaceholder="Secret value"
|
||||
addButtonLabel="Add Secret"
|
||||
masked
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="setup-script">Setup Script</Label>
|
||||
<Textarea
|
||||
id="setup-script"
|
||||
value={newEnv.setupScript}
|
||||
onChange={(e) =>
|
||||
setNewEnv({ ...newEnv, setupScript: e.target.value })
|
||||
}
|
||||
placeholder="#!/bin/bash apt-get update apt-get install -y git"
|
||||
rows={5}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsAdding(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Add Environment</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{environments.map((env) => (
|
||||
<EnvironmentCard
|
||||
key={env.id}
|
||||
environment={env}
|
||||
/>
|
||||
))}
|
||||
|
||||
{environments.length === 0 && !isAdding && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No environments configured. Add your first environment to get started.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { IconPalette, IconCode, IconBell } from '@tabler/icons-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
|
||||
import type { Theme, Editor, Notifications } from '@/lib/settings/types';
|
||||
|
||||
export default function GeneralSettingsPage() {
|
||||
const { theme: currentTheme, setTheme } = useTheme();
|
||||
const [editor, setEditor] = useState<Editor>(SETTINGS_DATA.general.editor);
|
||||
const [customEditorCommand, setCustomEditorCommand] = useState<string>(
|
||||
SETTINGS_DATA.general.customEditorCommand || ''
|
||||
);
|
||||
const [notifications, setNotifications] = useState<Notifications>(
|
||||
SETTINGS_DATA.general.notifications
|
||||
);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleNotificationToggle = (key: keyof Notifications) => {
|
||||
setNotifications((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">General Settings</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage your application preferences and notifications
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
icon={<IconPalette className="h-5 w-5" />}
|
||||
title="Appearance"
|
||||
description="Customize how the application looks"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Theme</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="theme">Color Theme</Label>
|
||||
<Select
|
||||
value={currentTheme}
|
||||
onValueChange={(value) => setTheme(value as Theme)}
|
||||
>
|
||||
<SelectTrigger id="theme">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
icon={<IconCode className="h-5 w-5" />}
|
||||
title="Editor"
|
||||
description="Choose your preferred code editor"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Editor Preference</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="editor">Default Editor</Label>
|
||||
<Select value={editor} onValueChange={(value) => setEditor(value as Editor)}>
|
||||
<SelectTrigger id="editor">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="vscode">VS Code</SelectItem>
|
||||
<SelectItem value="cursor">Cursor</SelectItem>
|
||||
<SelectItem value="zed">Zed</SelectItem>
|
||||
<SelectItem value="vim">Vim</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{editor === 'custom' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom-command">Custom Editor Command</Label>
|
||||
<Input
|
||||
id="custom-command"
|
||||
value={customEditorCommand}
|
||||
onChange={(e) => setCustomEditorCommand(e.target.value)}
|
||||
placeholder="code --goto {file}:{line}"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use {'{file}'} and {'{line}'} as placeholders
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
icon={<IconBell className="h-5 w-5" />}
|
||||
title="Notifications"
|
||||
description="Control what notifications you receive"
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="task-updates" className="text-base font-medium">
|
||||
Task Updates
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Get notified when tasks change status
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
id="task-updates"
|
||||
role="switch"
|
||||
aria-checked={notifications.taskUpdates}
|
||||
onClick={() => handleNotificationToggle('taskUpdates')}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
notifications.taskUpdates ? 'bg-primary' : 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
|
||||
notifications.taskUpdates ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="agent-completion" className="text-base font-medium">
|
||||
Agent Completion
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Get notified when agents complete tasks
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
id="agent-completion"
|
||||
role="switch"
|
||||
aria-checked={notifications.agentCompletion}
|
||||
onClick={() => handleNotificationToggle('agentCompletion')}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
notifications.agentCompletion ? 'bg-primary' : 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
|
||||
notifications.agentCompletion ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="errors" className="text-base font-medium">
|
||||
Errors
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Get notified about errors and failures
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
id="errors"
|
||||
role="switch"
|
||||
aria-checked={notifications.errors}
|
||||
onClick={() => handleNotificationToggle('errors')}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
notifications.errors ? 'bg-primary' : 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
|
||||
notifications.errors ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
IconBrandGithub,
|
||||
IconBrandOpenai,
|
||||
IconBrandGoogle,
|
||||
IconKey,
|
||||
IconBox,
|
||||
IconCircleDot,
|
||||
} from '@tabler/icons-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
type Integration = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
connected: boolean;
|
||||
category: 'ai' | 'project-management';
|
||||
};
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
const [integrations, setIntegrations] = useState<Integration[]>([
|
||||
{
|
||||
id: 'github',
|
||||
name: 'GitHub',
|
||||
description: 'Connect your GitHub repositories for code management and CI/CD',
|
||||
icon: <IconBrandGithub className="h-5 w-5" />,
|
||||
connected: false,
|
||||
category: 'project-management',
|
||||
},
|
||||
{
|
||||
id: 'anthropic',
|
||||
name: 'Anthropic',
|
||||
description: 'Connect to Anthropic API for Claude models',
|
||||
icon: <IconBox className="h-5 w-5" />,
|
||||
connected: false,
|
||||
category: 'ai',
|
||||
},
|
||||
{
|
||||
id: 'openai',
|
||||
name: 'OpenAI',
|
||||
description: 'Integrate OpenAI models and services',
|
||||
icon: <IconBrandOpenai className="h-5 w-5" />,
|
||||
connected: false,
|
||||
category: 'ai',
|
||||
},
|
||||
{
|
||||
id: 'augment',
|
||||
name: 'Augment',
|
||||
description: 'Connect Augment AI coding assistant',
|
||||
icon: <IconBox className="h-5 w-5" />,
|
||||
connected: false,
|
||||
category: 'ai',
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
name: 'Gemini',
|
||||
description: 'Google Gemini AI models integration',
|
||||
icon: <IconBrandGoogle className="h-5 w-5" />,
|
||||
connected: false,
|
||||
category: 'ai',
|
||||
},
|
||||
{
|
||||
id: 'jira',
|
||||
name: 'Jira',
|
||||
description: 'Sync tasks with Atlassian Jira',
|
||||
icon: <IconBox className="h-5 w-5" />,
|
||||
connected: false,
|
||||
category: 'project-management',
|
||||
},
|
||||
{
|
||||
id: 'linear',
|
||||
name: 'Linear',
|
||||
description: 'Integrate with Linear for issue tracking',
|
||||
icon: <IconCircleDot className="h-5 w-5" />,
|
||||
connected: false,
|
||||
category: 'project-management',
|
||||
},
|
||||
]);
|
||||
|
||||
const handleConnect = (id: string) => {
|
||||
setIntegrations(integrations.map(int =>
|
||||
int.id === id ? { ...int, connected: !int.connected } : int
|
||||
));
|
||||
};
|
||||
|
||||
const groupedIntegrations = {
|
||||
'AI Models': integrations.filter(i => i.category === 'ai'),
|
||||
'Project Management': integrations.filter(i => i.category === 'project-management'),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Integrations</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Connect external services and tools to enhance your workflow
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-8">
|
||||
{Object.entries(groupedIntegrations).map(([category, items]) => (
|
||||
<div key={category} className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">{category}</h3>
|
||||
<div className="space-y-2">
|
||||
{items.map((integration) => (
|
||||
<Card key={integration.id}>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="p-2 bg-muted rounded-lg">
|
||||
{React.cloneElement(integration.icon as React.ReactElement, {
|
||||
className: 'h-5 w-5',
|
||||
})}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium">{integration.name}</h4>
|
||||
{integration.connected && (
|
||||
<span className="text-sm text-primary">Connected</span>
|
||||
)}
|
||||
</div>
|
||||
{!integration.connected && (
|
||||
<p className="text-sm text-muted-foreground">Disconnected</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant={integration.connected ? 'outline' : 'default'}
|
||||
onClick={() => handleConnect(integration.id)}
|
||||
>
|
||||
{integration.connected ? 'Disconnect' : 'Connect'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
import { IconArrowLeft } from '@tabler/icons-react';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { SettingsAppSidebar } from '@/components/settings/settings-app-sidebar';
|
||||
|
||||
export default function SettingsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<SettingsAppSidebar />
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/" className="flex items-center gap-2">
|
||||
<IconArrowLeft className="h-4 w-4" />
|
||||
Home
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
||||
{children}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function SettingsPage() {
|
||||
redirect('/settings/general');
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
IconBrandAws,
|
||||
IconBrandDocker,
|
||||
IconCloud,
|
||||
IconServer,
|
||||
IconKey,
|
||||
IconShield,
|
||||
} from '@tabler/icons-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
type Plugin = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
installed: boolean;
|
||||
category: 'infrastructure' | 'security';
|
||||
};
|
||||
|
||||
export default function PluginsPage() {
|
||||
const [plugins, setPlugins] = useState<Plugin[]>([
|
||||
{
|
||||
id: 'aws',
|
||||
name: 'AWS Cloud',
|
||||
description: 'Deploy and manage resources on Amazon Web Services',
|
||||
icon: <IconBrandAws className="h-5 w-5" />,
|
||||
installed: false,
|
||||
category: 'infrastructure',
|
||||
},
|
||||
{
|
||||
id: 'hetzner',
|
||||
name: 'Hetzner',
|
||||
description: 'Deploy applications to Hetzner Cloud infrastructure',
|
||||
icon: <IconServer className="h-5 w-5" />,
|
||||
installed: false,
|
||||
category: 'infrastructure',
|
||||
},
|
||||
{
|
||||
id: 'flyio',
|
||||
name: 'Fly.io',
|
||||
description: 'Deploy apps globally with Fly.io edge infrastructure',
|
||||
icon: <IconCloud className="h-5 w-5" />,
|
||||
installed: false,
|
||||
category: 'infrastructure',
|
||||
},
|
||||
{
|
||||
id: 'cloudflare',
|
||||
name: 'Cloudflare',
|
||||
description: 'Use Cloudflare Workers and edge compute platform',
|
||||
icon: <IconShield className="h-5 w-5" />,
|
||||
installed: false,
|
||||
category: 'infrastructure',
|
||||
},
|
||||
{
|
||||
id: 'docker',
|
||||
name: 'Docker',
|
||||
description: 'Container runtime for local and remote environments',
|
||||
icon: <IconBrandDocker className="h-5 w-5" />,
|
||||
installed: true,
|
||||
category: 'infrastructure',
|
||||
},
|
||||
{
|
||||
id: 'sso',
|
||||
name: 'Single Sign-On',
|
||||
description: 'Enterprise SSO authentication provider',
|
||||
icon: <IconKey className="h-5 w-5" />,
|
||||
installed: false,
|
||||
category: 'security',
|
||||
},
|
||||
]);
|
||||
|
||||
const handleToggleInstall = (id: string) => {
|
||||
setPlugins(plugins.map(plugin =>
|
||||
plugin.id === id ? { ...plugin, installed: !plugin.installed } : plugin
|
||||
));
|
||||
};
|
||||
|
||||
const groupedPlugins = {
|
||||
'Infrastructure': plugins.filter(p => p.category === 'infrastructure'),
|
||||
'Security': plugins.filter(p => p.category === 'security'),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Plugins</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Extend functionality with infrastructure and security plugins
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-8">
|
||||
{Object.entries(groupedPlugins).map(([category, items]) => (
|
||||
<div key={category} className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">{category}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{items.map((plugin) => (
|
||||
<Card key={plugin.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-muted rounded-md">
|
||||
{plugin.icon}
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{plugin.name}</CardTitle>
|
||||
{plugin.installed && (
|
||||
<Badge variant="secondary" className="mt-1">
|
||||
Installed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription className="mt-2">
|
||||
{plugin.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
variant={plugin.installed ? 'outline' : 'default'}
|
||||
className="w-full"
|
||||
onClick={() => handleToggleInstall(plugin.id)}
|
||||
>
|
||||
{plugin.installed ? 'Uninstall' : 'Install'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
'use client';
|
||||
|
||||
import { use, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { IconGitBranch, IconFolder, IconPlus, IconTrash } from '@tabler/icons-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { RepositoryCard } from '@/components/settings/repository-card';
|
||||
import { ContextCard } from '@/components/settings/context-card';
|
||||
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
|
||||
import type { Workspace, Repository, Context } from '@/lib/settings/types';
|
||||
|
||||
export default function WorkspaceEditPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const [workspace, setWorkspace] = useState<Workspace | undefined>(
|
||||
SETTINGS_DATA.workspaces.find((w) => w.id === id)
|
||||
);
|
||||
const [isAddingRepo, setIsAddingRepo] = useState(false);
|
||||
const [isAddingContext, setIsAddingContext] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
||||
|
||||
if (!workspace) {
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">Workspace not found</p>
|
||||
<Button className="mt-4" onClick={() => router.push('/settings/workspace/new')}>
|
||||
Create New Workspace
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleUpdateWorkspaceName = (name: string) => {
|
||||
setWorkspace({ ...workspace, name });
|
||||
};
|
||||
|
||||
const handleAddRepository = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const newRepo: Repository = {
|
||||
id: crypto.randomUUID(),
|
||||
name: formData.get('name') as string,
|
||||
path: formData.get('path') as string,
|
||||
setupScript: formData.get('setupScript') as string,
|
||||
cleanupScript: formData.get('cleanupScript') as string,
|
||||
customScripts: [],
|
||||
};
|
||||
setWorkspace({
|
||||
...workspace,
|
||||
repositories: [...workspace.repositories, newRepo],
|
||||
});
|
||||
setIsAddingRepo(false);
|
||||
};
|
||||
|
||||
const handleUpdateRepository = (repo: Repository) => {
|
||||
setWorkspace({
|
||||
...workspace,
|
||||
repositories: workspace.repositories.map((r) => (r.id === repo.id ? repo : r)),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteRepository = (id: string) => {
|
||||
setWorkspace({
|
||||
...workspace,
|
||||
repositories: workspace.repositories.filter((r) => r.id !== id),
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddContext = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const newContext: Context = {
|
||||
id: crypto.randomUUID(),
|
||||
name: formData.get('name') as string,
|
||||
columns: [
|
||||
{ id: 'todo', title: 'To Do', color: 'bg-neutral-400' },
|
||||
{ id: 'in-progress', title: 'In Progress', color: 'bg-blue-500' },
|
||||
{ id: 'done', title: 'Done', color: 'bg-green-500' },
|
||||
],
|
||||
};
|
||||
setWorkspace({
|
||||
...workspace,
|
||||
contexts: [...workspace.contexts, newContext],
|
||||
});
|
||||
setIsAddingContext(false);
|
||||
};
|
||||
|
||||
const handleUpdateContext = (context: Context) => {
|
||||
setWorkspace({
|
||||
...workspace,
|
||||
contexts: workspace.contexts.map((c) => (c.id === context.id ? context : c)),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteContext = (id: string) => {
|
||||
setWorkspace({
|
||||
...workspace,
|
||||
contexts: workspace.contexts.filter((c) => c.id !== id),
|
||||
});
|
||||
};
|
||||
|
||||
const handleFolderPick = (callback: (path: string) => void) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.webkitdirectory = true;
|
||||
input.onchange = (e: any) => {
|
||||
const files = e.target.files;
|
||||
if (files && files[0]) {
|
||||
const fullPath = files[0].webkitRelativePath;
|
||||
const folderName = fullPath.split('/')[0];
|
||||
callback(`/${folderName}`);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const handleDeleteWorkspace = () => {
|
||||
if (deleteConfirmText === 'delete') {
|
||||
// In real app, this would delete from database
|
||||
router.push('/settings/general');
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{workspace.name}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage repositories and contexts for this workspace
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Workspace Name</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Input
|
||||
value={workspace.name}
|
||||
onChange={(e) => handleUpdateWorkspaceName(e.target.value)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
icon={<IconGitBranch className="h-5 w-5" />}
|
||||
title="Repositories"
|
||||
description="Repositories in this workspace"
|
||||
action={
|
||||
<Button size="sm" onClick={() => setIsAddingRepo(true)}>
|
||||
<IconPlus className="h-4 w-4 mr-2" />
|
||||
Add Repository
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-3">
|
||||
{isAddingRepo && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleAddRepository} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Repository Name</Label>
|
||||
<Input name="name" placeholder="my-project" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Directory Path</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="repo-path"
|
||||
name="path"
|
||||
placeholder="/path/to/repository"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
handleFolderPick((path) => {
|
||||
const input = document.getElementById('repo-path') as HTMLInputElement;
|
||||
if (input) input.value = path;
|
||||
})
|
||||
}
|
||||
>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Setup Script</Label>
|
||||
<Textarea
|
||||
name="setupScript"
|
||||
placeholder="#!/bin/bash npm install"
|
||||
rows={3}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Cleanup Script</Label>
|
||||
<Textarea
|
||||
name="cleanupScript"
|
||||
placeholder="#!/bin/bash rm -rf node_modules"
|
||||
rows={3}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => setIsAddingRepo(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Add</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{workspace.repositories.map((repo) => (
|
||||
<RepositoryCard
|
||||
key={repo.id}
|
||||
repository={repo}
|
||||
onUpdate={handleUpdateRepository}
|
||||
onDelete={() => handleDeleteRepository(repo.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{workspace.repositories.length === 0 && !isAddingRepo && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">No repositories in this workspace</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
icon={<IconFolder className="h-5 w-5" />}
|
||||
title="Contexts"
|
||||
description="Kanban view contexts for this workspace"
|
||||
action={
|
||||
<Button size="sm" onClick={() => setIsAddingContext(true)}>
|
||||
<IconPlus className="h-4 w-4 mr-2" />
|
||||
Add Context
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-3">
|
||||
{isAddingContext && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleAddContext} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Context Name</Label>
|
||||
<Input name="name" placeholder="Dev" required />
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsAddingContext(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Add</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{workspace.contexts.map((context) => (
|
||||
<ContextCard
|
||||
key={context.id}
|
||||
context={context}
|
||||
onUpdate={handleUpdateContext}
|
||||
onDelete={() => handleDeleteContext(context.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{workspace.contexts.length === 0 && !isAddingContext && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">No contexts in this workspace</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="border-destructive">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
||||
<CardDescription>
|
||||
Irreversible actions that will permanently delete this workspace
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Delete this workspace</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Once deleted, all repositories and contexts will be permanently removed
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={() => setDeleteDialogOpen(true)}>
|
||||
<IconTrash className="h-4 w-4 mr-2" />
|
||||
Delete Workspace
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Workspace</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the workspace "
|
||||
{workspace.name}" and all its data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<p className="text-sm">
|
||||
Please type <span className="font-mono font-bold">delete</span> to confirm:
|
||||
</p>
|
||||
<Input
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
||||
placeholder="delete"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteWorkspace}
|
||||
disabled={deleteConfirmText !== 'delete'}
|
||||
>
|
||||
Delete Workspace
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { IconFolder, IconPlus, IconChevronRight } from '@tabler/icons-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
|
||||
import type { Workspace } from '@/lib/settings/types';
|
||||
|
||||
export default function WorkspacesPage() {
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>(SETTINGS_DATA.workspaces);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newWorkspaceName, setNewWorkspaceName] = useState('');
|
||||
|
||||
const handleAddWorkspace = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newWorkspaceName.trim()) {
|
||||
const newWorkspace: Workspace = {
|
||||
id: crypto.randomUUID(),
|
||||
name: newWorkspaceName,
|
||||
repositories: [],
|
||||
contexts: [],
|
||||
};
|
||||
setWorkspaces([...workspaces, newWorkspace]);
|
||||
setNewWorkspaceName('');
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Workspaces</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage your workspaces with repositories and contexts
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setIsAdding(true)}>
|
||||
<IconPlus className="h-4 w-4 mr-2" />
|
||||
Add Workspace
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3">
|
||||
{isAdding && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleAddWorkspace} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workspace-name">Workspace Name</Label>
|
||||
<Input
|
||||
id="workspace-name"
|
||||
value={newWorkspaceName}
|
||||
onChange={(e) => setNewWorkspaceName(e.target.value)}
|
||||
placeholder="My Workspace"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={() => setIsAdding(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Add Workspace</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{workspaces.map((workspace) => (
|
||||
<Link key={workspace.id} href={`/settings/workspace/${workspace.id}`}>
|
||||
<Card className="hover:bg-accent transition-colors cursor-pointer">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div className="p-2 bg-muted rounded-md">
|
||||
<IconFolder className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium">{workspace.name}</h4>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
|
||||
<span>{workspace.repositories.length} repositories</span>
|
||||
<span>{workspace.contexts.length} contexts</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<IconChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{workspaces.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No workspaces configured. Add your first workspace to get started.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,9 @@ import { KanbanColumn, Column } from './kanban-column';
|
||||
import { KanbanCardPreview, Task } from './kanban-card';
|
||||
import { ThemeToggle } from './theme-toggle';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { IconPlus, IconSettings } from '@tabler/icons-react';
|
||||
import { TaskCreateDialog } from './task-create-dialog';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -166,7 +167,12 @@ export function KanbanBoard() {
|
||||
<IconPlus className="h-4 w-4" />
|
||||
Add task
|
||||
</Button>
|
||||
<ThemeToggle />
|
||||
<Link href="/settings" className="cursor-pointer">
|
||||
<Button variant="outline">
|
||||
<IconSettings className="h-4 w-4 mr-2" />
|
||||
Settings
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<TaskCreateDialog
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { IconRobot, IconChevronRight } from '@tabler/icons-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { AgentProfile, AgentType } from '@/lib/settings/types';
|
||||
|
||||
type AgentCardProps = {
|
||||
agent: AgentProfile;
|
||||
};
|
||||
|
||||
const AGENT_LABELS: Record<AgentType, string> = {
|
||||
'claude-code': 'Claude Code',
|
||||
'codex': 'Codex',
|
||||
'auggie': 'Auggie',
|
||||
};
|
||||
|
||||
export function AgentCard({ agent }: AgentCardProps) {
|
||||
const agentLabel = AGENT_LABELS[agent.agent] || agent.agent;
|
||||
|
||||
return (
|
||||
<Link href={`/settings/agent/${agent.id}`}>
|
||||
<Card className="hover:bg-accent transition-colors cursor-pointer">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div className="p-2 bg-muted rounded-md">
|
||||
<IconRobot className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="font-medium">{agent.name}</h4>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{agentLabel}
|
||||
</Badge>
|
||||
{agent.autoApprove && (
|
||||
<Badge variant="outline" className="text-xs text-green-600">
|
||||
Auto-approve
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
<p>Model: {agent.model}</p>
|
||||
<p>Temperature: {agent.temperature}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<IconChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { IconFolder, IconTrash, IconEdit, IconCheck, IconX, IconPlus } from '@tabler/icons-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { Context, Column } from '@/lib/settings/types';
|
||||
|
||||
type ContextCardProps = {
|
||||
context: Context;
|
||||
onUpdate: (context: Context) => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
const COLOR_OPTIONS = [
|
||||
{ value: 'bg-neutral-400', label: 'Gray' },
|
||||
{ value: 'bg-red-500', label: 'Red' },
|
||||
{ value: 'bg-orange-500', label: 'Orange' },
|
||||
{ value: 'bg-yellow-500', label: 'Yellow' },
|
||||
{ value: 'bg-green-500', label: 'Green' },
|
||||
{ value: 'bg-blue-500', label: 'Blue' },
|
||||
{ value: 'bg-indigo-500', label: 'Indigo' },
|
||||
{ value: 'bg-violet-500', label: 'Violet' },
|
||||
{ value: 'bg-cyan-500', label: 'Cyan' },
|
||||
{ value: 'bg-sky-500', label: 'Sky' },
|
||||
];
|
||||
|
||||
export function ContextCard({ context, onUpdate, onDelete }: ContextCardProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedContext, setEditedContext] = useState<Context>(context);
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate(editedContext);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditedContext(context);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleAddColumn = () => {
|
||||
setEditedContext({
|
||||
...editedContext,
|
||||
columns: [
|
||||
...editedContext.columns,
|
||||
{ id: crypto.randomUUID(), title: '', color: 'bg-neutral-400' },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveColumn = (id: string) => {
|
||||
setEditedContext({
|
||||
...editedContext,
|
||||
columns: editedContext.columns.filter((c) => c.id !== id),
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateColumn = (id: string, field: 'title' | 'color' | 'id', value: string) => {
|
||||
setEditedContext({
|
||||
...editedContext,
|
||||
columns: editedContext.columns.map((c) =>
|
||||
c.id === id ? { ...c, [field]: value } : c
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Context Name</Label>
|
||||
<Input
|
||||
value={editedContext.name}
|
||||
onChange={(e) => setEditedContext({ ...editedContext, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Kanban Columns</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddColumn}>
|
||||
<IconPlus className="h-4 w-4 mr-1" />
|
||||
Add Column
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{editedContext.columns.map((column, index) => (
|
||||
<div key={column.id} className="flex gap-2 items-start">
|
||||
<div className="flex-1 grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
placeholder="Column title"
|
||||
value={column.title}
|
||||
onChange={(e) =>
|
||||
handleUpdateColumn(column.id, 'title', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<select
|
||||
value={column.color}
|
||||
onChange={(e) =>
|
||||
handleUpdateColumn(column.id, 'color', e.target.value)
|
||||
}
|
||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
|
||||
>
|
||||
{COLOR_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Input
|
||||
placeholder="Column ID (e.g., todo, in-progress)"
|
||||
value={column.id}
|
||||
onChange={(e) =>
|
||||
handleUpdateColumn(column.id, 'id', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveColumn(column.id)}
|
||||
>
|
||||
<IconX className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
<IconX className="h-4 w-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
<IconCheck className="h-4 w-4 mr-2" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="hover:bg-accent transition-colors cursor-pointer" onClick={() => setIsEditing(true)}>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div className="p-2 bg-muted rounded-md">
|
||||
<IconFolder className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium">{context.name}</h4>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{context.columns.map((column) => {
|
||||
// Map Tailwind bg colors to actual hex values
|
||||
const colorMap: Record<string, string> = {
|
||||
'bg-neutral-400': '#a3a3a3',
|
||||
'bg-red-500': '#ef4444',
|
||||
'bg-orange-500': '#f97316',
|
||||
'bg-yellow-500': '#eab308',
|
||||
'bg-green-500': '#22c55e',
|
||||
'bg-blue-500': '#3b82f6',
|
||||
'bg-indigo-500': '#6366f1',
|
||||
'bg-violet-500': '#8b5cf6',
|
||||
'bg-cyan-500': '#06b6d4',
|
||||
'bg-sky-500': '#0ea5e9',
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={column.id}
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
style={{
|
||||
borderLeftWidth: '3px',
|
||||
borderLeftColor: colorMap[column.color] || '#a3a3a3',
|
||||
}}
|
||||
>
|
||||
{column.title}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsEditing(true)}>
|
||||
<IconEdit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={onDelete}>
|
||||
<IconTrash className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { IconServer, IconChevronRight } from '@tabler/icons-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { Environment } from '@/lib/settings/types';
|
||||
|
||||
type EnvironmentCardProps = {
|
||||
environment: Environment;
|
||||
};
|
||||
|
||||
export function EnvironmentCard({ environment }: EnvironmentCardProps) {
|
||||
const typeLabel = environment.type === 'local-docker' ? 'Local Docker' : 'Remote Docker';
|
||||
const baseDockerLabel = environment.baseDocker === 'universal' ? 'Universal' : 'Golang';
|
||||
|
||||
return (
|
||||
<Link href={`/settings/environment/${environment.id}`}>
|
||||
<Card className="hover:bg-accent transition-colors cursor-pointer">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div className="p-2 bg-muted rounded-md">
|
||||
<IconServer className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="font-medium">{environment.name}</h4>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{typeLabel}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{baseDockerLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
|
||||
<span>{environment.envVariables.length} variables</span>
|
||||
<span>{environment.secrets.length} secrets</span>
|
||||
<span>{(environment.installedAgents || []).length} agents</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<IconChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { IconPlus, IconX } from '@tabler/icons-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { KeyValue } from '@/lib/settings/types';
|
||||
|
||||
type KeyValueInputProps = {
|
||||
items: KeyValue[];
|
||||
onChange: (items: KeyValue[]) => void;
|
||||
keyPlaceholder?: string;
|
||||
valuePlaceholder?: string;
|
||||
addButtonLabel?: string;
|
||||
masked?: boolean;
|
||||
};
|
||||
|
||||
export function KeyValueInput({
|
||||
items,
|
||||
onChange,
|
||||
keyPlaceholder = 'Key',
|
||||
valuePlaceholder = 'Value',
|
||||
addButtonLabel = 'Add Item',
|
||||
masked = false,
|
||||
}: KeyValueInputProps) {
|
||||
const handleAdd = () => {
|
||||
onChange([...items, { id: crypto.randomUUID(), key: '', value: '' }]);
|
||||
};
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
onChange(items.filter((item) => item.id !== id));
|
||||
};
|
||||
|
||||
const handleChange = (id: string, field: 'key' | 'value', value: string) => {
|
||||
onChange(
|
||||
items.map((item) =>
|
||||
item.id === id ? { ...item, [field]: value } : item
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="flex gap-2">
|
||||
<Input
|
||||
placeholder={keyPlaceholder}
|
||||
value={item.key}
|
||||
onChange={(e) => handleChange(item.id, 'key', e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
type={masked ? 'password' : 'text'}
|
||||
placeholder={valuePlaceholder}
|
||||
value={item.value}
|
||||
onChange={(e) => handleChange(item.id, 'value', e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemove(item.id)}
|
||||
>
|
||||
<IconX className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" size="sm" onClick={handleAdd} className="w-full">
|
||||
<IconPlus className="h-4 w-4 mr-2" />
|
||||
{addButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { IconGitBranch, IconTrash, IconEdit, IconCheck, IconX, IconPlus } from '@tabler/icons-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { Repository, CustomScript } from '@/lib/settings/types';
|
||||
|
||||
type RepositoryCardProps = {
|
||||
repository: Repository;
|
||||
onUpdate: (repo: Repository) => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
export function RepositoryCard({ repository, onUpdate, onDelete }: RepositoryCardProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [editedRepo, setEditedRepo] = useState<Repository>(repository);
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate(editedRepo);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditedRepo(repository);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleFolderPick = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.webkitdirectory = true;
|
||||
input.onchange = (e: any) => {
|
||||
const files = e.target.files;
|
||||
if (files && files[0]) {
|
||||
const path = files[0].webkitRelativePath.split('/')[0];
|
||||
setEditedRepo({ ...editedRepo, path: `/${path}` });
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const handleAddCustomScript = () => {
|
||||
setEditedRepo({
|
||||
...editedRepo,
|
||||
customScripts: [
|
||||
...editedRepo.customScripts,
|
||||
{ id: crypto.randomUUID(), name: '', command: '#!/bin/bash\n' },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveCustomScript = (id: string) => {
|
||||
setEditedRepo({
|
||||
...editedRepo,
|
||||
customScripts: editedRepo.customScripts.filter((s) => s.id !== id),
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateCustomScript = (id: string, field: 'name' | 'command', value: string) => {
|
||||
setEditedRepo({
|
||||
...editedRepo,
|
||||
customScripts: editedRepo.customScripts.map((s) =>
|
||||
s.id === id ? { ...s, [field]: value } : s
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Repository Name</Label>
|
||||
<Input
|
||||
value={editedRepo.name}
|
||||
onChange={(e) => setEditedRepo({ ...editedRepo, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Directory Path</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={editedRepo.path}
|
||||
onChange={(e) => setEditedRepo({ ...editedRepo, path: e.target.value })}
|
||||
placeholder="/path/to/repository"
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={handleFolderPick}>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Setup Script</Label>
|
||||
<Textarea
|
||||
value={editedRepo.setupScript}
|
||||
onChange={(e) => setEditedRepo({ ...editedRepo, setupScript: e.target.value })}
|
||||
placeholder="#!/bin/bash npm install"
|
||||
rows={3}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Cleanup Script</Label>
|
||||
<Textarea
|
||||
value={editedRepo.cleanupScript}
|
||||
onChange={(e) => setEditedRepo({ ...editedRepo, cleanupScript: e.target.value })}
|
||||
placeholder="#!/bin/bash rm -rf node_modules"
|
||||
rows={3}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Custom Scripts</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddCustomScript}
|
||||
>
|
||||
<IconPlus className="h-4 w-4 mr-1" />
|
||||
Add Script
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{editedRepo.customScripts.map((script) => (
|
||||
<div key={script.id} className="border rounded-md p-3 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Script name"
|
||||
value={script.name}
|
||||
onChange={(e) =>
|
||||
handleUpdateCustomScript(script.id, 'name', e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveCustomScript(script.id)}
|
||||
>
|
||||
<IconX className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="#!/bin/bash npm run dev"
|
||||
value={script.command}
|
||||
onChange={(e) =>
|
||||
handleUpdateCustomScript(script.id, 'command', e.target.value)
|
||||
}
|
||||
rows={3}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
<IconX className="h-4 w-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
<IconCheck className="h-4 w-4 mr-2" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="hover:bg-accent transition-colors cursor-pointer" onClick={() => setIsEditing(true)}>
|
||||
<CardContent className="py-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div className="p-2 bg-muted rounded-md">
|
||||
<IconGitBranch className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium">{repository.name}</h4>
|
||||
<p className="text-sm text-muted-foreground">{repository.path}</p>
|
||||
{repository.customScripts.length > 0 && (
|
||||
<div className="flex gap-1 mt-2 flex-wrap">
|
||||
{repository.customScripts.map((script) => (
|
||||
<Badge key={script.id} variant="secondary" className="text-xs">
|
||||
{script.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" onClick={() => setIsEditing(true)}>
|
||||
<IconEdit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={onDelete}>
|
||||
<IconTrash className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="pl-11 space-y-2 text-sm">
|
||||
<div>
|
||||
<p className="font-medium">Setup:</p>
|
||||
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto">
|
||||
{repository.setupScript}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Cleanup:</p>
|
||||
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto">
|
||||
{repository.cleanupScript}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(repository.setupScript || repository.cleanupScript) && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="pl-11 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{isExpanded ? 'Hide' : 'Show'} scripts
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
IconSettings,
|
||||
IconFolder,
|
||||
IconServer,
|
||||
IconRobot,
|
||||
IconChevronRight,
|
||||
IconPlug,
|
||||
IconPuzzle,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubItem,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuAction,
|
||||
SidebarHeader,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
|
||||
|
||||
export function SettingsAppSidebar() {
|
||||
const pathname = usePathname();
|
||||
const [workspaces] = React.useState(SETTINGS_DATA.workspaces);
|
||||
const [environments] = React.useState(SETTINGS_DATA.environments);
|
||||
const [agents] = React.useState(SETTINGS_DATA.agents);
|
||||
|
||||
return (
|
||||
<Sidebar variant="inset">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<Link href="/">
|
||||
<div className="bg-primary text-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg font-bold text-sm">
|
||||
K
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">KanDev.ai</span>
|
||||
<span className="truncate text-xs">Settings</span>
|
||||
</div>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Settings</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{/* General */}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={pathname === '/settings/general'}>
|
||||
<Link href="/settings/general">
|
||||
<IconSettings className="h-4 w-4" />
|
||||
<span>General</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
{/* Workspaces */}
|
||||
<Collapsible defaultOpen className="group/collapsible">
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild tooltip="Workspaces">
|
||||
<Link href="/settings/workspace">
|
||||
<IconFolder className="h-4 w-4" />
|
||||
<span>Workspaces</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{workspaces.length > 0 && (
|
||||
<>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuAction className="data-[state=open]:rotate-90">
|
||||
<IconChevronRight className="h-4 w-4" />
|
||||
<span className="sr-only">Toggle</span>
|
||||
</SidebarMenuAction>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{workspaces.map((workspace) => (
|
||||
<SidebarMenuSubItem key={workspace.id}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={pathname === `/settings/workspace/${workspace.id}`}
|
||||
>
|
||||
<Link href={`/settings/workspace/${workspace.id}`}>
|
||||
<span>{workspace.name}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
|
||||
{/* Environments */}
|
||||
<Collapsible defaultOpen className="group/collapsible">
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild tooltip="Environments">
|
||||
<Link href="/settings/environments">
|
||||
<IconServer className="h-4 w-4" />
|
||||
<span>Environments</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{environments.length > 0 && (
|
||||
<>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuAction className="data-[state=open]:rotate-90">
|
||||
<IconChevronRight className="h-4 w-4" />
|
||||
<span className="sr-only">Toggle</span>
|
||||
</SidebarMenuAction>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{environments.map((env) => (
|
||||
<SidebarMenuSubItem key={env.id}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={pathname === `/settings/environment/${env.id}`}
|
||||
>
|
||||
<Link href={`/settings/environment/${env.id}`}>
|
||||
<span>{env.name}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
|
||||
{/* Agents */}
|
||||
<Collapsible defaultOpen className="group/collapsible">
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild tooltip="Agents">
|
||||
<Link href="/settings/agents">
|
||||
<IconRobot className="h-4 w-4" />
|
||||
<span>Agents</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{agents.length > 0 && (
|
||||
<>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuAction className="data-[state=open]:rotate-90">
|
||||
<IconChevronRight className="h-4 w-4" />
|
||||
<span className="sr-only">Toggle</span>
|
||||
</SidebarMenuAction>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{agents.map((agent) => (
|
||||
<SidebarMenuSubItem key={agent.id}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={pathname === `/settings/agent/${agent.id}`}
|
||||
>
|
||||
<Link href={`/settings/agent/${agent.id}`}>
|
||||
<span>{agent.name}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
|
||||
{/* Integrations */}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={pathname === '/settings/integrations'}>
|
||||
<Link href="/settings/integrations">
|
||||
<IconPlug className="h-4 w-4" />
|
||||
<span>Integrations</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
{/* Plugins */}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={pathname === '/settings/plugins'}>
|
||||
<Link href="/settings/plugins">
|
||||
<IconPuzzle className="h-4 w-4" />
|
||||
<span>Plugins</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet';
|
||||
import { SettingsSidebar } from './settings-sidebar';
|
||||
|
||||
type SettingsMobileNavProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function SettingsMobileNav({ open, onOpenChange }: SettingsMobileNavProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="left" className="w-64 p-0">
|
||||
<div className="pt-6">
|
||||
<SettingsSidebar
|
||||
className="border-0"
|
||||
onNavigate={() => onOpenChange(false)}
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
type SettingsSectionProps = {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function SettingsSection({ icon, title, description, action, children }: SettingsSectionProps) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
{icon}
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { IconSettings, IconFolder, IconServer, IconRobot } from '@tabler/icons-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{
|
||||
href: '/settings/general',
|
||||
label: 'General',
|
||||
icon: IconSettings,
|
||||
},
|
||||
{
|
||||
href: '/settings/workspace',
|
||||
label: 'Workspace',
|
||||
icon: IconFolder,
|
||||
},
|
||||
{
|
||||
href: '/settings/environments',
|
||||
label: 'Environments',
|
||||
icon: IconServer,
|
||||
},
|
||||
{
|
||||
href: '/settings/agents',
|
||||
label: 'Agents',
|
||||
icon: IconRobot,
|
||||
},
|
||||
];
|
||||
|
||||
type SettingsSidebarProps = {
|
||||
className?: string;
|
||||
onNavigate?: () => void;
|
||||
};
|
||||
|
||||
export function SettingsSidebar({ className, onNavigate }: SettingsSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className={cn('w-64 border-r border-border bg-card flex-col p-4', className)}>
|
||||
<div className="space-y-1">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.href;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
enableSystem={false}
|
||||
defaultTheme="system"
|
||||
enableSystem={true}
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { IconChevronRight, IconDots } from "@tabler/icons-react"
|
||||
|
||||
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="breadcrumb"
|
||||
data-slot="breadcrumb"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground gap-1.5 text-sm sm:gap-2.5 flex flex-wrap items-center break-words",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("gap-1.5 inline-flex items-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<IconChevronRight
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"size-5 [&>svg]:size-4 flex items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<IconDots
|
||||
/>
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Slider as SliderPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const _values = React.useMemo(
|
||||
() =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min, max],
|
||||
[value, defaultValue, min, max]
|
||||
)
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
"data-vertical:min-h-40 relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:w-auto data-vertical:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className="bg-muted rounded-full data-horizontal:h-1.5 data-horizontal:w-full data-vertical:h-full data-vertical:w-1.5 bg-muted relative grow overflow-hidden data-horizontal:w-full data-vertical:h-full"
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className="bg-primary absolute select-none data-horizontal:h-full data-vertical:w-full"
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="border-primary ring-ring/50 size-4 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden block shrink-0 select-none disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Slider }
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent shadow-xs focus-visible:ring-[3px] aria-invalid:ring-[3px] data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] peer group/switch relative inline-flex items-center transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground rounded-full group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 pointer-events-none block ring-0 transition-transform"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { SettingsData } from './types';
|
||||
|
||||
export const SETTINGS_DATA: SettingsData = {
|
||||
general: {
|
||||
theme: 'dark',
|
||||
editor: 'vscode',
|
||||
customEditorCommand: '',
|
||||
notifications: {
|
||||
taskUpdates: true,
|
||||
agentCompletion: true,
|
||||
errors: true,
|
||||
},
|
||||
},
|
||||
workspaces: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'KanDev Project',
|
||||
repositories: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'bismarck',
|
||||
path: '/Users/username/projects/bismarck',
|
||||
setupScript: '#!/bin/bash\nnpm install && npm run build',
|
||||
cleanupScript: '#!/bin/bash\nrm -rf node_modules dist',
|
||||
customScripts: [
|
||||
{ id: 's1', name: 'dev', command: '#!/bin/bash\nnpm run dev' },
|
||||
{ id: 's2', name: 'test', command: '#!/bin/bash\nnpm test' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'backend-api',
|
||||
path: '/Users/username/projects/backend-api',
|
||||
setupScript: '#!/bin/bash\ngo mod download',
|
||||
cleanupScript: '#!/bin/bash\ngo clean',
|
||||
customScripts: [
|
||||
{ id: 's3', name: 'run', command: '#!/bin/bash\ngo run main.go' },
|
||||
],
|
||||
},
|
||||
],
|
||||
contexts: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Architect',
|
||||
columns: [
|
||||
{ id: 'backlog', title: 'Backlog', color: 'bg-neutral-400' },
|
||||
{ id: 'high-level-design', title: 'High Level Design', color: 'bg-cyan-500' },
|
||||
{ id: 'low-level-design', title: 'Low Level Design', color: 'bg-violet-500' },
|
||||
{ id: 'review', title: 'Review', color: 'bg-yellow-500' },
|
||||
{ id: 'done', title: 'Done', color: 'bg-green-500' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Dev',
|
||||
columns: [
|
||||
{ id: 'todo', title: 'To Do', color: 'bg-neutral-400' },
|
||||
{ id: 'in-progress', title: 'In Progress', color: 'bg-blue-500' },
|
||||
{ id: 'review', title: 'Review', color: 'bg-yellow-500' },
|
||||
{ id: 'done', title: 'Done', color: 'bg-green-500' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Team',
|
||||
columns: [
|
||||
{ id: 'backlog', title: 'Backlog', color: 'bg-neutral-400' },
|
||||
{ id: 'solution-design', title: 'Solution Design', color: 'bg-sky-500' },
|
||||
{ id: 'ready-for-dev', title: 'Ready for Dev', color: 'bg-indigo-500' },
|
||||
{ id: 'in-progress', title: 'In Progress', color: 'bg-blue-500' },
|
||||
{ id: 'review', title: 'Review', color: 'bg-yellow-500' },
|
||||
{ id: 'done', title: 'Done', color: 'bg-green-500' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
environments: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Development',
|
||||
type: 'local-docker',
|
||||
baseDocker: 'universal',
|
||||
envVariables: [
|
||||
{ id: 'e1', key: 'NODE_ENV', value: 'development' },
|
||||
{ id: 'e2', key: 'PORT', value: '3000' },
|
||||
],
|
||||
secrets: [
|
||||
{ id: 'se1', key: 'DATABASE_PASSWORD', value: '••••••••' },
|
||||
{ id: 'se2', key: 'API_SECRET', value: '••••••••' },
|
||||
],
|
||||
setupScript: '#!/bin/bash\napt-get update\napt-get install -y git curl',
|
||||
installedAgents: ['claude-code', 'codex'],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Production',
|
||||
type: 'remote-docker',
|
||||
baseDocker: 'golang',
|
||||
envVariables: [
|
||||
{ id: 'e3', key: 'NODE_ENV', value: 'production' },
|
||||
],
|
||||
secrets: [],
|
||||
setupScript: '',
|
||||
installedAgents: ['auggie'],
|
||||
},
|
||||
],
|
||||
agents: [
|
||||
{
|
||||
id: '1',
|
||||
agent: 'claude-code',
|
||||
name: 'Default Profile',
|
||||
model: 'claude-sonnet-4.5',
|
||||
autoApprove: false,
|
||||
temperature: 0.7,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
agent: 'codex',
|
||||
name: 'Fast Development',
|
||||
model: 'codex-v2',
|
||||
autoApprove: true,
|
||||
temperature: 0.5,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
agent: 'auggie',
|
||||
name: 'Code Review',
|
||||
model: 'auggie-v1',
|
||||
autoApprove: false,
|
||||
temperature: 0.3,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
export type Theme = 'system' | 'light' | 'dark';
|
||||
export type Editor = 'vscode' | 'cursor' | 'zed' | 'vim' | 'custom';
|
||||
|
||||
export type Notifications = {
|
||||
taskUpdates: boolean;
|
||||
agentCompletion: boolean;
|
||||
errors: boolean;
|
||||
};
|
||||
|
||||
export type GeneralSettings = {
|
||||
theme: Theme;
|
||||
editor: Editor;
|
||||
customEditorCommand?: string;
|
||||
notifications: Notifications;
|
||||
};
|
||||
|
||||
export type CustomScript = {
|
||||
id: string;
|
||||
name: string;
|
||||
command: string;
|
||||
};
|
||||
|
||||
export type Repository = {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
setupScript: string;
|
||||
cleanupScript: string;
|
||||
customScripts: CustomScript[];
|
||||
};
|
||||
|
||||
export type Column = {
|
||||
id: string;
|
||||
title: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export type Context = {
|
||||
id: string;
|
||||
name: string;
|
||||
columns: Column[];
|
||||
};
|
||||
|
||||
export type Workspace = {
|
||||
id: string;
|
||||
name: string;
|
||||
repositories: Repository[];
|
||||
contexts: Context[];
|
||||
};
|
||||
|
||||
export type KeyValue = {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type EnvironmentType = 'local-docker' | 'remote-docker';
|
||||
export type BaseDocker = 'universal' | 'golang';
|
||||
|
||||
export type Environment = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: EnvironmentType;
|
||||
baseDocker: BaseDocker;
|
||||
envVariables: KeyValue[];
|
||||
secrets: KeyValue[];
|
||||
setupScript: string;
|
||||
installedAgents: AgentType[];
|
||||
};
|
||||
|
||||
export type AgentType = 'claude-code' | 'codex' | 'auggie';
|
||||
|
||||
export type AgentProfile = {
|
||||
id: string;
|
||||
agent: AgentType;
|
||||
name: string;
|
||||
model: string;
|
||||
autoApprove: boolean;
|
||||
temperature: number;
|
||||
};
|
||||
|
||||
export type SettingsData = {
|
||||
general: GeneralSettings;
|
||||
workspaces: Workspace[];
|
||||
environments: Environment[];
|
||||
agents: AgentProfile[];
|
||||
};
|
||||
Reference in New Issue
Block a user