Compare commits

...

4 Commits

Author SHA1 Message Date
0b8c18a43f fix: Style 2026-03-11 17:43:37 +01:00
fc58dcdaa2 feat: refactor names 2026-03-11 17:24:32 +01:00
6471cee05f feat: menu refactoring 2026-03-11 17:16:13 +01:00
b92088f583 feat: add keroma 2026-03-11 17:07:27 +01:00
17 changed files with 455 additions and 161 deletions

View File

@@ -10,14 +10,10 @@ import {
MenubarItem,
MenubarMenu,
MenubarSeparator,
MenubarTrigger,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
MenubarCheckboxItem,
MenubarTrigger
} from '@/components/ui/menubar'
import { Kbd, KbdGroup } from '@/components/ui/kbd'
import { usePlatform } from '@/app/platform/platformContext'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { ArrowLeft, ClipboardPaste, Copy, CopyPlus, Download, FolderOpen, Pencil, Redo2, Undo2 } from 'lucide-react'
import { Input } from '@/components/ui/input'
@@ -135,8 +131,20 @@ export function CanvasMenubar({
<ArrowLeft className="size-4" />
</Link>
<MenubarMenu>
<MenubarTrigger className="font-medium">Project</MenubarTrigger>
<MenubarTrigger className="font-normal text-muted-foreground">Project</MenubarTrigger>
<MenubarContent>
{projectId && (
<>
<MenubarItem
onClick={() => setIsRenamingProject(true)}
className="gap-2"
>
<Pencil className="h-4 w-4" />
Rename
</MenubarItem>
<MenubarSeparator />
</>
)}
<MenubarItem onClick={onImport} className="gap-2">
<FolderOpen className="h-4 w-4" />
Import
@@ -145,22 +153,10 @@ export function CanvasMenubar({
<Download className="h-4 w-4" />
Export
</MenubarItem>
{projectId && (
<>
<MenubarSeparator />
<MenubarItem
onClick={() => setIsRenamingProject(true)}
className="gap-2"
>
<Pencil className="h-4 w-4" />
Rename
</MenubarItem>
</>
)}
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger className="font-medium">Edit</MenubarTrigger>
<MenubarTrigger className="font-normal text-muted-foreground">Edit</MenubarTrigger>
<MenubarContent>
<MenubarItem onClick={undo} disabled={!canUndo} className="gap-2">
<Undo2 className="h-4 w-4" />
@@ -217,7 +213,7 @@ export function CanvasMenubar({
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger className="font-medium">View</MenubarTrigger>
<MenubarTrigger className="font-normal text-muted-foreground">View</MenubarTrigger>
<MenubarContent>
{onFitView && (
<MenubarItem onClick={onFitView} className="gap-2">
@@ -250,11 +246,11 @@ export function CanvasMenubar({
}
}}
onBlur={handleRenameBlur}
className="h-7 text-sm font-medium text-center"
className="h-7 text-sm font-medium text-center font-serif"
aria-label="Project name"
/>
) : (
<span className="pointer-events-none truncate text-sm font-medium text-foreground">
<span className="pointer-events-none truncate text-sm font-medium text-foreground font-serif">
{projectName ?? 'Untitled'}
</span>
)}

View File

@@ -25,7 +25,7 @@ import {
import { AnimatedEdge } from '@/components/base/AnimatedEdge'
import FlowContext from '@/lib/flowContext'
import { useTheme } from '@/lib/themeContext'
import { usePlatform } from '@/app/platform/platformContext'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
import {
ContextMenu,
@@ -67,7 +67,7 @@ import {
saveGraphToStorage,
PROJECT_FILE_EXT,
PROJECT_VERSION,
} from '@/app/platform/projectGraphStorage'
} from '@/app/pleroma/projectGraphStorage'
const SNAP_GRID: [number, number] = [15, 15]
const DUPLICATE_OFFSET = { x: 30, y: 30 }

View File

@@ -4,8 +4,8 @@
import React, { useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { CanvasPage } from '../canvas/CanvasPage'
import { usePlatform } from './platformContext'
import { CanvasPage } from './CanvasPage'
import { usePlatform } from '../kosmos/KosmosContext'
export function CanvasRoute() {
const { projectId } = useParams<{ projectId: string }>()

View File

@@ -0,0 +1,20 @@
/**
* Keroma page. Rendered at /keroma.
*/
import React from 'react'
export function KeromaPage() {
return (
<div className="relative flex flex-1 flex-col min-h-0 p-4">
<div className="flex flex-wrap items-center gap-3">
<h1 className="p-3 scroll-m-20 text-4xl font-extrabold tracking-tight text-balance font-serif">
Keroma
</h1>
</div>
<div className="rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
<p className="text-muted-foreground">Welcome to Keroma.</p>
</div>
</div>
)
}

View File

@@ -5,8 +5,8 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'
import type { Project } from './types'
import { saveGraphToStorage } from './projectGraphStorage'
import { PROJECT_VERSION } from './projectGraphStorage'
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'
@@ -149,7 +149,7 @@ export function sortProjectsByOrder(projects: Project[], order: string[]): Proje
return [...ordered, ...rest]
}
export type PlatformContextValue = {
export type KosmosContextValue = {
projects: Project[]
projectOrder: string[]
/** Projects in display order (sidebar order, then lastEditedAt) */
@@ -172,9 +172,9 @@ export type PlatformContextValue = {
setAiConnection: (value: AiConnection) => void
}
const PlatformContext = createContext<PlatformContextValue | null>(null)
const KosmosContext = createContext<KosmosContextValue | null>(null)
export function PlatformProvider({ children }: { children: React.ReactNode }) {
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)
@@ -281,7 +281,7 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) {
[projects, projectOrder]
)
const value: PlatformContextValue = useMemo(
const value: KosmosContextValue = useMemo(
() => ({
projects,
projectOrder,
@@ -320,11 +320,11 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) {
]
)
return <PlatformContext.Provider value={value}>{children}</PlatformContext.Provider>
return <KosmosContext.Provider value={value}>{children}</KosmosContext.Provider>
}
export function usePlatform() {
const ctx = useContext(PlatformContext)
const ctx = useContext(KosmosContext)
if (!ctx) throw new Error('usePlatform must be used within PlatformProvider')
return ctx
}

View File

@@ -5,10 +5,10 @@
import React, { useEffect, useState } from 'react'
import { Outlet, useParams } from 'react-router-dom'
import { SidebarProvider } from '@/components/ui/sidebar'
import { PlatformProvider, usePlatform } from './platformContext'
import { AppSidebar } from './AppSidebar'
import { KosmosProvider, usePlatform } from './KosmosContext'
import { KosmosSidebar } from './KosmosSidebar'
function PlatformLayoutInner() {
function KosmosLayoutInner() {
const { projectId } = useParams<{ projectId: string }>()
const { recordProjectAccess } = usePlatform()
const [sidebarOpen, setSidebarOpen] = useState(true)
@@ -19,7 +19,7 @@ function PlatformLayoutInner() {
return (
<SidebarProvider open={sidebarOpen} onOpenChange={setSidebarOpen}>
<AppSidebar />
<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 />
@@ -29,10 +29,10 @@ function PlatformLayoutInner() {
)
}
export function PlatformPage() {
export function KosmosPage() {
return (
<PlatformProvider>
<PlatformLayoutInner />
</PlatformProvider>
<KosmosProvider>
<KosmosLayoutInner />
</KosmosProvider>
)
}

View File

@@ -3,7 +3,7 @@
*/
import React, { useCallback, useMemo } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { Link, useNavigate, useParams, useLocation } from 'react-router-dom'
import {
Sidebar,
SidebarContent,
@@ -17,16 +17,16 @@ import {
SidebarRail,
SidebarTrigger,
} from '@/components/ui/sidebar'
import { Plus, ListTodo, Settings } from 'lucide-react'
import { Plus, Settings, Triangle } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useSidebar } from '@/components/ui/sidebar'
import { usePlatform } from './platformContext'
import { usePlatform } from './KosmosContext'
import { getProjectIcon } from '../../lib/iconMap'
import { NewProjectDialog } from './NewProjectDialog'
import { SettingsDialog } from './SettingsDialog'
import type { Project } from './types'
export function AppSidebar() {
export function KosmosSidebar() {
const { state, setOpen } = useSidebar()
const isCollapsed = state === 'collapsed'
const { orderedProjects, createProject, recentProjectIds } = usePlatform()
@@ -35,7 +35,9 @@ export function AppSidebar() {
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])
@@ -130,13 +132,21 @@ export function AppSidebar() {
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="All projects" isActive={!selectedProjectId}>
<SidebarMenuButton asChild tooltip="All projects" isActive={location.pathname === '/projects' && !selectedProjectId}>
<Link to="/projects">
<ListTodo className="size-4" />
<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 && (

View File

@@ -22,8 +22,8 @@ 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 './platformContext'
import type { AiConnection, AiConnectionProvider } from './platformContext'
import { usePlatform } from './KosmosContext'
import type { AiConnection, AiConnectionProvider } from './KosmosContext'
import { Sun, Sparkles } from 'lucide-react'
type SettingsSection = 'appearance' | 'ai'

View File

@@ -18,6 +18,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
@@ -47,19 +48,26 @@ import {
LayoutGrid,
List,
Network,
ArrowUpDown,
ArrowUp,
ArrowDown,
X,
Copy,
} from 'lucide-react'
import { usePlatform } from './platformContext'
import { Checkbox } from '@/components/ui/checkbox'
import { usePlatform } from '../kosmos/KosmosContext'
import { getProjectIcon } from '../../lib/iconMap'
import {
loadGraphFromStorage,
saveGraphToStorage,
removeGraphFromStorage,
PROJECT_FILE_EXT,
PROJECT_VERSION,
} from './projectGraphStorage'
import { toast } from 'sonner'
import { NewProjectDialog } from './NewProjectDialog'
import { NewProjectDialog } from '../kosmos/NewProjectDialog'
import { ProjectsPageBackground } from './ProjectsPageBackground'
import type { Project } from './types'
import type { Project } from '../kosmos/types'
const PAGE_SIZE_OPTIONS = [10, 25, 50] as const
type SortKey = 'lastEdited' | 'name' | 'created'
@@ -102,6 +110,19 @@ function getGraphCounts(projectId: string): { nodes: number; edges: number } {
type NodeLike = { id: string; position?: { x: number; y: number } }
type EdgeLike = { id?: string; source: string; target: string }
/** Shared grid style for empty thumbnail, non-empty thumbnail SVG, and New project placeholder. */
const THUMBNAIL_GRID = {
baseFill: 'hsl(var(--muted) / 0.4)',
dotFill: 'hsl(var(--muted-foreground) / 0.06)',
size: 8,
} as const
const thumbnailGridStyle: React.CSSProperties = {
backgroundColor: THUMBNAIL_GRID.baseFill,
backgroundImage: `radial-gradient(circle, ${THUMBNAIL_GRID.dotFill} 1px, transparent 1px)`,
backgroundSize: `${THUMBNAIL_GRID.size}px ${THUMBNAIL_GRID.size}px`,
}
/** Renders a minimal SVG preview of the graph from storage, or a placeholder. */
function GraphThumbnail({ projectId, className }: { projectId: string; className?: string }) {
const stored = loadGraphFromStorage(projectId)
@@ -115,11 +136,7 @@ function GraphThumbnail({ projectId, className }: { projectId: string; className
return (
<div
className={`flex h-full w-full flex-col items-center justify-center gap-1 text-muted-foreground ${className ?? ''}`}
style={{
backgroundImage: 'radial-gradient(circle, currentColor 1px, transparent 1px)',
backgroundSize: '8px 8px',
backgroundColor: 'hsl(var(--muted) / 0.5)',
}}
style={thumbnailGridStyle}
aria-hidden
>
<Network className="size-8" />
@@ -156,11 +173,11 @@ function GraphThumbnail({ projectId, className }: { projectId: string; className
aria-hidden
>
<defs>
<pattern id={dotGridPatternId} width={8} height={8} patternUnits="userSpaceOnUse">
<circle cx={1} cy={1} r={0.6} fill="hsl(var(--muted-foreground) / 0.2)" />
<pattern id={dotGridPatternId} width={THUMBNAIL_GRID.size} height={THUMBNAIL_GRID.size} patternUnits="userSpaceOnUse">
<circle cx={THUMBNAIL_GRID.size / 2} cy={THUMBNAIL_GRID.size / 2} r={1} fill={THUMBNAIL_GRID.dotFill} />
</pattern>
</defs>
<rect width={200} height={120} fill="hsl(var(--muted) / 0.4)" />
<rect width={200} height={120} fill={THUMBNAIL_GRID.baseFill} />
<rect width={200} height={120} fill={`url(#${dotGridPatternId})`} />
{validEdges.slice(0, 50).map((e, i) => {
const a = nodeById.get(e.source)!.position!
@@ -190,6 +207,55 @@ function GraphThumbnail({ projectId, className }: { projectId: string; className
)
}
type ProjectActionsMenuProps = {
project: Project
onOpen: (id: string) => void
onRenameOpen: (project: Project) => void
onDuplicateOpen: (project: Project) => void
onExport: (project: Project) => void
onDeleteOpen: (project: Project) => void
trigger: React.ReactNode
}
function ProjectActionsMenu({
project,
onOpen,
onRenameOpen,
onDuplicateOpen,
onExport,
onDeleteOpen,
trigger,
}: ProjectActionsMenuProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onOpen(project.id)}>
<FolderOpen className="size-4" />
Open
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onRenameOpen(project)}>
<Pencil className="size-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDuplicateOpen(project)}>
<Copy className="size-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onExport(project)}>
<Download className="size-4" />
Export
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => onDeleteOpen(project)}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export type ViewMode = 'table' | 'cards'
export function ProjectsPage() {
@@ -205,6 +271,11 @@ export function ProjectsPage() {
const [renameValue, setRenameValue] = useState('')
const renameInputRef = React.useRef<HTMLInputElement>(null)
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null)
const [duplicateTarget, setDuplicateTarget] = useState<Project | null>(null)
const [duplicateName, setDuplicateName] = useState('')
const duplicateInputRef = React.useRef<HTMLInputElement>(null)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [bulkDeleteTargets, setBulkDeleteTargets] = useState<Project[] | null>(null)
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
@@ -259,6 +330,13 @@ export function ProjectsPage() {
}
}, [renameTarget])
React.useEffect(() => {
if (duplicateTarget) {
const t = setTimeout(() => duplicateInputRef.current?.focus(), 0)
return () => clearTimeout(t)
}
}, [duplicateTarget])
const handleRenameSubmit = useCallback(() => {
if (renameTarget && renameValue.trim()) {
renameProject(renameTarget.id, renameValue.trim())
@@ -319,19 +397,118 @@ export function ProjectsPage() {
[createProject, navigate]
)
const handleDuplicateOpen = useCallback((project: Project) => {
setDuplicateTarget(project)
setDuplicateName(`${project.name} (copy)`)
}, [])
const handleDuplicateConfirm = useCallback(() => {
if (!duplicateTarget || !duplicateName.trim()) return
const name = duplicateName.trim()
const newId = `proj_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
const now = Date.now()
const newProject: Project = {
id: newId,
name,
iconId: duplicateTarget.iconId,
createdAt: now,
lastEditedAt: now,
}
createProject(newProject)
const graph = loadGraphFromStorage(duplicateTarget.id)
if (graph && (graph.nodes.length > 0 || graph.edges.length > 0)) {
saveGraphToStorage(newId, { version: PROJECT_VERSION, nodes: graph.nodes, edges: graph.edges })
}
toast.success('Project duplicated')
setDuplicateTarget(null)
setDuplicateName('')
}, [duplicateTarget, duplicateName, createProject])
const allOnPageSelected = pageItems.length > 0 && pageItems.every((p) => selectedIds.has(p.id))
const someOnPageSelected = pageItems.some((p) => selectedIds.has(p.id))
const toggleSelection = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const toggleSelectAll = useCallback(() => {
if (allOnPageSelected) {
setSelectedIds((prev) => {
const next = new Set(prev)
pageItems.forEach((p) => next.delete(p.id))
return next
})
} else {
setSelectedIds((prev) => {
const next = new Set(prev)
pageItems.forEach((p) => next.add(p.id))
return next
})
}
}, [allOnPageSelected, pageItems])
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
const handleBulkDeleteOpen = useCallback(() => {
const toDelete = sorted.filter((p) => selectedIds.has(p.id))
if (toDelete.length > 0) setBulkDeleteTargets(toDelete)
}, [sorted, selectedIds])
const handleBulkDeleteConfirm = useCallback(() => {
if (!bulkDeleteTargets || bulkDeleteTargets.length === 0) return
const count = bulkDeleteTargets.length
bulkDeleteTargets.forEach((project) => {
removeGraphFromStorage(project.id)
deleteProject(project.id)
})
setBulkDeleteTargets(null)
setSelectedIds(new Set())
navigate('/projects', { replace: true })
toast(`${count} project${count === 1 ? '' : 's'} deleted`)
}, [bulkDeleteTargets, deleteProject, navigate])
const handleBulkExport = useCallback(() => {
const toExport = sorted.filter((p) => selectedIds.has(p.id))
toExport.forEach((project) => {
const stored = loadGraphFromStorage(project.id)
const state = stored
? { version: PROJECT_VERSION, nodes: stored.nodes, edges: stored.edges }
: { version: PROJECT_VERSION, nodes: [], edges: [] }
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const filename = `${project.name.replace(/[^\w.-]/g, '_')}${PROJECT_FILE_EXT}`
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
})
toast.success(`Exported ${toExport.length} project${toExport.length === 1 ? '' : 's'}`)
}, [sorted, selectedIds])
const SortIcon = ({ columnKey }: { columnKey: SortKey }) => {
if (sortKey !== columnKey) return <ArrowUpDown className="size-3.5 opacity-50" />
return sortDir === 'asc' ? <ArrowUp className="size-3.5" /> : <ArrowDown className="size-3.5" />
}
return (
<div className="relative flex flex-1 flex-col min-h-0">
<ProjectsPageBackground className="absolute inset-0 pointer-events-none" />
<div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 overflow-auto">
<TooltipProvider>
<div className="flex flex-wrap items-center gap-3">
<h1 className="p-3 scroll-m-20 text-4xl font-extrabold tracking-tight text-balance">
<h1 className="p-3 scroll-m-20 text-4xl font-extrabold tracking-tight text-balance font-serif">
Hi, Demiurge
</h1>
</div>
<div className="flex flex-col gap-4 rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
<div className="grid w-full grid-cols-1 gap-3 sm:grid-cols-3 sm:items-center">
<div className="relative w-64 shrink-0 sm:justify-self-start">
<div className="flex flex-wrap items-center gap-3">
<div className="relative w-64 shrink-0">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground pointer-events-none" />
<Input
placeholder="Search projects"
@@ -344,27 +521,7 @@ export function ProjectsPage() {
aria-label="Search projects by name"
/>
</div>
<div className="flex justify-self-start sm:justify-self-center">
<ToggleGroup
type="single"
value={viewMode}
onValueChange={(v) => v && setViewMode(v as ViewMode)}
aria-label="View mode"
variant="outline"
size="sm"
className="gap-0 rounded-md p-0.5 [&>button]:rounded-none [&>button:first-child]:rounded-l-md [&>button:last-child]:rounded-r-md [&>button:not(:first-child)]:border-l-0"
>
<ToggleGroupItem value="cards" aria-label="Cards view" className="gap-1.5 px-2.5">
<LayoutGrid className="size-3.5" />
Cards
</ToggleGroupItem>
<ToggleGroupItem value="table" aria-label="Table view" className="gap-1.5 px-2.5">
<List className="size-3.5" />
Table
</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="flex h-9 items-center gap-2 sm:justify-self-end">
<div className="ml-auto flex h-9 items-center gap-2">
<Select
value={pageSize === -1 ? 'all' : String(pageSize)}
onValueChange={(v) => {
@@ -384,6 +541,22 @@ export function ProjectsPage() {
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
<ToggleGroup
type="single"
value={viewMode}
onValueChange={(v) => v && setViewMode(v as ViewMode)}
aria-label="View mode"
variant="outline"
size="sm"
className="gap-0 rounded-md p-0.5 [&>button]:rounded-none [&>button:first-child]:rounded-l-md [&>button:last-child]:rounded-r-md [&>button:not(:first-child)]:border-l-0"
>
<ToggleGroupItem value="cards" aria-label="Cards view" className="px-2.5">
<LayoutGrid className="size-4" />
</ToggleGroupItem>
<ToggleGroupItem value="table" aria-label="Table view" className="px-2.5">
<List className="size-4" />
</ToggleGroupItem>
</ToggleGroup>
</div>
</div>
@@ -405,7 +578,7 @@ export function ProjectsPage() {
}}
aria-label="Create new project"
>
<div className="flex aspect-video w-full shrink-0 items-center justify-center bg-muted/30">
<div className="flex aspect-video w-full shrink-0 items-center justify-center" style={thumbnailGridStyle}>
<Plus className="size-12 text-muted-foreground" />
</div>
<div className="flex flex-1 flex-col gap-1 p-3">
@@ -420,45 +593,73 @@ export function ProjectsPage() {
</div>
) : viewMode === 'table' ? (
<>
<div className="overflow-auto">
<div className="overflow-auto rounded-md border">
{(selectedIds.size > 0) && (
<div className="flex flex-wrap items-center gap-2 border-b bg-muted/60 px-3 py-2">
<span className="text-sm font-medium tabular-nums">
{selectedIds.size} selected
</span>
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" onClick={handleBulkExport} className="h-8 gap-1.5 px-2.5">
<Download className="size-3.5" />
Export
</Button>
<Button variant="outline" size="sm" className="h-8 gap-1.5 px-2.5 text-destructive hover:text-destructive" onClick={handleBulkDeleteOpen}>
<Trash2 className="size-3.5" />
Delete
</Button>
</div>
<Button variant="ghost" size="sm" onClick={clearSelection} className="ml-auto h-8 gap-1.5 px-2.5">
<X className="size-3.5" />
Clear
</Button>
</div>
)}
<Table className="table-fixed">
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow className="hover:bg-transparent border-b h-8">
<TableHead className="w-10 px-2 py-1.5 text-xs [&:has([role=checkbox])]:pr-0">
<Checkbox
checked={pageItems.length === 0 ? false : allOnPageSelected ? true : someOnPageSelected ? 'indeterminate' : false}
onCheckedChange={() => toggleSelectAll()}
aria-label="Select all on page"
/>
</TableHead>
<TableHead className="w-[35%] min-w-[100px] h-8 px-2 py-1.5 text-xs">
<button
type="button"
className="flex items-center font-medium hover:underline"
className="flex items-center gap-1 font-medium hover:underline"
onClick={() => handleSort('name')}
aria-sort={sortKey === 'name' ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
>
Name
{sortKey === 'name' && (sortDir === 'asc' ? ' ↑' : ' ↓')}
<SortIcon columnKey="name" />
</button>
</TableHead>
<TableHead className="hidden sm:table-cell w-[120px] min-w-[100px] h-8 px-2 py-1.5 text-xs">
<button
type="button"
className="font-medium hover:underline"
className="flex items-center gap-1 font-medium hover:underline"
onClick={() => handleSort('created')}
aria-sort={sortKey === 'created' ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
>
Created
{sortKey === 'created' && (sortDir === 'asc' ? ' ↑' : ' ↓')}
<SortIcon columnKey="created" />
</button>
</TableHead>
<TableHead className="hidden md:table-cell w-[100px] min-w-[80px] h-8 px-2 py-1.5 text-xs">Size</TableHead>
<TableHead className="w-[115px] min-w-[90px] h-8 px-2 py-1.5 text-xs">
<button
type="button"
className="font-medium hover:underline"
className="flex items-center gap-1 font-medium hover:underline"
onClick={() => handleSort('lastEdited')}
aria-sort={sortKey === 'lastEdited' ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
>
Last edited
{sortKey === 'lastEdited' && (sortDir === 'asc' ? ' ↑' : ' ↓')}
<SortIcon columnKey="lastEdited" />
</button>
</TableHead>
<TableHead className="sticky right-0 z-10 w-[52px] min-w-[52px] bg-card h-8 px-1 py-1.5 text-xs" />
<TableHead className="sticky right-0 z-10 w-12 min-w-12 bg-card h-8 px-1 py-1.5 text-xs font-medium" />
</TableRow>
</TableHeader>
<TableBody className="text-xs">
@@ -466,10 +667,11 @@ export function ProjectsPage() {
const Icon = getProjectIcon(project.iconId)
const counts = getGraphCounts(project.id)
const lastEdited = project.lastEditedAt ?? project.createdAt
const isSelected = selectedIds.has(project.id)
return (
<TableRow
key={project.id}
className="cursor-pointer hover:bg-muted/50 h-8"
className={`group cursor-pointer hover:bg-muted/50 h-8 ${isSelected ? 'bg-muted/70' : ''}`}
onClick={() => handleOpen(project.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -481,6 +683,13 @@ export function ProjectsPage() {
role="button"
aria-label={`Open ${project.name}`}
>
<TableCell className="px-2 py-1.5 w-10 [&:has([role=checkbox])]:pr-0" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelection(project.id)}
aria-label={`Select ${project.name}`}
/>
</TableCell>
<TableCell className="px-2 py-1.5 min-w-0">
<div className="flex items-center gap-1.5 min-w-0">
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
@@ -504,42 +713,22 @@ export function ProjectsPage() {
</Tooltip>
</TableCell>
<TableCell
className="sticky right-0 bg-card w-[52px] min-w-[52px] px-1 py-1.5"
className={`sticky right-0 w-12 min-w-12 px-1 py-1.5 ${isSelected ? 'bg-muted/70' : 'bg-card group-hover:bg-muted/50'}`}
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label={`Actions for ${project.name}`}
>
<ProjectActionsMenu
project={project}
onOpen={handleOpen}
onRenameOpen={handleRenameOpen}
onDuplicateOpen={handleDuplicateOpen}
onExport={handleExport}
onDeleteOpen={handleDeleteOpen}
trigger={
<Button variant="ghost" size="icon" className="size-7" aria-label={`Actions for ${project.name}`}>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleOpen(project.id)}>
<FolderOpen className="size-4" />
Open
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleRenameOpen(project)}>
<Pencil className="size-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport(project)}>
<Download className="size-4" />
Export
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => handleDeleteOpen(project)}
>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
/>
</TableCell>
</TableRow>
)
@@ -614,7 +803,7 @@ export function ProjectsPage() {
}}
aria-label="Create new project"
>
<div className="flex aspect-video w-full shrink-0 items-center justify-center bg-muted/30">
<div className="flex aspect-video w-full shrink-0 items-center justify-center" style={thumbnailGridStyle}>
<Plus className="size-12 text-muted-foreground" />
</div>
<div className="flex flex-1 flex-col gap-1 p-3">
@@ -648,8 +837,14 @@ export function ProjectsPage() {
className="absolute right-1.5 top-1.5 z-10 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ProjectActionsMenu
project={project}
onOpen={handleOpen}
onRenameOpen={handleRenameOpen}
onDuplicateOpen={handleDuplicateOpen}
onExport={handleExport}
onDeleteOpen={handleDeleteOpen}
trigger={
<Button
variant="secondary"
size="icon"
@@ -659,35 +854,14 @@ export function ProjectsPage() {
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleOpen(project.id)}>
<FolderOpen className="size-4" />
Open
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleRenameOpen(project)}>
<Pencil className="size-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport(project)}>
<Download className="size-4" />
Export
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => handleDeleteOpen(project)}
>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
/>
</div>
</div>
<div className="flex flex-1 flex-col gap-1 p-3">
<div className="flex items-center gap-2">
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate font-medium">{project.name}</span>
<span className="truncate font-medium font-serif">{project.name}</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
@@ -782,6 +956,38 @@ export function ProjectsPage() {
</DialogContent>
</Dialog>
{/* Duplicate dialog */}
<Dialog open={!!duplicateTarget} onOpenChange={(open) => { if (!open) { setDuplicateTarget(null); setDuplicateName('') } }}>
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>Duplicate project</DialogTitle>
<DialogDescription>Enter a name for the duplicate project.</DialogDescription>
</DialogHeader>
<Input
ref={duplicateInputRef}
value={duplicateName}
onChange={(e) => setDuplicateName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleDuplicateConfirm()
if (e.key === 'Escape') setDuplicateTarget(null)
}}
placeholder="Project name"
aria-label="Duplicate project name"
/>
{duplicateTarget && duplicateName.trim() && orderedProjects.some((p) => p.name.toLowerCase() === duplicateName.trim().toLowerCase()) && (
<p className="text-xs text-amber-600 dark:text-amber-500">A project with this name already exists.</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => { setDuplicateTarget(null); setDuplicateName('') }}>
Cancel
</Button>
<Button onClick={handleDuplicateConfirm} disabled={!duplicateName.trim()}>
Duplicate
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete confirmation */}
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent>
@@ -803,6 +1009,26 @@ export function ProjectsPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Bulk delete confirmation */}
<Dialog open={bulkDeleteTargets !== null && bulkDeleteTargets.length > 0} onOpenChange={(open) => !open && setBulkDeleteTargets(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete {bulkDeleteTargets?.length ?? 0} projects</DialogTitle>
<DialogDescription>
Are you sure you want to delete these projects? This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkDeleteTargets(null)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleBulkDeleteConfirm}>
Delete all
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TooltipProvider>
</div>
</div>

View File

@@ -16,7 +16,7 @@ import { NodeFooterEdgeIndicators } from '@/components/base/NodeFooterEdgeIndica
import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle'
import { InputHandle, OutputHandle } from '@/components/base/NodeHandles'
import { Button } from '@/components/ui/button'
import { usePlatform } from '@/app/platform/platformContext'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { Bot, Play, Loader2 } from 'lucide-react'
export type AgentNodeData = {

View File

@@ -0,0 +1,40 @@
import * as React from 'react'
import { Check, Minus } from 'lucide-react'
import { cn } from '@/lib/utils'
export interface CheckboxProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'> {
checked?: boolean | 'indeterminate'
onCheckedChange?: (checked: boolean) => void
}
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
({ className, checked, onCheckedChange, disabled, ...props }, ref) => {
const isChecked = checked === true
const isIndeterminate = checked === 'indeterminate'
return (
<button
type="button"
role="checkbox"
ref={ref}
aria-checked={isIndeterminate ? 'mixed' : isChecked}
disabled={disabled}
className={cn(
'peer inline-flex h-4 w-4 shrink-0 items-center justify-center rounded border border-primary shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
isChecked || isIndeterminate ? 'bg-primary text-primary-foreground' : 'bg-background',
className
)}
onClick={(e) => {
e.stopPropagation()
if (disabled) return
onCheckedChange?.(!isChecked)
}}
{...props}
>
{isIndeterminate ? <Minus className="size-2.5" /> : isChecked ? <Check className="size-2.5" /> : null}
</button>
)
}
)
Checkbox.displayName = 'Checkbox'
export { Checkbox }

View File

@@ -18,7 +18,7 @@ import {
Turtle,
type LucideIcon,
} from 'lucide-react'
import type { ProjectIconId } from '../app/platform/types'
import type { ProjectIconId } from '../app/kosmos/types'
export const PROJECT_ICON_MAP: Record<ProjectIconId, LucideIcon> = {
bird: Bird,

View File

@@ -4,9 +4,10 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { Toaster } from 'sonner'
import { ThemeProvider } from './lib/themeContext'
import { registerBuiltinNodes } from './lib/registerBuiltinNodes'
import { PlatformPage } from './app/platform/PlatformPage'
import { ProjectsPage } from './app/platform/ProjectsPage'
import { CanvasRoute } from './app/platform/CanvasRoute'
import { KosmosPage } from './app/kosmos/KosmosPage'
import { ProjectsPage } from './app/pleroma/PleromaPage'
import { KeromaPage } from './app/keroma/KeromaPage'
import { CanvasRoute } from './app/canvas/CanvasRoute'
import './styles.css'
import '@xyflow/react/dist/style.css'
@@ -17,10 +18,11 @@ createRoot(document.getElementById('root')!).render(
<ThemeProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<PlatformPage />}>
<Route path="/" element={<KosmosPage />}>
<Route index element={<Navigate to="/projects" replace />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:projectId" element={<CanvasRoute />} />
<Route path="keroma" element={<KeromaPage />} />
</Route>
</Routes>
</BrowserRouter>