Compare commits
4 Commits
f201fe92f4
...
0b8c18a43f
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b8c18a43f | |||
| fc58dcdaa2 | |||
| 6471cee05f | |||
| b92088f583 |
@@ -10,14 +10,10 @@ import {
|
|||||||
MenubarItem,
|
MenubarItem,
|
||||||
MenubarMenu,
|
MenubarMenu,
|
||||||
MenubarSeparator,
|
MenubarSeparator,
|
||||||
MenubarTrigger,
|
MenubarTrigger
|
||||||
MenubarSub,
|
|
||||||
MenubarSubTrigger,
|
|
||||||
MenubarSubContent,
|
|
||||||
MenubarCheckboxItem,
|
|
||||||
} from '@/components/ui/menubar'
|
} from '@/components/ui/menubar'
|
||||||
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
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 { ArrowLeft, ClipboardPaste, Copy, CopyPlus, Download, FolderOpen, Pencil, Redo2, Undo2 } from 'lucide-react'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
@@ -135,8 +131,20 @@ export function CanvasMenubar({
|
|||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
<MenubarMenu>
|
<MenubarMenu>
|
||||||
<MenubarTrigger className="font-medium">Project</MenubarTrigger>
|
<MenubarTrigger className="font-normal text-muted-foreground">Project</MenubarTrigger>
|
||||||
<MenubarContent>
|
<MenubarContent>
|
||||||
|
{projectId && (
|
||||||
|
<>
|
||||||
|
<MenubarItem
|
||||||
|
onClick={() => setIsRenamingProject(true)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
Rename
|
||||||
|
</MenubarItem>
|
||||||
|
<MenubarSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<MenubarItem onClick={onImport} className="gap-2">
|
<MenubarItem onClick={onImport} className="gap-2">
|
||||||
<FolderOpen className="h-4 w-4" />
|
<FolderOpen className="h-4 w-4" />
|
||||||
Import…
|
Import…
|
||||||
@@ -145,22 +153,10 @@ export function CanvasMenubar({
|
|||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
Export…
|
Export…
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
{projectId && (
|
|
||||||
<>
|
|
||||||
<MenubarSeparator />
|
|
||||||
<MenubarItem
|
|
||||||
onClick={() => setIsRenamingProject(true)}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
Rename
|
|
||||||
</MenubarItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</MenubarContent>
|
</MenubarContent>
|
||||||
</MenubarMenu>
|
</MenubarMenu>
|
||||||
<MenubarMenu>
|
<MenubarMenu>
|
||||||
<MenubarTrigger className="font-medium">Edit</MenubarTrigger>
|
<MenubarTrigger className="font-normal text-muted-foreground">Edit</MenubarTrigger>
|
||||||
<MenubarContent>
|
<MenubarContent>
|
||||||
<MenubarItem onClick={undo} disabled={!canUndo} className="gap-2">
|
<MenubarItem onClick={undo} disabled={!canUndo} className="gap-2">
|
||||||
<Undo2 className="h-4 w-4" />
|
<Undo2 className="h-4 w-4" />
|
||||||
@@ -217,7 +213,7 @@ export function CanvasMenubar({
|
|||||||
</MenubarContent>
|
</MenubarContent>
|
||||||
</MenubarMenu>
|
</MenubarMenu>
|
||||||
<MenubarMenu>
|
<MenubarMenu>
|
||||||
<MenubarTrigger className="font-medium">View</MenubarTrigger>
|
<MenubarTrigger className="font-normal text-muted-foreground">View</MenubarTrigger>
|
||||||
<MenubarContent>
|
<MenubarContent>
|
||||||
{onFitView && (
|
{onFitView && (
|
||||||
<MenubarItem onClick={onFitView} className="gap-2">
|
<MenubarItem onClick={onFitView} className="gap-2">
|
||||||
@@ -250,11 +246,11 @@ export function CanvasMenubar({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onBlur={handleRenameBlur}
|
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"
|
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'}
|
{projectName ?? 'Untitled'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
import { AnimatedEdge } from '@/components/base/AnimatedEdge'
|
import { AnimatedEdge } from '@/components/base/AnimatedEdge'
|
||||||
import FlowContext from '@/lib/flowContext'
|
import FlowContext from '@/lib/flowContext'
|
||||||
import { useTheme } from '@/lib/themeContext'
|
import { useTheme } from '@/lib/themeContext'
|
||||||
import { usePlatform } from '@/app/platform/platformContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
|
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@@ -67,7 +67,7 @@ import {
|
|||||||
saveGraphToStorage,
|
saveGraphToStorage,
|
||||||
PROJECT_FILE_EXT,
|
PROJECT_FILE_EXT,
|
||||||
PROJECT_VERSION,
|
PROJECT_VERSION,
|
||||||
} from '@/app/platform/projectGraphStorage'
|
} from '@/app/pleroma/projectGraphStorage'
|
||||||
|
|
||||||
const SNAP_GRID: [number, number] = [15, 15]
|
const SNAP_GRID: [number, number] = [15, 15]
|
||||||
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
import React, { useEffect } from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { CanvasPage } from '../canvas/CanvasPage'
|
import { CanvasPage } from './CanvasPage'
|
||||||
import { usePlatform } from './platformContext'
|
import { usePlatform } from '../kosmos/KosmosContext'
|
||||||
|
|
||||||
export function CanvasRoute() {
|
export function CanvasRoute() {
|
||||||
const { projectId } = useParams<{ projectId: string }>()
|
const { projectId } = useParams<{ projectId: string }>()
|
||||||
20
frontend/src/app/keroma/KeromaPage.tsx
Normal file
20
frontend/src/app/keroma/KeromaPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@
|
|||||||
|
|
||||||
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
||||||
import type { Project } from './types'
|
import type { Project } from './types'
|
||||||
import { saveGraphToStorage } from './projectGraphStorage'
|
import { saveGraphToStorage } from '../pleroma/projectGraphStorage'
|
||||||
import { PROJECT_VERSION } from './projectGraphStorage'
|
import { PROJECT_VERSION } from '../pleroma/projectGraphStorage'
|
||||||
|
|
||||||
const STORAGE_KEY = 'zui_platform_projects'
|
const STORAGE_KEY = 'zui_platform_projects'
|
||||||
const ORDER_STORAGE_KEY = 'zui_platform_project_order'
|
const ORDER_STORAGE_KEY = 'zui_platform_project_order'
|
||||||
@@ -149,7 +149,7 @@ export function sortProjectsByOrder(projects: Project[], order: string[]): Proje
|
|||||||
return [...ordered, ...rest]
|
return [...ordered, ...rest]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PlatformContextValue = {
|
export type KosmosContextValue = {
|
||||||
projects: Project[]
|
projects: Project[]
|
||||||
projectOrder: string[]
|
projectOrder: string[]
|
||||||
/** Projects in display order (sidebar order, then lastEditedAt) */
|
/** Projects in display order (sidebar order, then lastEditedAt) */
|
||||||
@@ -172,9 +172,9 @@ export type PlatformContextValue = {
|
|||||||
setAiConnection: (value: AiConnection) => void
|
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 [projects, setProjects] = useState<Project[]>(loadProjects)
|
||||||
const [projectOrder, setProjectOrder] = useState<string[]>(loadOrder)
|
const [projectOrder, setProjectOrder] = useState<string[]>(loadOrder)
|
||||||
const [recentProjectIds, setRecentProjectIds] = useState<string[]>(loadRecentIds)
|
const [recentProjectIds, setRecentProjectIds] = useState<string[]>(loadRecentIds)
|
||||||
@@ -281,7 +281,7 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) {
|
|||||||
[projects, projectOrder]
|
[projects, projectOrder]
|
||||||
)
|
)
|
||||||
|
|
||||||
const value: PlatformContextValue = useMemo(
|
const value: KosmosContextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
projects,
|
projects,
|
||||||
projectOrder,
|
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() {
|
export function usePlatform() {
|
||||||
const ctx = useContext(PlatformContext)
|
const ctx = useContext(KosmosContext)
|
||||||
if (!ctx) throw new Error('usePlatform must be used within PlatformProvider')
|
if (!ctx) throw new Error('usePlatform must be used within PlatformProvider')
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
@@ -5,10 +5,10 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { Outlet, useParams } from 'react-router-dom'
|
import { Outlet, useParams } from 'react-router-dom'
|
||||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||||
import { PlatformProvider, usePlatform } from './platformContext'
|
import { KosmosProvider, usePlatform } from './KosmosContext'
|
||||||
import { AppSidebar } from './AppSidebar'
|
import { KosmosSidebar } from './KosmosSidebar'
|
||||||
|
|
||||||
function PlatformLayoutInner() {
|
function KosmosLayoutInner() {
|
||||||
const { projectId } = useParams<{ projectId: string }>()
|
const { projectId } = useParams<{ projectId: string }>()
|
||||||
const { recordProjectAccess } = usePlatform()
|
const { recordProjectAccess } = usePlatform()
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||||
@@ -19,7 +19,7 @@ function PlatformLayoutInner() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
<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="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">
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
@@ -29,10 +29,10 @@ function PlatformLayoutInner() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlatformPage() {
|
export function KosmosPage() {
|
||||||
return (
|
return (
|
||||||
<PlatformProvider>
|
<KosmosProvider>
|
||||||
<PlatformLayoutInner />
|
<KosmosLayoutInner />
|
||||||
</PlatformProvider>
|
</KosmosProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useMemo } from 'react'
|
import React, { useCallback, useMemo } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams, useLocation } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -17,16 +17,16 @@ import {
|
|||||||
SidebarRail,
|
SidebarRail,
|
||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
} from '@/components/ui/sidebar'
|
} 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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useSidebar } from '@/components/ui/sidebar'
|
import { useSidebar } from '@/components/ui/sidebar'
|
||||||
import { usePlatform } from './platformContext'
|
import { usePlatform } from './KosmosContext'
|
||||||
import { getProjectIcon } from '../../lib/iconMap'
|
import { getProjectIcon } from '../../lib/iconMap'
|
||||||
import { NewProjectDialog } from './NewProjectDialog'
|
import { NewProjectDialog } from './NewProjectDialog'
|
||||||
import { SettingsDialog } from './SettingsDialog'
|
import { SettingsDialog } from './SettingsDialog'
|
||||||
import type { Project } from './types'
|
import type { Project } from './types'
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function KosmosSidebar() {
|
||||||
const { state, setOpen } = useSidebar()
|
const { state, setOpen } = useSidebar()
|
||||||
const isCollapsed = state === 'collapsed'
|
const isCollapsed = state === 'collapsed'
|
||||||
const { orderedProjects, createProject, recentProjectIds } = usePlatform()
|
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)
|
return recentProjectIds.map((id) => byId.get(id)).filter((p): p is Project => p != null)
|
||||||
}, [orderedProjects, recentProjectIds])
|
}, [orderedProjects, recentProjectIds])
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
const { projectId: selectedProjectId } = useParams<{ projectId: string }>()
|
const { projectId: selectedProjectId } = useParams<{ projectId: string }>()
|
||||||
|
const isKeroma = location.pathname === '/keroma'
|
||||||
|
|
||||||
const handleSelectProject = useCallback((id: string) => navigate(`/projects/${id}`), [navigate])
|
const handleSelectProject = useCallback((id: string) => navigate(`/projects/${id}`), [navigate])
|
||||||
|
|
||||||
@@ -130,13 +132,21 @@ export function AppSidebar() {
|
|||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton asChild tooltip="All projects" isActive={!selectedProjectId}>
|
<SidebarMenuButton asChild tooltip="All projects" isActive={location.pathname === '/projects' && !selectedProjectId}>
|
||||||
<Link to="/projects">
|
<Link to="/projects">
|
||||||
<ListTodo className="size-4" />
|
<Triangle className="size-4" />
|
||||||
<span>Pleroma</span>
|
<span>Pleroma</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton asChild tooltip="Keroma" isActive={isKeroma}>
|
||||||
|
<Link to="/keroma">
|
||||||
|
<Triangle className="size-4 rotate-180" />
|
||||||
|
<span>Keroma</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
{recentProjects.length > 0 && (
|
{recentProjects.length > 0 && (
|
||||||
@@ -22,8 +22,8 @@ import { Switch } from '@/components/ui/switch'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { useTheme } from '@/lib/themeContext'
|
import { useTheme } from '@/lib/themeContext'
|
||||||
import type { Theme } from '@/lib/themeContext'
|
import type { Theme } from '@/lib/themeContext'
|
||||||
import { usePlatform } from './platformContext'
|
import { usePlatform } from './KosmosContext'
|
||||||
import type { AiConnection, AiConnectionProvider } from './platformContext'
|
import type { AiConnection, AiConnectionProvider } from './KosmosContext'
|
||||||
import { Sun, Sparkles } from 'lucide-react'
|
import { Sun, Sparkles } from 'lucide-react'
|
||||||
|
|
||||||
type SettingsSection = 'appearance' | 'ai'
|
type SettingsSection = 'appearance' | 'ai'
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import {
|
import {
|
||||||
@@ -47,19 +48,26 @@ import {
|
|||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
Network,
|
Network,
|
||||||
|
ArrowUpDown,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
X,
|
||||||
|
Copy,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { usePlatform } from './platformContext'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { usePlatform } from '../kosmos/KosmosContext'
|
||||||
import { getProjectIcon } from '../../lib/iconMap'
|
import { getProjectIcon } from '../../lib/iconMap'
|
||||||
import {
|
import {
|
||||||
loadGraphFromStorage,
|
loadGraphFromStorage,
|
||||||
|
saveGraphToStorage,
|
||||||
removeGraphFromStorage,
|
removeGraphFromStorage,
|
||||||
PROJECT_FILE_EXT,
|
PROJECT_FILE_EXT,
|
||||||
PROJECT_VERSION,
|
PROJECT_VERSION,
|
||||||
} from './projectGraphStorage'
|
} from './projectGraphStorage'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { NewProjectDialog } from './NewProjectDialog'
|
import { NewProjectDialog } from '../kosmos/NewProjectDialog'
|
||||||
import { ProjectsPageBackground } from './ProjectsPageBackground'
|
import { ProjectsPageBackground } from './ProjectsPageBackground'
|
||||||
import type { Project } from './types'
|
import type { Project } from '../kosmos/types'
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [10, 25, 50] as const
|
const PAGE_SIZE_OPTIONS = [10, 25, 50] as const
|
||||||
type SortKey = 'lastEdited' | 'name' | 'created'
|
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 NodeLike = { id: string; position?: { x: number; y: number } }
|
||||||
type EdgeLike = { id?: string; source: string; target: string }
|
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. */
|
/** Renders a minimal SVG preview of the graph from storage, or a placeholder. */
|
||||||
function GraphThumbnail({ projectId, className }: { projectId: string; className?: string }) {
|
function GraphThumbnail({ projectId, className }: { projectId: string; className?: string }) {
|
||||||
const stored = loadGraphFromStorage(projectId)
|
const stored = loadGraphFromStorage(projectId)
|
||||||
@@ -115,11 +136,7 @@ function GraphThumbnail({ projectId, className }: { projectId: string; className
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex h-full w-full flex-col items-center justify-center gap-1 text-muted-foreground ${className ?? ''}`}
|
className={`flex h-full w-full flex-col items-center justify-center gap-1 text-muted-foreground ${className ?? ''}`}
|
||||||
style={{
|
style={thumbnailGridStyle}
|
||||||
backgroundImage: 'radial-gradient(circle, currentColor 1px, transparent 1px)',
|
|
||||||
backgroundSize: '8px 8px',
|
|
||||||
backgroundColor: 'hsl(var(--muted) / 0.5)',
|
|
||||||
}}
|
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
<Network className="size-8" />
|
<Network className="size-8" />
|
||||||
@@ -156,11 +173,11 @@ function GraphThumbnail({ projectId, className }: { projectId: string; className
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<pattern id={dotGridPatternId} width={8} height={8} patternUnits="userSpaceOnUse">
|
<pattern id={dotGridPatternId} width={THUMBNAIL_GRID.size} height={THUMBNAIL_GRID.size} patternUnits="userSpaceOnUse">
|
||||||
<circle cx={1} cy={1} r={0.6} fill="hsl(var(--muted-foreground) / 0.2)" />
|
<circle cx={THUMBNAIL_GRID.size / 2} cy={THUMBNAIL_GRID.size / 2} r={1} fill={THUMBNAIL_GRID.dotFill} />
|
||||||
</pattern>
|
</pattern>
|
||||||
</defs>
|
</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})`} />
|
<rect width={200} height={120} fill={`url(#${dotGridPatternId})`} />
|
||||||
{validEdges.slice(0, 50).map((e, i) => {
|
{validEdges.slice(0, 50).map((e, i) => {
|
||||||
const a = nodeById.get(e.source)!.position!
|
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 type ViewMode = 'table' | 'cards'
|
||||||
|
|
||||||
export function ProjectsPage() {
|
export function ProjectsPage() {
|
||||||
@@ -205,6 +271,11 @@ export function ProjectsPage() {
|
|||||||
const [renameValue, setRenameValue] = useState('')
|
const [renameValue, setRenameValue] = useState('')
|
||||||
const renameInputRef = React.useRef<HTMLInputElement>(null)
|
const renameInputRef = React.useRef<HTMLInputElement>(null)
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(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 filtered = useMemo(() => {
|
||||||
const q = search.trim().toLowerCase()
|
const q = search.trim().toLowerCase()
|
||||||
@@ -259,6 +330,13 @@ export function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
}, [renameTarget])
|
}, [renameTarget])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (duplicateTarget) {
|
||||||
|
const t = setTimeout(() => duplicateInputRef.current?.focus(), 0)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}
|
||||||
|
}, [duplicateTarget])
|
||||||
|
|
||||||
const handleRenameSubmit = useCallback(() => {
|
const handleRenameSubmit = useCallback(() => {
|
||||||
if (renameTarget && renameValue.trim()) {
|
if (renameTarget && renameValue.trim()) {
|
||||||
renameProject(renameTarget.id, renameValue.trim())
|
renameProject(renameTarget.id, renameValue.trim())
|
||||||
@@ -319,19 +397,118 @@ export function ProjectsPage() {
|
|||||||
[createProject, navigate]
|
[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 (
|
return (
|
||||||
<div className="relative flex flex-1 flex-col min-h-0">
|
<div className="relative flex flex-1 flex-col min-h-0">
|
||||||
<ProjectsPageBackground className="absolute inset-0 pointer-events-none" />
|
<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">
|
<div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 overflow-auto">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<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
|
Hi, Demiurge
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
|
<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="flex flex-wrap items-center gap-3">
|
||||||
<div className="relative w-64 shrink-0 sm:justify-self-start">
|
<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" />
|
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search projects"
|
placeholder="Search projects"
|
||||||
@@ -344,27 +521,7 @@ export function ProjectsPage() {
|
|||||||
aria-label="Search projects by name"
|
aria-label="Search projects by name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-self-start sm:justify-self-center">
|
<div className="ml-auto flex h-9 items-center gap-2">
|
||||||
<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">
|
|
||||||
<Select
|
<Select
|
||||||
value={pageSize === -1 ? 'all' : String(pageSize)}
|
value={pageSize === -1 ? 'all' : String(pageSize)}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
@@ -384,6 +541,22 @@ export function ProjectsPage() {
|
|||||||
<SelectItem value="all">All</SelectItem>
|
<SelectItem value="all">All</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -405,7 +578,7 @@ export function ProjectsPage() {
|
|||||||
}}
|
}}
|
||||||
aria-label="Create new project"
|
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" />
|
<Plus className="size-12 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col gap-1 p-3">
|
<div className="flex flex-1 flex-col gap-1 p-3">
|
||||||
@@ -420,45 +593,73 @@ export function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : viewMode === 'table' ? (
|
) : 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">
|
<Table className="table-fixed">
|
||||||
<TableHeader className="sticky top-0 z-10 bg-card">
|
<TableHeader className="sticky top-0 z-10 bg-card">
|
||||||
<TableRow className="hover:bg-transparent border-b h-8">
|
<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">
|
<TableHead className="w-[35%] min-w-[100px] h-8 px-2 py-1.5 text-xs">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center font-medium hover:underline"
|
className="flex items-center gap-1 font-medium hover:underline"
|
||||||
onClick={() => handleSort('name')}
|
onClick={() => handleSort('name')}
|
||||||
aria-sort={sortKey === 'name' ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
|
aria-sort={sortKey === 'name' ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
|
||||||
>
|
>
|
||||||
Name
|
Name
|
||||||
{sortKey === 'name' && (sortDir === 'asc' ? ' ↑' : ' ↓')}
|
<SortIcon columnKey="name" />
|
||||||
</button>
|
</button>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden sm:table-cell w-[120px] min-w-[100px] h-8 px-2 py-1.5 text-xs">
|
<TableHead className="hidden sm:table-cell w-[120px] min-w-[100px] h-8 px-2 py-1.5 text-xs">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="font-medium hover:underline"
|
className="flex items-center gap-1 font-medium hover:underline"
|
||||||
onClick={() => handleSort('created')}
|
onClick={() => handleSort('created')}
|
||||||
aria-sort={sortKey === 'created' ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
|
aria-sort={sortKey === 'created' ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
|
||||||
>
|
>
|
||||||
Created
|
Created
|
||||||
{sortKey === 'created' && (sortDir === 'asc' ? ' ↑' : ' ↓')}
|
<SortIcon columnKey="created" />
|
||||||
</button>
|
</button>
|
||||||
</TableHead>
|
</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="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">
|
<TableHead className="w-[115px] min-w-[90px] h-8 px-2 py-1.5 text-xs">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="font-medium hover:underline"
|
className="flex items-center gap-1 font-medium hover:underline"
|
||||||
onClick={() => handleSort('lastEdited')}
|
onClick={() => handleSort('lastEdited')}
|
||||||
aria-sort={sortKey === 'lastEdited' ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
|
aria-sort={sortKey === 'lastEdited' ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
|
||||||
>
|
>
|
||||||
Last edited
|
Last edited
|
||||||
{sortKey === 'lastEdited' && (sortDir === 'asc' ? ' ↑' : ' ↓')}
|
<SortIcon columnKey="lastEdited" />
|
||||||
</button>
|
</button>
|
||||||
</TableHead>
|
</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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody className="text-xs">
|
<TableBody className="text-xs">
|
||||||
@@ -466,10 +667,11 @@ export function ProjectsPage() {
|
|||||||
const Icon = getProjectIcon(project.iconId)
|
const Icon = getProjectIcon(project.iconId)
|
||||||
const counts = getGraphCounts(project.id)
|
const counts = getGraphCounts(project.id)
|
||||||
const lastEdited = project.lastEditedAt ?? project.createdAt
|
const lastEdited = project.lastEditedAt ?? project.createdAt
|
||||||
|
const isSelected = selectedIds.has(project.id)
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={project.id}
|
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)}
|
onClick={() => handleOpen(project.id)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
@@ -481,6 +683,13 @@ export function ProjectsPage() {
|
|||||||
role="button"
|
role="button"
|
||||||
aria-label={`Open ${project.name}`}
|
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">
|
<TableCell className="px-2 py-1.5 min-w-0">
|
||||||
<div className="flex items-center gap-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" />
|
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
@@ -504,42 +713,22 @@ export function ProjectsPage() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<DropdownMenu>
|
<ProjectActionsMenu
|
||||||
<DropdownMenuTrigger asChild>
|
project={project}
|
||||||
<Button
|
onOpen={handleOpen}
|
||||||
variant="ghost"
|
onRenameOpen={handleRenameOpen}
|
||||||
size="icon"
|
onDuplicateOpen={handleDuplicateOpen}
|
||||||
className="size-7"
|
onExport={handleExport}
|
||||||
aria-label={`Actions for ${project.name}`}
|
onDeleteOpen={handleDeleteOpen}
|
||||||
>
|
trigger={
|
||||||
|
<Button variant="ghost" size="icon" className="size-7" aria-label={`Actions for ${project.name}`}>
|
||||||
<MoreHorizontal className="size-4" />
|
<MoreHorizontal className="size-4" />
|
||||||
</Button>
|
</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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
)
|
||||||
@@ -614,7 +803,7 @@ export function ProjectsPage() {
|
|||||||
}}
|
}}
|
||||||
aria-label="Create new project"
|
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" />
|
<Plus className="size-12 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col gap-1 p-3">
|
<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"
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<DropdownMenu>
|
<ProjectActionsMenu
|
||||||
<DropdownMenuTrigger asChild>
|
project={project}
|
||||||
|
onOpen={handleOpen}
|
||||||
|
onRenameOpen={handleRenameOpen}
|
||||||
|
onDuplicateOpen={handleDuplicateOpen}
|
||||||
|
onExport={handleExport}
|
||||||
|
onDeleteOpen={handleDeleteOpen}
|
||||||
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -659,35 +854,14 @@ export function ProjectsPage() {
|
|||||||
>
|
>
|
||||||
<MoreHorizontal className="size-4" />
|
<MoreHorizontal className="size-4" />
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
<div className="flex flex-1 flex-col gap-1 p-3">
|
<div className="flex flex-1 flex-col gap-1 p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
<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>
|
</div>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -782,6 +956,38 @@ export function ProjectsPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 */}
|
{/* Delete confirmation */}
|
||||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -803,6 +1009,26 @@ export function ProjectsPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -16,7 +16,7 @@ import { NodeFooterEdgeIndicators } from '@/components/base/NodeFooterEdgeIndica
|
|||||||
import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle'
|
import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle'
|
||||||
import { InputHandle, OutputHandle } from '@/components/base/NodeHandles'
|
import { InputHandle, OutputHandle } from '@/components/base/NodeHandles'
|
||||||
import { Button } from '@/components/ui/button'
|
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'
|
import { Bot, Play, Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
export type AgentNodeData = {
|
export type AgentNodeData = {
|
||||||
|
|||||||
40
frontend/src/components/ui/checkbox.tsx
Normal file
40
frontend/src/components/ui/checkbox.tsx
Normal 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 }
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
Turtle,
|
Turtle,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react'
|
} 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> = {
|
export const PROJECT_ICON_MAP: Record<ProjectIconId, LucideIcon> = {
|
||||||
bird: Bird,
|
bird: Bird,
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
|||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
import { ThemeProvider } from './lib/themeContext'
|
import { ThemeProvider } from './lib/themeContext'
|
||||||
import { registerBuiltinNodes } from './lib/registerBuiltinNodes'
|
import { registerBuiltinNodes } from './lib/registerBuiltinNodes'
|
||||||
import { PlatformPage } from './app/platform/PlatformPage'
|
import { KosmosPage } from './app/kosmos/KosmosPage'
|
||||||
import { ProjectsPage } from './app/platform/ProjectsPage'
|
import { ProjectsPage } from './app/pleroma/PleromaPage'
|
||||||
import { CanvasRoute } from './app/platform/CanvasRoute'
|
import { KeromaPage } from './app/keroma/KeromaPage'
|
||||||
|
import { CanvasRoute } from './app/canvas/CanvasRoute'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
import '@xyflow/react/dist/style.css'
|
import '@xyflow/react/dist/style.css'
|
||||||
|
|
||||||
@@ -17,10 +18,11 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<PlatformPage />}>
|
<Route path="/" element={<KosmosPage />}>
|
||||||
<Route index element={<Navigate to="/projects" replace />} />
|
<Route index element={<Navigate to="/projects" replace />} />
|
||||||
<Route path="projects" element={<ProjectsPage />} />
|
<Route path="projects" element={<ProjectsPage />} />
|
||||||
<Route path="projects/:projectId" element={<CanvasRoute />} />
|
<Route path="projects/:projectId" element={<CanvasRoute />} />
|
||||||
|
<Route path="keroma" element={<KeromaPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
Reference in New Issue
Block a user