@cero-base/panel 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # @cero-base/panel
2
+
3
+ Debug panel component for cero-base. Displays schema, collections, rooms, and system data in a collapsible panel overlay.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @cero-base/panel
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```jsx
14
+ import { CeroPanel } from '@cero-base/panel'
15
+
16
+ function App() {
17
+ return (
18
+ <>
19
+ <MyApp />
20
+ <CeroPanel db={db} />
21
+ </>
22
+ )
23
+ }
24
+ ```
25
+
26
+ ## Exports
27
+
28
+ | Export | Description |
29
+ | --------------------- | --------------------------------- |
30
+ | `CeroPanel` | Debug panel component |
31
+ | `useSchema` | Hook for schema introspection |
32
+ | `useSchemaByScope` | Hook for schema filtered by scope |
33
+ | `useCollectionSchema` | Hook for single collection schema |
34
+ | `useCollection` | Hook for collection data |
35
+ | `useRooms` | Hook for room list |
36
+ | `useRoomSelection` | Hook for room selection state |
37
+ | `useIdentityData` | Hook for identity system data |
38
+ | `useRoomData` | Hook for room system data |
39
+
40
+ ## License
41
+
42
+ Apache-2.0
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@cero-base/panel",
3
+ "version": "0.0.1",
4
+ "description": "Admin panel UI for cero-base",
5
+ "files": [
6
+ "src",
7
+ "README.md"
8
+ ],
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "type": "module",
13
+ "main": "src/index.js",
14
+ "exports": {
15
+ ".": "./src/index.js",
16
+ "./global.css": "./src/global.css"
17
+ },
18
+ "dependencies": {
19
+ "@base-ui/react": "^1.0.0",
20
+ "@cero-base/core": "^0.0.1",
21
+ "@cero-base/react": "^0.0.1",
22
+ "@tanstack/react-table": "^8.0.0",
23
+ "b4a": "^1.6.4",
24
+ "class-variance-authority": "^0.7.0",
25
+ "clsx": "^2.1.0",
26
+ "lucide-react": "^0.400.0",
27
+ "react-resizable-panels": "^2.0.0",
28
+ "streamx": "^2.23.0",
29
+ "tailwind-merge": "^2.0.0"
30
+ },
31
+ "peerDependencies": {
32
+ "react": ">=18",
33
+ "react-dom": ">=18"
34
+ },
35
+ "license": "Apache-2.0",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/lekinox/cero-base.git",
39
+ "directory": "packages/panel"
40
+ }
41
+ }
@@ -0,0 +1,245 @@
1
+ import { useState, useMemo } from 'react'
2
+ import {
3
+ flexRender,
4
+ getCoreRowModel,
5
+ getSortedRowModel,
6
+ getPaginationRowModel,
7
+ useReactTable
8
+ } from '@tanstack/react-table'
9
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table'
10
+ import { Checkbox } from './ui/checkbox'
11
+ import { Button } from './ui/button'
12
+ import {
13
+ DropdownMenu,
14
+ DropdownMenuContent,
15
+ DropdownMenuItem,
16
+ DropdownMenuTrigger
17
+ } from './ui/dropdown-menu'
18
+ import { formatValue, truncate } from '../lib/field-types'
19
+ import { cn } from '../lib/utils'
20
+ import {
21
+ ArrowUpDown,
22
+ ArrowUp,
23
+ ArrowDown,
24
+ MoreHorizontal,
25
+ Pencil,
26
+ Trash2,
27
+ ChevronLeft,
28
+ ChevronRight
29
+ } from 'lucide-react'
30
+
31
+ function SortButton({ column, children }) {
32
+ const sorted = column.getIsSorted()
33
+ return (
34
+ <button
35
+ onClick={() => column.toggleSorting(sorted === 'asc')}
36
+ className='flex items-center gap-1 hover:text-foreground'
37
+ >
38
+ {children}
39
+ {sorted === 'asc' ? (
40
+ <ArrowUp className='size-3' />
41
+ ) : sorted === 'desc' ? (
42
+ <ArrowDown className='size-3' />
43
+ ) : (
44
+ <ArrowUpDown className='size-3 opacity-50' />
45
+ )}
46
+ </button>
47
+ )
48
+ }
49
+
50
+ function ActionCell({ row, onEdit, onDelete }) {
51
+ return (
52
+ <DropdownMenu>
53
+ <DropdownMenuTrigger asChild>
54
+ <Button variant='ghost' size='icon-sm' className='size-6'>
55
+ <MoreHorizontal className='size-3.5' />
56
+ <span className='sr-only'>Actions</span>
57
+ </Button>
58
+ </DropdownMenuTrigger>
59
+ <DropdownMenuContent align='end'>
60
+ <DropdownMenuItem onClick={() => onEdit(row.original)}>
61
+ <Pencil />
62
+ Edit
63
+ </DropdownMenuItem>
64
+ <DropdownMenuItem variant='destructive' onClick={() => onDelete(row.original)}>
65
+ <Trash2 />
66
+ Delete
67
+ </DropdownMenuItem>
68
+ </DropdownMenuContent>
69
+ </DropdownMenu>
70
+ )
71
+ }
72
+
73
+ function Pagination({ table }) {
74
+ return (
75
+ <div className='flex items-center justify-between px-2 py-2 border-t border-border/50'>
76
+ <div className='text-xs text-muted-foreground'>
77
+ {table.getFilteredSelectedRowModel().rows.length} of{' '}
78
+ {table.getFilteredRowModel().rows.length} selected
79
+ </div>
80
+ <div className='flex items-center gap-1'>
81
+ <span className='text-xs text-muted-foreground mr-2'>
82
+ Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
83
+ </span>
84
+ <Button
85
+ variant='outline'
86
+ size='icon-sm'
87
+ onClick={() => table.previousPage()}
88
+ disabled={!table.getCanPreviousPage()}
89
+ >
90
+ <ChevronLeft className='size-3.5' />
91
+ </Button>
92
+ <Button
93
+ variant='outline'
94
+ size='icon-sm'
95
+ onClick={() => table.nextPage()}
96
+ disabled={!table.getCanNextPage()}
97
+ >
98
+ <ChevronRight className='size-3.5' />
99
+ </Button>
100
+ </div>
101
+ </div>
102
+ )
103
+ }
104
+
105
+ export function DataTable({ data, schema, loading, onEdit, onDelete, onSelectionChange }) {
106
+ const [sorting, setSorting] = useState([])
107
+ const [rowSelection, setRowSelection] = useState({})
108
+
109
+ // Build columns from schema
110
+ const columns = useMemo(() => {
111
+ if (!schema?.fields) return []
112
+
113
+ const cols = [
114
+ // Selection column
115
+ {
116
+ id: 'select',
117
+ header: ({ table }) => (
118
+ <Checkbox
119
+ checked={
120
+ table.getIsAllPageRowsSelected() ||
121
+ (table.getIsSomePageRowsSelected() && 'indeterminate')
122
+ }
123
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
124
+ aria-label='Select all'
125
+ />
126
+ ),
127
+ cell: ({ row }) => (
128
+ <Checkbox
129
+ checked={row.getIsSelected()}
130
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
131
+ aria-label='Select row'
132
+ />
133
+ ),
134
+ enableSorting: false,
135
+ size: 40
136
+ },
137
+ // Field columns from schema
138
+ ...Object.entries(schema.fields).map(([field, type]) => ({
139
+ accessorKey: field,
140
+ header: ({ column }) => <SortButton column={column}>{field}</SortButton>,
141
+ cell: ({ row }) => {
142
+ const value = row.getValue(field)
143
+ const formatted = formatValue(value, type)
144
+ return (
145
+ <span
146
+ className={cn(schema.key.includes(field) && 'font-medium', 'max-w-48 block truncate')}
147
+ title={formatted}
148
+ >
149
+ {truncate(formatted, 40)}
150
+ </span>
151
+ )
152
+ }
153
+ })),
154
+ // Actions column
155
+ {
156
+ id: 'actions',
157
+ cell: ({ row }) => <ActionCell row={row} onEdit={onEdit} onDelete={onDelete} />,
158
+ size: 40
159
+ }
160
+ ]
161
+
162
+ return cols
163
+ }, [schema, onEdit, onDelete])
164
+
165
+ const table = useReactTable({
166
+ data: data || [],
167
+ columns,
168
+ state: {
169
+ sorting,
170
+ rowSelection
171
+ },
172
+ onSortingChange: setSorting,
173
+ onRowSelectionChange: (updater) => {
174
+ const newSelection = typeof updater === 'function' ? updater(rowSelection) : updater
175
+ setRowSelection(newSelection)
176
+ if (onSelectionChange) {
177
+ const selectedRows = Object.keys(newSelection)
178
+ .filter((key) => newSelection[key])
179
+ .map((key) => data[parseInt(key)])
180
+ onSelectionChange(selectedRows)
181
+ }
182
+ },
183
+ getCoreRowModel: getCoreRowModel(),
184
+ getSortedRowModel: getSortedRowModel(),
185
+ getPaginationRowModel: getPaginationRowModel()
186
+ })
187
+
188
+ if (loading) {
189
+ return (
190
+ <div className='flex h-40 items-center justify-center text-xs text-muted-foreground'>
191
+ Loading...
192
+ </div>
193
+ )
194
+ }
195
+
196
+ if (!schema) {
197
+ return (
198
+ <div className='flex h-40 items-center justify-center text-xs text-muted-foreground'>
199
+ Select a collection to view data
200
+ </div>
201
+ )
202
+ }
203
+
204
+ if (data.length === 0) {
205
+ return (
206
+ <div className='flex h-40 items-center justify-center text-xs text-muted-foreground'>
207
+ No documents found
208
+ </div>
209
+ )
210
+ }
211
+
212
+ return (
213
+ <div className='flex flex-col h-full'>
214
+ <div className='flex-1 overflow-auto'>
215
+ <Table>
216
+ <TableHeader>
217
+ {table.getHeaderGroups().map((headerGroup) => (
218
+ <TableRow key={headerGroup.id}>
219
+ {headerGroup.headers.map((header) => (
220
+ <TableHead key={header.id} style={{ width: header.column.getSize() }}>
221
+ {header.isPlaceholder
222
+ ? null
223
+ : flexRender(header.column.columnDef.header, header.getContext())}
224
+ </TableHead>
225
+ ))}
226
+ </TableRow>
227
+ ))}
228
+ </TableHeader>
229
+ <TableBody>
230
+ {table.getRowModel().rows.map((row) => (
231
+ <TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
232
+ {row.getVisibleCells().map((cell) => (
233
+ <TableCell key={cell.id}>
234
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
235
+ </TableCell>
236
+ ))}
237
+ </TableRow>
238
+ ))}
239
+ </TableBody>
240
+ </Table>
241
+ </div>
242
+ <Pagination table={table} />
243
+ </div>
244
+ )
245
+ }
@@ -0,0 +1,211 @@
1
+ import { useState, useEffect, useMemo } from 'react'
2
+ import {
3
+ Dialog,
4
+ DialogContent,
5
+ DialogHeader,
6
+ DialogTitle,
7
+ DialogDescription,
8
+ DialogFooter
9
+ } from './ui/dialog'
10
+ import { Button } from './ui/button'
11
+ import { Input } from './ui/input'
12
+ import { Checkbox } from './ui/checkbox'
13
+ import { ScrollArea } from './ui/scroll-area'
14
+ import { getInputType, parseValue, getDefaultValue } from '../lib/field-types'
15
+ import { cn } from '../lib/utils'
16
+
17
+ function FieldInput({ name, type, value, onChange, disabled }) {
18
+ const inputType = getInputType(type)
19
+ const typeName = typeof type === 'string' ? type : type?.type
20
+
21
+ if (inputType === 'checkbox') {
22
+ return (
23
+ <div className='flex items-center gap-2'>
24
+ <Checkbox
25
+ id={name}
26
+ checked={Boolean(value)}
27
+ onCheckedChange={(checked) => onChange(checked)}
28
+ disabled={disabled}
29
+ />
30
+ <label htmlFor={name} className='text-xs'>
31
+ {value ? 'true' : 'false'}
32
+ </label>
33
+ </div>
34
+ )
35
+ }
36
+
37
+ if (inputType === 'textarea') {
38
+ const displayValue =
39
+ typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value || '')
40
+ return (
41
+ <textarea
42
+ id={name}
43
+ value={displayValue}
44
+ onChange={(e) => onChange(parseValue(e.target.value, type))}
45
+ disabled={disabled}
46
+ className={cn(
47
+ 'flex min-h-20 w-full rounded-md border border-input bg-input/20 px-3 py-2 text-xs',
48
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
49
+ 'disabled:cursor-not-allowed disabled:opacity-50',
50
+ 'font-mono'
51
+ )}
52
+ rows={4}
53
+ />
54
+ )
55
+ }
56
+
57
+ return (
58
+ <Input
59
+ id={name}
60
+ type={inputType}
61
+ value={value ?? ''}
62
+ onChange={(e) => {
63
+ const val = inputType === 'number' ? parseValue(e.target.value, type) : e.target.value
64
+ onChange(val)
65
+ }}
66
+ disabled={disabled}
67
+ className='text-xs'
68
+ />
69
+ )
70
+ }
71
+
72
+ function FormField({ name, type, value, onChange, disabled, isKey }) {
73
+ const typeName = typeof type === 'string' ? type : type?.type
74
+
75
+ return (
76
+ <div className='space-y-1.5'>
77
+ <label htmlFor={name} className='flex items-center gap-2 text-xs font-medium'>
78
+ {name}
79
+ {isKey && (
80
+ <span className='text-[10px] text-muted-foreground px-1 py-0.5 bg-muted rounded'>
81
+ key
82
+ </span>
83
+ )}
84
+ <span className='text-[10px] text-muted-foreground ml-auto'>{typeName || 'string'}</span>
85
+ </label>
86
+ <FieldInput name={name} type={type} value={value} onChange={onChange} disabled={disabled} />
87
+ </div>
88
+ )
89
+ }
90
+
91
+ export function DocumentForm({ open, onOpenChange, schema, doc, onSave, onDelete }) {
92
+ const isNew = !doc || Object.keys(doc).length === 0
93
+ const [values, setValues] = useState({})
94
+ const [saving, setSaving] = useState(false)
95
+ const [deleting, setDeleting] = useState(false)
96
+ const [confirmDelete, setConfirmDelete] = useState(false)
97
+
98
+ // Initialize values when doc changes
99
+ useEffect(() => {
100
+ if (!schema?.fields) {
101
+ setValues({})
102
+ return
103
+ }
104
+
105
+ if (isNew) {
106
+ // Set default values for new doc
107
+ const defaults = Object.entries(schema.fields).reduce((acc, [field, type]) => {
108
+ acc[field] = getDefaultValue(type)
109
+ return acc
110
+ }, {})
111
+ setValues(defaults)
112
+ } else {
113
+ setValues({ ...doc })
114
+ }
115
+ }, [doc, schema, isNew])
116
+
117
+ const onChange = (field, value) => {
118
+ setValues((prev) => ({ ...prev, [field]: value }))
119
+ }
120
+
121
+ const handleSave = async () => {
122
+ setSaving(true)
123
+ try {
124
+ await onSave(values, isNew)
125
+ onOpenChange(false)
126
+ } catch (err) {
127
+ console.error('Save failed:', err)
128
+ } finally {
129
+ setSaving(false)
130
+ }
131
+ }
132
+
133
+ const handleDelete = async () => {
134
+ if (!confirmDelete) {
135
+ setConfirmDelete(true)
136
+ return
137
+ }
138
+
139
+ setDeleting(true)
140
+ try {
141
+ await onDelete(doc)
142
+ onOpenChange(false)
143
+ } catch (err) {
144
+ console.error('Delete failed:', err)
145
+ } finally {
146
+ setDeleting(false)
147
+ setConfirmDelete(false)
148
+ }
149
+ }
150
+
151
+ const onClose = (open) => {
152
+ if (!open) {
153
+ setConfirmDelete(false)
154
+ }
155
+ onOpenChange(open)
156
+ }
157
+
158
+ if (!schema) return null
159
+
160
+ return (
161
+ <Dialog open={open} onOpenChange={onClose}>
162
+ <DialogContent className='sm:max-w-md'>
163
+ <DialogHeader>
164
+ <DialogTitle>{isNew ? 'New Document' : 'Edit Document'}</DialogTitle>
165
+ <DialogDescription>
166
+ {isNew
167
+ ? `Create a new document in ${schema.name}`
168
+ : `Editing document in ${schema.name}`}
169
+ </DialogDescription>
170
+ </DialogHeader>
171
+ <ScrollArea className='max-h-96'>
172
+ <div className='space-y-4 px-1 py-2'>
173
+ {Object.entries(schema.fields).map(([field, type]) => (
174
+ <FormField
175
+ key={field}
176
+ name={field}
177
+ type={type}
178
+ value={values[field]}
179
+ onChange={(v) => onChange(field, v)}
180
+ disabled={!isNew && schema.key.includes(field)}
181
+ isKey={schema.key.includes(field)}
182
+ />
183
+ ))}
184
+ </div>
185
+ </ScrollArea>
186
+ <DialogFooter className='flex-row gap-2'>
187
+ {!isNew && (
188
+ <Button
189
+ variant='destructive'
190
+ onClick={handleDelete}
191
+ disabled={deleting || saving}
192
+ className='mr-auto'
193
+ >
194
+ {confirmDelete ? 'Confirm Delete' : 'Delete'}
195
+ </Button>
196
+ )}
197
+ <Button
198
+ variant='outline'
199
+ onClick={() => onOpenChange(false)}
200
+ disabled={saving || deleting}
201
+ >
202
+ Cancel
203
+ </Button>
204
+ <Button onClick={handleSave} disabled={saving || deleting}>
205
+ {saving ? 'Saving...' : isNew ? 'Create' : 'Save'}
206
+ </Button>
207
+ </DialogFooter>
208
+ </DialogContent>
209
+ </Dialog>
210
+ )
211
+ }