From b92088f583158abe651916f853a7d77c756e870f Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 11 Mar 2026 17:07:27 +0100 Subject: [PATCH] feat: add keroma --- frontend/src/app/platform/AppSidebar.tsx | 18 +- frontend/src/app/platform/KeromaPage.tsx | 20 ++ frontend/src/app/platform/ProjectsPage.tsx | 290 ++++++++++++++++----- frontend/src/components/ui/checkbox.tsx | 40 +++ frontend/src/main.tsx | 2 + 5 files changed, 300 insertions(+), 70 deletions(-) create mode 100644 frontend/src/app/platform/KeromaPage.tsx create mode 100644 frontend/src/components/ui/checkbox.tsx 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 ( +
+
+

+ Keroma +

+
+
+

Welcome to Keroma.

+
+
+ ) +} 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 */} + 0} onOpenChange={(open) => !open && setBulkDeleteTargets(null)}> + + + Delete {bulkDeleteTargets?.length ?? 0} projects + + Are you sure you want to delete these projects? This cannot be undone. + + + + + + + +
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( } /> } /> } /> + } />