diff --git a/frontend/src/app/platform/AppSidebar.tsx b/frontend/src/app/platform/AppSidebar.tsx
index 87766b0..1406b7b 100644
--- a/frontend/src/app/platform/AppSidebar.tsx
+++ b/frontend/src/app/platform/AppSidebar.tsx
@@ -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,7 +17,7 @@ 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'
@@ -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() {
-
+
-
+
Pleroma
+
+
+
+
+ Keroma
+
+
+
{recentProjects.length > 0 && (
diff --git a/frontend/src/app/platform/KeromaPage.tsx b/frontend/src/app/platform/KeromaPage.tsx
new file mode 100644
index 0000000..329288e
--- /dev/null
+++ b/frontend/src/app/platform/KeromaPage.tsx
@@ -0,0 +1,20 @@
+/**
+ * Keroma page. Rendered at /keroma.
+ */
+
+import React from 'react'
+
+export function KeromaPage() {
+ return (
+
+ )
+}
diff --git a/frontend/src/app/platform/ProjectsPage.tsx b/frontend/src/app/platform/ProjectsPage.tsx
index 89506e0..986f688 100644
--- a/frontend/src/app/platform/ProjectsPage.tsx
+++ b/frontend/src/app/platform/ProjectsPage.tsx
@@ -47,7 +47,12 @@ import {
LayoutGrid,
List,
Network,
+ ArrowUpDown,
+ ArrowUp,
+ ArrowDown,
+ X,
} from 'lucide-react'
+import { Checkbox } from '@/components/ui/checkbox'
import { usePlatform } from './platformContext'
import { getProjectIcon } from '../../lib/iconMap'
import {
@@ -205,6 +210,8 @@ export function ProjectsPage() {
const [renameValue, setRenameValue] = useState('')
const renameInputRef = React.useRef(null)
const [deleteTarget, setDeleteTarget] = useState(null)
+ const [selectedIds, setSelectedIds] = useState>(new Set())
+ const [bulkDeleteTargets, setBulkDeleteTargets] = useState(null)
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
@@ -319,6 +326,78 @@ export function ProjectsPage() {
[createProject, navigate]
)
+ 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
+ return sortDir === 'asc' ? :
+ }
+
return (
@@ -330,8 +409,8 @@ export function ProjectsPage() {
-
-
+
+
-
- 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"
- >
-
-
- Cards
-
-
-
- Table
-
-
-
-
+
+ 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"
+ >
+
+
+
+
+
+
+
@@ -420,45 +495,75 @@ export function ProjectsPage() {
) : viewMode === 'table' ? (
<>
-
+
+ {(selectedIds.size > 0) && (
+
+
+ {selectedIds.size} selected
+
+
+
+
+
+
+
+ )}
+
+ toggleSelectAll()}
+ aria-label="Select all on page"
+ />
+
Size
-
+
+ Actions
+
@@ -466,10 +571,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 (
handleOpen(project.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -481,6 +587,13 @@ export function ProjectsPage() {
role="button"
aria-label={`Open ${project.name}`}
>
+ e.stopPropagation()}>
+ toggleSelection(project.id)}
+ aria-label={`Select ${project.name}`}
+ />
+
@@ -504,42 +617,67 @@ export function ProjectsPage() {
e.stopPropagation()}
>
-
-
-
-
-
- handleOpen(project.id)}>
-
- Open
-
- handleRenameOpen(project)}>
-
- Rename
-
- handleExport(project)}>
-
- Export
-
- handleDeleteOpen(project)}
- >
-
- Delete
-
-
-
+
+
+
+
+
+ Open
+
+
+
+
+
+ Rename
+
+
+
+
+
+ Export
+
+
+
+
+
+ Delete
+
+
)
@@ -803,6 +941,26 @@ export function ProjectsPage() {
+
+ {/* Bulk delete confirmation */}
+
diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..2550cab
--- /dev/null
+++ b/frontend/src/components/ui/checkbox.tsx
@@ -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, 'onChange'> {
+ checked?: boolean | 'indeterminate'
+ onCheckedChange?: (checked: boolean) => void
+}
+
+const Checkbox = React.forwardRef(
+ ({ className, checked, onCheckedChange, disabled, ...props }, ref) => {
+ const isChecked = checked === true
+ const isIndeterminate = checked === 'indeterminate'
+ return (
+
+ )
+ }
+)
+Checkbox.displayName = 'Checkbox'
+
+export { Checkbox }
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index fc4ac93..e36898e 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -6,6 +6,7 @@ import { ThemeProvider } from './lib/themeContext'
import { registerBuiltinNodes } from './lib/registerBuiltinNodes'
import { PlatformPage } from './app/platform/PlatformPage'
import { ProjectsPage } from './app/platform/ProjectsPage'
+import { KeromaPage } from './app/platform/KeromaPage'
import { CanvasRoute } from './app/platform/CanvasRoute'
import './styles.css'
import '@xyflow/react/dist/style.css'
@@ -21,6 +22,7 @@ createRoot(document.getElementById('root')!).render(
} />
} />
} />
+ } />