feat: add keroma
This commit is contained in:
@@ -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() {
|
||||
<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 && (
|
||||
|
||||
20
frontend/src/app/platform/KeromaPage.tsx
Normal file
20
frontend/src/app/platform/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">
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLInputElement>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [bulkDeleteTargets, setBulkDeleteTargets] = useState<Project[] | null>(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 <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" />
|
||||
@@ -330,8 +409,8 @@ export function ProjectsPage() {
|
||||
</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 +423,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 +443,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>
|
||||
|
||||
@@ -420,45 +495,75 @@ 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-[140px] min-w-[140px] bg-card h-8 px-2 py-1.5 text-xs font-medium">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="text-xs">
|
||||
@@ -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 (
|
||||
<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 +587,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 +617,67 @@ 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-[140px] min-w-[140px] px-2 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}`}
|
||||
>
|
||||
<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 className="flex items-center justify-end gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
aria-label={`Open ${project.name}`}
|
||||
onClick={() => handleOpen(project.id)}
|
||||
>
|
||||
<FolderOpen className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
aria-label={`Rename ${project.name}`}
|
||||
onClick={() => handleRenameOpen(project)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Rename</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-foreground"
|
||||
aria-label={`Export ${project.name}`}
|
||||
onClick={() => handleExport(project)}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Export</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-destructive"
|
||||
aria-label={`Delete ${project.name}`}
|
||||
onClick={() => handleDeleteOpen(project)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
@@ -803,6 +941,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>
|
||||
|
||||
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 }
|
||||
@@ -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(
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user