feat: refactor names

This commit is contained in:
2026-03-11 17:24:32 +01:00
parent 6471cee05f
commit fc58dcdaa2
16 changed files with 35 additions and 35 deletions

View File

@@ -0,0 +1,330 @@
/**
* Platform context: projects list and handlers for create/delete/rename/updateLastEdited/reorder/restore.
* Used by AppSidebar, ProjectsTablePage, and canvas route.
*/
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'
import type { Project } from './types'
import { saveGraphToStorage } from '../pleroma/projectGraphStorage'
import { PROJECT_VERSION } from '../pleroma/projectGraphStorage'
const STORAGE_KEY = 'zui_platform_projects'
const ORDER_STORAGE_KEY = 'zui_platform_project_order'
const RECENT_STORAGE_KEY = 'zui_platform_recent_project_ids'
const CANVAS_MINIMAP_KEY = 'zui_canvas_show_minimap'
const AI_CONNECTION_KEY = 'zui_ai_connection'
const RECENT_MAX = 5
export type AiConnectionProvider = 'openai' | 'local'
export type AiConnection = {
provider: AiConnectionProvider
baseURL: string
model: string
apiKey: string
}
const DEFAULT_AI_CONNECTION: AiConnection = {
provider: 'local',
baseURL: 'http://localhost:1234/v1',
model: 'local-model',
apiKey: '',
}
function loadAiConnection(): AiConnection {
try {
const raw = localStorage.getItem(AI_CONNECTION_KEY)
if (!raw) return DEFAULT_AI_CONNECTION
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object') return DEFAULT_AI_CONNECTION
const p = parsed as Record<string, unknown>
return {
provider: p.provider === 'openai' ? 'openai' : 'local',
baseURL: typeof p.baseURL === 'string' ? p.baseURL : DEFAULT_AI_CONNECTION.baseURL,
model: typeof p.model === 'string' ? p.model : DEFAULT_AI_CONNECTION.model,
apiKey: typeof p.apiKey === 'string' ? p.apiKey : '',
}
} catch {
return DEFAULT_AI_CONNECTION
}
}
function saveAiConnection(value: AiConnection) {
try {
localStorage.setItem(AI_CONNECTION_KEY, JSON.stringify(value))
} catch {}
}
function loadShowMinimap(): boolean {
try {
const raw = localStorage.getItem(CANVAS_MINIMAP_KEY)
if (raw === 'true') return true
if (raw === 'false') return false
} catch {}
return false
}
function saveShowMinimap(value: boolean) {
try {
localStorage.setItem(CANVAS_MINIMAP_KEY, JSON.stringify(value))
} catch {}
}
function loadOrder(): string[] {
try {
const raw = localStorage.getItem(ORDER_STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as unknown
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []
} catch {
return []
}
}
function saveOrder(ids: string[]) {
localStorage.setItem(ORDER_STORAGE_KEY, JSON.stringify(ids))
}
function loadRecentIds(): string[] {
try {
const raw = localStorage.getItem(RECENT_STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as unknown
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string').slice(0, RECENT_MAX) : []
} catch {
return []
}
}
function saveRecentIds(ids: string[]) {
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(ids))
}
function loadProjects(): Project[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed
.filter(
(p): p is Project =>
p &&
typeof p === 'object' &&
typeof (p as Project).id === 'string' &&
typeof (p as Project).name === 'string' &&
typeof (p as Project).iconId === 'string' &&
typeof (p as Project).createdAt === 'number'
)
.map((p) => ({
...p,
lastEditedAt: typeof (p as Project).lastEditedAt === 'number' ? (p as Project).lastEditedAt : p.createdAt,
}))
} catch {
return []
}
}
function saveProjects(projects: Project[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(projects))
}
export type GraphSnapshot = { nodes: unknown[]; edges: unknown[] }
/** Projects sorted by projectOrder, then by lastEditedAt desc for any not in order */
export function sortProjectsByOrder(projects: Project[], order: string[]): Project[] {
const byId = new Map(projects.map((p) => [p.id, p]))
const ordered: Project[] = []
const seen = new Set<string>()
for (const id of order) {
const p = byId.get(id)
if (p) {
ordered.push(p)
seen.add(id)
}
}
const rest = projects
.filter((p) => !seen.has(p.id))
.sort((a, b) => (b.lastEditedAt ?? b.createdAt) - (a.lastEditedAt ?? a.createdAt))
return [...ordered, ...rest]
}
export type KosmosContextValue = {
projects: Project[]
projectOrder: string[]
/** Projects in display order (sidebar order, then lastEditedAt) */
orderedProjects: Project[]
/** Last RECENT_MAX accessed project IDs (most recent first) */
recentProjectIds: string[]
recordProjectAccess: (id: string) => void
persist: (next: Project[]) => void
createProject: (project: Project) => void
deleteProject: (id: string) => void
renameProject: (id: string, name: string) => void
updateLastEdited: (id: string) => void
reorderProjects: (orderedIds: string[]) => void
restoreProject: (project: Project, graphSnapshot: GraphSnapshot | null) => void
/** Canvas: show React Flow minimap (persisted) */
showMinimap: boolean
setShowMinimap: (value: boolean) => void
/** Agent node: AI connection (persisted). Sent to backend when running agent. */
aiConnection: AiConnection
setAiConnection: (value: AiConnection) => void
}
const KosmosContext = createContext<KosmosContextValue | null>(null)
export function KosmosProvider({ children }: { children: React.ReactNode }) {
const [projects, setProjects] = useState<Project[]>(loadProjects)
const [projectOrder, setProjectOrder] = useState<string[]>(loadOrder)
const [recentProjectIds, setRecentProjectIds] = useState<string[]>(loadRecentIds)
const [showMinimap, setShowMinimapState] = useState<boolean>(loadShowMinimap)
const [aiConnection, setAiConnectionState] = useState<AiConnection>(loadAiConnection)
const setShowMinimap = useCallback((value: boolean) => {
setShowMinimapState(value)
saveShowMinimap(value)
}, [])
const setAiConnection = useCallback((value: AiConnection) => {
setAiConnectionState(value)
saveAiConnection(value)
}, [])
const persist = useCallback((next: Project[]) => {
setProjects(next)
saveProjects(next)
}, [])
const createProject = useCallback(
(project: Project) => {
const withEdited = { ...project, lastEditedAt: project.createdAt }
persist([...projects, withEdited])
setProjectOrder((prev) => {
const next = prev.includes(project.id) ? prev : [project.id, ...prev]
saveOrder(next)
return next
})
},
[projects, persist]
)
const deleteProject = useCallback(
(id: string) => {
const next = projects.filter((p) => p.id !== id)
persist(next)
setProjectOrder((prev) => {
const nextOrder = prev.filter((oid) => oid !== id)
saveOrder(nextOrder)
return nextOrder
})
setRecentProjectIds((prev) => {
const next = prev.filter((oid) => oid !== id)
saveRecentIds(next)
return next
})
},
[projects, persist]
)
const renameProject = useCallback(
(id: string, name: string) => {
const next = projects.map((p) => (p.id === id ? { ...p, name } : p))
persist(next)
},
[projects, persist]
)
const updateLastEdited = useCallback(
(id: string) => {
const now = Date.now()
const next = projects.map((p) => (p.id === id ? { ...p, lastEditedAt: now } : p))
persist(next)
},
[projects, persist]
)
const reorderProjects = useCallback((orderedIds: string[]) => {
setProjectOrder(orderedIds)
saveOrder(orderedIds)
}, [])
const recordProjectAccess = useCallback((id: string) => {
setRecentProjectIds((prev) => {
const next = [id, ...prev.filter((x) => x !== id)].slice(0, RECENT_MAX)
saveRecentIds(next)
return next
})
}, [])
const restoreProject = useCallback(
(project: Project, graphSnapshot: GraphSnapshot | null) => {
persist([...projects, project])
setProjectOrder((prev) => {
const next = prev.includes(project.id) ? prev : [project.id, ...prev]
saveOrder(next)
return next
})
if (graphSnapshot) {
saveGraphToStorage(project.id, {
version: PROJECT_VERSION,
nodes: graphSnapshot.nodes,
edges: graphSnapshot.edges,
})
}
},
[projects, persist]
)
const orderedProjects = useMemo(
() => sortProjectsByOrder(projects, projectOrder),
[projects, projectOrder]
)
const value: KosmosContextValue = useMemo(
() => ({
projects,
projectOrder,
orderedProjects,
recentProjectIds,
recordProjectAccess,
persist,
createProject,
deleteProject,
renameProject,
updateLastEdited,
reorderProjects,
restoreProject,
showMinimap,
setShowMinimap,
aiConnection,
setAiConnection,
}),
[
projects,
projectOrder,
orderedProjects,
recentProjectIds,
recordProjectAccess,
persist,
createProject,
deleteProject,
renameProject,
updateLastEdited,
reorderProjects,
restoreProject,
showMinimap,
setShowMinimap,
aiConnection,
setAiConnection,
]
)
return <KosmosContext.Provider value={value}>{children}</KosmosContext.Provider>
}
export function usePlatform() {
const ctx = useContext(KosmosContext)
if (!ctx) throw new Error('usePlatform must be used within PlatformProvider')
return ctx
}

View File

@@ -0,0 +1,38 @@
/**
* Platform: main layout with sidebar and header. Renders child routes (projects list or canvas) via Outlet.
*/
import React, { useEffect, useState } from 'react'
import { Outlet, useParams } from 'react-router-dom'
import { SidebarProvider } from '@/components/ui/sidebar'
import { KosmosProvider, usePlatform } from './KosmosContext'
import { KosmosSidebar } from './KosmosSidebar'
function KosmosLayoutInner() {
const { projectId } = useParams<{ projectId: string }>()
const { recordProjectAccess } = usePlatform()
const [sidebarOpen, setSidebarOpen] = useState(true)
useEffect(() => {
if (projectId) recordProjectAccess(projectId)
}, [projectId, recordProjectAccess])
return (
<SidebarProvider open={sidebarOpen} onOpenChange={setSidebarOpen}>
<KosmosSidebar />
<div className="relative flex h-dvh min-h-0 w-full flex-1 flex-col overflow-hidden bg-background">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<Outlet />
</div>
</div>
</SidebarProvider>
)
}
export function KosmosPage() {
return (
<KosmosProvider>
<KosmosLayoutInner />
</KosmosProvider>
)
}

View File

@@ -0,0 +1,210 @@
/**
* Platform sidebar: All projects, Recently used, New project.
*/
import React, { useCallback, useMemo } from 'react'
import { Link, useNavigate, useParams, useLocation } from 'react-router-dom'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarTrigger,
} from '@/components/ui/sidebar'
import { Plus, Settings, Triangle } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useSidebar } from '@/components/ui/sidebar'
import { usePlatform } from './KosmosContext'
import { getProjectIcon } from '../../lib/iconMap'
import { NewProjectDialog } from './NewProjectDialog'
import { SettingsDialog } from './SettingsDialog'
import type { Project } from './types'
export function KosmosSidebar() {
const { state, setOpen } = useSidebar()
const isCollapsed = state === 'collapsed'
const { orderedProjects, createProject, recentProjectIds } = usePlatform()
const recentProjects = useMemo(() => {
const byId = new Map(orderedProjects.map((p) => [p.id, p]))
return recentProjectIds.map((id) => byId.get(id)).filter((p): p is Project => p != null)
}, [orderedProjects, recentProjectIds])
const navigate = useNavigate()
const location = useLocation()
const { projectId: selectedProjectId } = useParams<{ projectId: string }>()
const isKeroma = location.pathname === '/keroma'
const handleSelectProject = useCallback((id: string) => navigate(`/projects/${id}`), [navigate])
const handleCreateProject = useCallback(
(project: Parameters<typeof createProject>[0]) => {
createProject(project)
navigate(`/projects/${project.id}`)
},
[createProject, navigate]
)
return (
<>
<Sidebar collapsible="icon" className="relative">
{isCollapsed && (
<button
type="button"
className="absolute inset-0 z-0 cursor-pointer border-0 bg-transparent p-0 outline-none"
onClick={() => setOpen(true)}
aria-label="Expand sidebar"
/>
)}
<SidebarHeader className="relative z-10 flex flex-row items-center gap-2">
<SidebarMenu className="flex-1 min-w-0">
<SidebarMenuItem>
{isCollapsed ? (
<Tooltip>
<TooltipTrigger asChild>
<SidebarMenuButton
size="lg"
className="font-semibold font-serif"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setOpen(true)
}}
>
<span className="flex size-8 min-w-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground dark:bg-white dark:text-black">
<span
className="size-6 shrink-0 rounded-[2px] opacity-90"
style={{
maskImage: 'url(/app-icon.svg)',
maskRepeat: 'no-repeat',
maskPosition: 'center',
maskSize: 'contain',
WebkitMaskImage: 'url(/app-icon.svg)',
WebkitMaskRepeat: 'no-repeat',
WebkitMaskPosition: 'center',
WebkitMaskSize: 'contain',
backgroundColor: 'currentColor',
}}
aria-hidden
/>
</span>
<span className="font-serif group-data-[collapsible=icon]:hidden uppercase tracking-wide">ZOË</span>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">Click to expand sidebar</TooltipContent>
</Tooltip>
) : (
<SidebarMenuButton asChild size="lg" tooltip="Zoë" className="font-semibold font-serif">
<Link to="/projects">
<span className="flex size-8 min-w-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground dark:bg-white dark:text-black">
<span
className="size-6 shrink-0 rounded-[2px] opacity-90"
style={{
maskImage: 'url(/app-icon.svg)',
maskRepeat: 'no-repeat',
maskPosition: 'center',
maskSize: 'contain',
WebkitMaskImage: 'url(/app-icon.svg)',
WebkitMaskRepeat: 'no-repeat',
WebkitMaskPosition: 'center',
WebkitMaskSize: 'contain',
backgroundColor: 'currentColor',
}}
aria-hidden
/>
</span>
<span className="font-serif group-data-[collapsible=icon]:hidden uppercase tracking-wide">ZOË</span>
</Link>
</SidebarMenuButton>
)}
</SidebarMenuItem>
</SidebarMenu>
{!isCollapsed && <SidebarTrigger className="ml-auto shrink-0" />}
</SidebarHeader>
<SidebarContent
className="relative z-10"
onClick={isCollapsed ? (e) => { if ((e.target as HTMLElement).closest('button') || (e.target as HTMLElement).closest('a')) return; setOpen(true) } : undefined}
>
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="All projects" isActive={location.pathname === '/projects' && !selectedProjectId}>
<Link to="/projects">
<Triangle className="size-4" />
<span>Pleroma</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Keroma" isActive={isKeroma}>
<Link to="/keroma">
<Triangle className="size-4 rotate-180" />
<span>Keroma</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
{recentProjects.length > 0 && (
<SidebarGroup>
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">Recently used</SidebarGroupLabel>
<SidebarMenu>
{recentProjects.map((project) => {
const Icon = getProjectIcon(project.iconId)
const isActive = selectedProjectId === project.id
return (
<SidebarMenuItem key={project.id}>
<SidebarMenuButton
tooltip={project.name}
isActive={isActive}
onClick={() => handleSelectProject(project.id)}
>
<Icon className="size-4" />
<span>{project.name}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroup>
)}
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem>
<NewProjectDialog
onCreate={handleCreateProject}
existingNames={orderedProjects.map((p) => p.name)}
trigger={
<SidebarMenuButton className="text-sidebar-foreground/70 w-full cursor-pointer">
<Plus className="size-4" />
<span>{orderedProjects.length === 0 ? 'Create your first project' : 'New project'}</span>
</SidebarMenuButton>
}
/>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="relative z-10">
<SidebarMenu>
<SidebarMenuItem>
<SettingsDialog
trigger={
<SidebarMenuButton tooltip="Settings" className="w-full">
<Settings className="size-4" />
<span className="group-data-[collapsible=icon]:hidden">Settings</span>
</SidebarMenuButton>
}
/>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail className="relative z-10" />
</Sidebar>
</>
)
}

View File

@@ -0,0 +1,118 @@
/**
* Dialog to create a new project: name + icon.
*/
import React, { useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Plus } from 'lucide-react'
import { PROJECT_ICON_IDS, type Project, type ProjectIconId } from './types'
import { getProjectIcon } from '../../lib/iconMap'
type NewProjectDialogProps = {
onCreate: (project: Project) => void
trigger?: React.ReactNode
/** Other project names to check for duplicates (case-insensitive warning only) */
existingNames?: string[]
}
export function NewProjectDialog({ onCreate, trigger, existingNames = [] }: NewProjectDialogProps) {
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [iconId, setIconId] = useState<ProjectIconId>('cat')
const trimmed = name.trim()
const isDuplicate = trimmed.length > 0 && existingNames.some((n) => n.toLowerCase() === trimmed.toLowerCase())
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!trimmed) return
const project: Project = {
id: `proj_${Date.now()}`,
name: trimmed,
iconId,
createdAt: Date.now(),
}
onCreate(project)
setName('')
setIconId('cat')
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" size="sm" className="w-full justify-start gap-2">
<Plus className="size-4" />
New project
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>New project</DialogTitle>
<DialogDescription>Create a project to start editing a graph canvas.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<label htmlFor="project-name" className="text-sm font-medium">
Name
</label>
<Input
id="project-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My project"
autoFocus
/>
{isDuplicate && (
<p className="text-xs text-amber-600 dark:text-amber-500">A project with this name already exists.</p>
)}
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Icon</label>
<Select value={iconId} onValueChange={(v) => setIconId(v as ProjectIconId)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROJECT_ICON_IDS.map((id) => {
const Icon = getProjectIcon(id)
return (
<SelectItem key={id} value={id}>
<span className="flex items-center gap-2">
<Icon className="size-4" />
{id}
</span>
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={!name.trim()}>
Create
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

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

View File

@@ -0,0 +1,33 @@
/**
* Platform types: projects and sidebar state.
*/
import type { LucideIcon } from 'lucide-react'
export type Project = {
id: string
name: string
/** Icon identifier: key of PROJECT_ICONS map */
iconId: string
createdAt: number
/** Last time the project was opened/edited; used for sorting. Defaults to createdAt if missing. */
lastEditedAt?: number
}
/** Project icon ids: Lucide "Animals" category only */
export const PROJECT_ICON_IDS = [
'bird',
'bug',
'cat',
'dog',
'fish',
'rat',
'rabbit',
'squirrel',
'turtle',
'snail',
'shrimp',
'egg'
] as const
export type ProjectIconId = (typeof PROJECT_ICON_IDS)[number]