@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.
@@ -0,0 +1,1131 @@
1
+ import { useState, useMemo, useEffect, useRef, Fragment } from 'react'
2
+ import {
3
+ flexRender,
4
+ getCoreRowModel,
5
+ getFilteredRowModel,
6
+ getSortedRowModel,
7
+ useReactTable
8
+ } from '@tanstack/react-table'
9
+ import { useCollection } from '../hooks/use-collection'
10
+ import { useSchema, useCollectionSchema } from '../hooks/use-schema'
11
+ import { useIdentityData, useRoomData } from '../hooks/use-system'
12
+ import * as b4a from 'b4a'
13
+ import {
14
+ Database,
15
+ X,
16
+ ChevronRight,
17
+ ArrowUpDown,
18
+ User,
19
+ Users,
20
+ Key,
21
+ Home,
22
+ Smartphone,
23
+ Mail,
24
+ Settings,
25
+ Search,
26
+ Download,
27
+ ChevronDown,
28
+ ChevronUp,
29
+ ImageIcon,
30
+ Braces,
31
+ List,
32
+ Binary,
33
+ Copy,
34
+ Check
35
+ } from 'lucide-react'
36
+ import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './ui/table'
37
+ import {
38
+ DropdownMenu,
39
+ DropdownMenuTrigger,
40
+ DropdownMenuContent,
41
+ DropdownMenuCheckboxItem
42
+ } from './ui/dropdown-menu'
43
+ import { Tabs, TabsList, TabsTrigger } from './ui/tabs'
44
+ import { Checkbox } from './ui/checkbox'
45
+ import { Button, buttonVariants } from './ui/button'
46
+ import { Input } from './ui/input'
47
+ import { Badge } from './ui/badge'
48
+ import { ScrollArea } from './ui/scroll-area'
49
+ import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from './ui/resizable'
50
+ import { cn } from '../lib/utils'
51
+
52
+ function truncate(str, len = 120) {
53
+ if (!str || str.length <= len) return str
54
+ return str.slice(0, len) + '...'
55
+ }
56
+
57
+ function formatValue(val) {
58
+ if (val === null || val === undefined) return '-'
59
+ if (val instanceof Uint8Array || b4a.isBuffer(val)) {
60
+ return val.toString('hex')
61
+ }
62
+ if (typeof val === 'object') return JSON.stringify(val)
63
+ return String(val)
64
+ }
65
+
66
+ // Check if bytes represent an image by magic bytes
67
+ function detectImageType(bytes) {
68
+ if (!bytes || bytes.length < 4) return null
69
+ // PNG: 89 50 4E 47
70
+ if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47)
71
+ return 'image/png'
72
+ // JPEG: FF D8 FF
73
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
74
+ // GIF: 47 49 46 38
75
+ if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38)
76
+ return 'image/gif'
77
+ // WebP: 52 49 46 46 ... 57 45 42 50
78
+ if (
79
+ bytes[0] === 0x52 &&
80
+ bytes[1] === 0x49 &&
81
+ bytes[2] === 0x46 &&
82
+ bytes[3] === 0x46 &&
83
+ bytes.length > 11 &&
84
+ bytes[8] === 0x57 &&
85
+ bytes[9] === 0x45 &&
86
+ bytes[10] === 0x42 &&
87
+ bytes[11] === 0x50
88
+ )
89
+ return 'image/webp'
90
+ return null
91
+ }
92
+
93
+ // Binary data display component
94
+ function BinaryCell({ value, field }) {
95
+ const [copied, setCopied] = useState(false)
96
+
97
+ if (!value) return <span className='text-muted-foreground'>null</span>
98
+
99
+ const bytes = value instanceof Uint8Array ? value : b4a.isBuffer(value) ? value : null
100
+
101
+ if (!bytes) return <span className='text-muted-foreground'>-</span>
102
+
103
+ // Check if it's an image
104
+ const imageType = detectImageType(bytes)
105
+ if (imageType) {
106
+ const base64 = btoa(String.fromCharCode(...bytes))
107
+ const src = `data:${imageType};base64,${base64}`
108
+ return (
109
+ <span className='relative group'>
110
+ <img
111
+ src={src}
112
+ alt={field}
113
+ className='w-6 h-6 rounded object-cover cursor-pointer'
114
+ onError={(e) => {
115
+ e.target.style.display = 'none'
116
+ }}
117
+ />
118
+ {/* Hover preview */}
119
+ <span className='absolute left-0 bottom-full mb-2 hidden group-hover:block z-50 p-1 bg-popover border border-border rounded-lg shadow-xl'>
120
+ <img src={src} alt={field} className='max-w-48 max-h-48 rounded object-contain' />
121
+ </span>
122
+ </span>
123
+ )
124
+ }
125
+
126
+ const hex = bytes.toString('hex')
127
+ const preview = hex.slice(0, 8)
128
+ const size = bytes.length
129
+
130
+ const copyHex = (e) => {
131
+ e.stopPropagation()
132
+ navigator.clipboard?.writeText(hex)
133
+ setCopied(true)
134
+ setTimeout(() => setCopied(false), 1500)
135
+ }
136
+
137
+ return (
138
+ <span
139
+ className='inline-flex items-center gap-1.5 cursor-pointer group'
140
+ onClick={copyHex}
141
+ title={`${size} bytes - Click to copy hex`}
142
+ >
143
+ <Binary className='w-3 h-3 text-cyan-500' />
144
+ <span className='font-mono text-muted-foreground'>{preview}…</span>
145
+ <span className='text-[10px] text-muted-foreground/70'>{size}B</span>
146
+ {copied ? (
147
+ <Check className='w-3 h-3 text-green-500' />
148
+ ) : (
149
+ <Copy className='w-3 h-3 text-muted-foreground/50 opacity-0 group-hover:opacity-100 transition-opacity' />
150
+ )}
151
+ </span>
152
+ )
153
+ }
154
+
155
+ function formatTimestamp(ts) {
156
+ if (!ts) return '-'
157
+ return new Date(ts).toLocaleString()
158
+ }
159
+
160
+ // ============================================================================
161
+ // Data Tab Components
162
+ // ============================================================================
163
+
164
+ function CollectionView({ db, collection, room, rooms, onRoomChange }) {
165
+ const schema = useCollectionSchema(db, collection.name)
166
+ const needsRoom = collection.scope === 'shared'
167
+ const [openedRoom, setOpenedRoom] = useState(null)
168
+ const { data, loading, put, del } = useCollection(
169
+ db,
170
+ collection.name,
171
+ needsRoom ? openedRoom : null
172
+ )
173
+ const [checked, setChecked] = useState(new Set())
174
+ const [editing, setEditing] = useState(null)
175
+ const [editValue, setEditValue] = useState('')
176
+ const [showAdd, setShowAdd] = useState(false)
177
+ const [newDoc, setNewDoc] = useState({})
178
+ const [sortField, setSortField] = useState(null)
179
+ const [sortDir, setSortDir] = useState('asc')
180
+
181
+ useEffect(() => {
182
+ if (!needsRoom || !room) {
183
+ setOpenedRoom(null)
184
+ return
185
+ }
186
+ let cancelled = false
187
+ db.rooms
188
+ .open(room.id)
189
+ .then((sub) => {
190
+ if (!cancelled) setOpenedRoom(sub)
191
+ })
192
+ .catch(() => {})
193
+ return () => {
194
+ cancelled = true
195
+ }
196
+ }, [db, room, needsRoom])
197
+
198
+ const fields = Object.keys(schema?.fields || {})
199
+ const isOpening = needsRoom && room && !openedRoom
200
+
201
+ const toggleSort = (field) => {
202
+ if (sortField === field) {
203
+ setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
204
+ } else {
205
+ setSortField(field)
206
+ setSortDir('asc')
207
+ }
208
+ }
209
+
210
+ const sortedData = [...data].sort((a, b) => {
211
+ if (!sortField) return 0
212
+ const aVal = a[sortField]
213
+ const bVal = b[sortField]
214
+ if (aVal === bVal) return 0
215
+ if (aVal === null || aVal === undefined) return 1
216
+ if (bVal === null || bVal === undefined) return -1
217
+ const cmp = aVal < bVal ? -1 : 1
218
+ return sortDir === 'asc' ? cmp : -cmp
219
+ })
220
+
221
+ const toggleCheck = (doc, e) => {
222
+ e?.stopPropagation?.()
223
+ const key = JSON.stringify(doc)
224
+ const next = new Set(checked)
225
+ if (next.has(key)) next.delete(key)
226
+ else next.add(key)
227
+ setChecked(next)
228
+ }
229
+
230
+ const toggleAll = () => {
231
+ if (checked.size === data.length) {
232
+ setChecked(new Set())
233
+ } else {
234
+ setChecked(new Set(data.map((d) => JSON.stringify(d))))
235
+ }
236
+ }
237
+
238
+ const startEdit = (doc, field, e) => {
239
+ e.stopPropagation()
240
+ const isKey = schema?.key?.includes(field)
241
+ if (isKey) return
242
+ setEditing({ docKey: JSON.stringify(doc), field })
243
+ setEditValue(formatValue(doc[field]))
244
+ }
245
+
246
+ const commitEdit = (doc) => {
247
+ const field = editing.field
248
+ const query = schema.key.reduce((q, k) => ({ ...q, [k]: doc[k] }), {})
249
+ const newVal =
250
+ schema.fields[field]?.type === 'int' || schema.fields[field]?.type === 'uint'
251
+ ? parseInt(editValue, 10)
252
+ : schema.fields[field]?.type === 'float'
253
+ ? parseFloat(editValue)
254
+ : schema.fields[field]?.type === 'bool'
255
+ ? editValue === 'true'
256
+ : editValue
257
+ const updated = { ...doc, [field]: newVal }
258
+ if (fields.includes('updatedAt')) {
259
+ updated.updatedAt = Date.now()
260
+ }
261
+ update(query, updated)
262
+ setEditing(null)
263
+ }
264
+
265
+ const cancelEdit = () => {
266
+ setEditing(null)
267
+ }
268
+
269
+ return (
270
+ <div className='flex flex-1 overflow-hidden'>
271
+ <div className='flex-1 flex flex-col overflow-hidden'>
272
+ <div className='px-3 py-2 border-b border-border flex items-center gap-2 flex-wrap bg-muted/30'>
273
+ <span className='font-semibold text-sm'>{collection.name}</span>
274
+ <Badge variant='secondary'>{collection.scope}</Badge>
275
+ {needsRoom && (
276
+ <select
277
+ value={room?.id || ''}
278
+ onChange={(e) => onRoomChange(rooms.find((r) => r.id === e.target.value) || null)}
279
+ className='h-7 px-2 bg-input/20 border border-input rounded-md text-xs outline-none'
280
+ >
281
+ <option value=''>Select room...</option>
282
+ {rooms.map((r) => (
283
+ <option key={r.id} value={r.id}>
284
+ {r.name || r.id?.slice(0, 8)}
285
+ </option>
286
+ ))}
287
+ </select>
288
+ )}
289
+ <span className='text-xs text-muted-foreground'>
290
+ {isOpening ? 'Opening...' : loading ? 'Loading...' : `${data.length} docs`}
291
+ </span>
292
+ {checked.size > 0 && (
293
+ <>
294
+ <span className='text-xs text-primary'>{checked.size} selected</span>
295
+ <Button
296
+ variant='destructive'
297
+ size='xs'
298
+ onClick={() => {
299
+ if (confirm(`Delete ${checked.size} documents?`)) {
300
+ for (const key of checked) {
301
+ const doc = JSON.parse(key)
302
+ const query = schema.key.reduce((q, k) => ({ ...q, [k]: doc[k] }), {})
303
+ del(query)
304
+ }
305
+ setChecked(new Set())
306
+ }
307
+ }}
308
+ >
309
+ Delete
310
+ </Button>
311
+ </>
312
+ )}
313
+ <div className='ml-auto'>
314
+ <Button
315
+ size='sm'
316
+ onClick={() => {
317
+ setNewDoc({})
318
+ setShowAdd(true)
319
+ }}
320
+ >
321
+ + Add
322
+ </Button>
323
+ </div>
324
+ </div>
325
+
326
+ <ScrollArea className='flex-1'>
327
+ {needsRoom && !room ? (
328
+ <div className='p-10 text-center text-amber-500 text-xs'>
329
+ Select a room to view data
330
+ </div>
331
+ ) : loading || isOpening ? (
332
+ <div className='p-10 text-center text-muted-foreground'>Loading...</div>
333
+ ) : data.length === 0 ? (
334
+ <div className='p-10 text-center text-muted-foreground'>No documents</div>
335
+ ) : (
336
+ <Table>
337
+ <TableHeader>
338
+ <TableRow>
339
+ <TableHead className='w-10'>
340
+ <Checkbox
341
+ checked={checked.size === sortedData.length && sortedData.length > 0}
342
+ onCheckedChange={toggleAll}
343
+ />
344
+ </TableHead>
345
+ {fields.map((f) => (
346
+ <TableHead key={f}>
347
+ <button
348
+ type='button'
349
+ onClick={() => toggleSort(f)}
350
+ className='flex items-center gap-1 cursor-pointer select-none hover:text-foreground transition-colors'
351
+ >
352
+ {f}
353
+ {sortField === f ? (
354
+ sortDir === 'asc' ? (
355
+ <ArrowUp className='w-3 h-3 text-primary' />
356
+ ) : (
357
+ <ArrowDown className='w-3 h-3 text-primary' />
358
+ )
359
+ ) : (
360
+ <ArrowUpDown className='w-3 h-3 text-muted-foreground/50' />
361
+ )}
362
+ </button>
363
+ </TableHead>
364
+ ))}
365
+ </TableRow>
366
+ </TableHeader>
367
+ <TableBody>
368
+ {sortedData.map((doc, i) => {
369
+ const key = JSON.stringify(doc)
370
+ const isChecked = checked.has(key)
371
+ return (
372
+ <TableRow key={i} className={cn(isChecked && 'bg-muted/50')}>
373
+ <TableCell className='w-10'>
374
+ <Checkbox
375
+ checked={isChecked}
376
+ onCheckedChange={() => toggleCheck(doc)}
377
+ onClick={(e) => e.stopPropagation()}
378
+ />
379
+ </TableCell>
380
+ {fields.map((f) => {
381
+ const isKey = schema?.key?.includes(f)
382
+ const isEditingThis = editing?.docKey === key && editing?.field === f
383
+ return (
384
+ <TableCell
385
+ key={f}
386
+ className='p-0'
387
+ onDoubleClick={(e) => startEdit(doc, f, e)}
388
+ >
389
+ <div className='relative'>
390
+ <div
391
+ className={cn(
392
+ 'px-3 py-2',
393
+ !isKey && 'cursor-text hover:bg-muted/50',
394
+ isEditingThis && 'invisible'
395
+ )}
396
+ >
397
+ {formatValue(doc[f])}
398
+ </div>
399
+ {isEditingThis && (
400
+ <Input
401
+ autoFocus
402
+ value={editValue}
403
+ onChange={(e) => setEditValue(e.target.value)}
404
+ onKeyDown={(e) => {
405
+ if (e.key === 'Enter') {
406
+ commitEdit(doc)
407
+ } else if (e.key === 'Escape') {
408
+ cancelEdit()
409
+ }
410
+ }}
411
+ onBlur={() => commitEdit(doc)}
412
+ onClick={(e) => e.stopPropagation()}
413
+ className='absolute inset-0 h-full w-full rounded-none border-0 border-b-2 border-primary bg-primary/10 font-mono text-xs px-3'
414
+ />
415
+ )}
416
+ </div>
417
+ </TableCell>
418
+ )
419
+ })}
420
+ </TableRow>
421
+ )
422
+ })}
423
+ </TableBody>
424
+ </Table>
425
+ )}
426
+ </ScrollArea>
427
+ </div>
428
+
429
+ {showAdd && (
430
+ <div className='w-80 border-l border-border flex flex-col bg-muted/30'>
431
+ <div className='p-3 border-b border-border flex items-center justify-between'>
432
+ <span className='font-semibold text-xs'>New Document</span>
433
+ <Button variant='ghost' size='icon-xs' onClick={() => setShowAdd(false)}>
434
+ <X className='w-3.5 h-3.5' />
435
+ </Button>
436
+ </div>
437
+ <ScrollArea className='flex-1 p-3'>
438
+ {fields.map((f) => (
439
+ <div key={f} className='mb-3'>
440
+ <div className='text-[10px] text-muted-foreground uppercase mb-1'>
441
+ {f}
442
+ {schema?.fields[f]?.required && <span className='text-destructive'> *</span>}
443
+ </div>
444
+ <Input
445
+ value={newDoc[f] || ''}
446
+ onChange={(e) => setNewDoc({ ...newDoc, [f]: e.target.value })}
447
+ placeholder={schema?.fields[f]?.type || 'value'}
448
+ className='font-mono'
449
+ />
450
+ </div>
451
+ ))}
452
+
453
+ <Button
454
+ className='w-full mt-2'
455
+ onClick={async () => {
456
+ const doc = {}
457
+ for (const f of fields) {
458
+ const type = schema?.fields[f]?.type
459
+ const val = newDoc[f]
460
+ if (type === 'int' || type === 'uint') {
461
+ doc[f] = parseInt(val, 10) || 0
462
+ } else if (type === 'float') {
463
+ doc[f] = parseFloat(val) || 0
464
+ } else if (type === 'bool') {
465
+ doc[f] = val === 'true'
466
+ } else {
467
+ doc[f] = val || ''
468
+ }
469
+ }
470
+ const now = Date.now()
471
+ if (fields.includes('createdAt')) {
472
+ doc.createdAt = now
473
+ }
474
+ if (fields.includes('updatedAt')) {
475
+ doc.updatedAt = now
476
+ }
477
+ await put(doc)
478
+ setShowAdd(false)
479
+ setNewDoc({})
480
+ }}
481
+ >
482
+ Create Document
483
+ </Button>
484
+ </ScrollArea>
485
+ </div>
486
+ )}
487
+ </div>
488
+ )
489
+ }
490
+
491
+ function DataSidebar({ collections, selected, onSelect }) {
492
+ const grouped = {
493
+ local: collections.filter((c) => c.scope === 'local'),
494
+ private: collections.filter((c) => c.scope === 'private'),
495
+ shared: collections.filter((c) => c.scope === 'shared')
496
+ }
497
+
498
+ return (
499
+ <ScrollArea className='h-full bg-muted/30'>
500
+ {Object.entries(grouped).map(
501
+ ([scope, cols]) =>
502
+ cols.length > 0 && (
503
+ <div key={scope} className='p-3'>
504
+ <div className='text-[10px] text-muted-foreground uppercase mb-2 font-medium'>
505
+ {scope} ({cols.length})
506
+ </div>
507
+ {cols.map((c) => {
508
+ const isActive = selected?.name === c.name
509
+ return (
510
+ <Button
511
+ key={c.name}
512
+ variant={isActive ? 'default' : 'ghost'}
513
+ size='sm'
514
+ className={cn(
515
+ 'w-full justify-start mb-0.5',
516
+ !isActive && 'text-muted-foreground'
517
+ )}
518
+ onClick={() => onSelect(c)}
519
+ >
520
+ {isActive && <ChevronRight className='w-3 h-3 mr-1' />}
521
+ <span className={!isActive ? 'ml-4' : ''}>{c.name}</span>
522
+ </Button>
523
+ )
524
+ })}
525
+ </div>
526
+ )
527
+ )}
528
+ </ScrollArea>
529
+ )
530
+ }
531
+
532
+ // ============================================================================
533
+ // System Tab Components
534
+ // ============================================================================
535
+
536
+ const IDENTITY_ITEMS = [
537
+ { id: 'profile', label: 'Profile', icon: User },
538
+ { id: 'devices', label: 'Devices', icon: Smartphone },
539
+ { id: 'invites', label: 'Invites', icon: Mail },
540
+ { id: 'rooms', label: 'Rooms', icon: Home },
541
+ { id: 'settings', label: 'Settings', icon: Settings }
542
+ ]
543
+
544
+ const ROOM_ITEMS = [
545
+ { id: 'profile', label: 'Profile', icon: Settings },
546
+ { id: 'members', label: 'Members', icon: Users },
547
+ { id: 'devices', label: 'Devices', icon: Smartphone },
548
+ { id: 'invites', label: 'Invites', icon: Key }
549
+ ]
550
+
551
+ function SystemSidebar({ selected, onSelect, rooms, room, onRoomChange }) {
552
+ return (
553
+ <ScrollArea className='h-full bg-muted/30'>
554
+ <div className='p-3'>
555
+ <div className='text-[10px] text-muted-foreground uppercase mb-2 font-medium'>Identity</div>
556
+ {IDENTITY_ITEMS.map((item) => {
557
+ const isActive = selected?.scope === 'identity' && selected?.type === item.id
558
+ const Icon = item.icon
559
+ return (
560
+ <Button
561
+ key={item.id}
562
+ variant={isActive ? 'default' : 'ghost'}
563
+ size='sm'
564
+ className={cn('w-full justify-start mb-0.5', !isActive && 'text-muted-foreground')}
565
+ onClick={() => onSelect({ scope: 'identity', type: item.id })}
566
+ >
567
+ <Icon className='w-3.5 h-3.5 mr-2' />
568
+ {item.label}
569
+ </Button>
570
+ )
571
+ })}
572
+ </div>
573
+
574
+ <div className='p-3 pt-0'>
575
+ <div className='text-[10px] text-muted-foreground uppercase mb-2 font-medium'>Room</div>
576
+ <select
577
+ value={room?.id || ''}
578
+ onChange={(e) => onRoomChange(rooms.find((r) => r.id === e.target.value) || null)}
579
+ className='w-full h-7 px-2 mb-2 bg-input/20 border border-input rounded-md text-xs outline-none'
580
+ >
581
+ <option value=''>Select room...</option>
582
+ {rooms.map((r) => (
583
+ <option key={r.id} value={r.id}>
584
+ {r.name || r.id?.slice(0, 8)}
585
+ </option>
586
+ ))}
587
+ </select>
588
+ {ROOM_ITEMS.map((item) => {
589
+ const isActive = selected?.scope === 'room' && selected?.type === item.id
590
+ const Icon = item.icon
591
+ return (
592
+ <Button
593
+ key={item.id}
594
+ variant={isActive ? 'default' : 'ghost'}
595
+ size='sm'
596
+ className={cn(
597
+ 'w-full justify-start mb-0.5',
598
+ !isActive && 'text-muted-foreground',
599
+ !room && 'opacity-50 pointer-events-none'
600
+ )}
601
+ onClick={() => onSelect({ scope: 'room', type: item.id })}
602
+ >
603
+ <Icon className='w-3.5 h-3.5 mr-2' />
604
+ {item.label}
605
+ </Button>
606
+ )
607
+ })}
608
+ </div>
609
+ </ScrollArea>
610
+ )
611
+ }
612
+
613
+ function formatCell(field, val) {
614
+ if (val === null || val === undefined) return <span className='text-muted-foreground'>null</span>
615
+ if (val instanceof Uint8Array || b4a.isBuffer(val))
616
+ return <BinaryCell value={val} field={field} />
617
+ if (field.endsWith('At') || field === 'expires') return formatTimestamp(val)
618
+ if (field === 'id' || field.endsWith('Id')) return String(val)
619
+ if (field === 'avatar' || field === 'image' || field === 'picture') {
620
+ const src =
621
+ typeof val === 'string'
622
+ ? val
623
+ : val?.url ||
624
+ val?.src ||
625
+ (val?.data ? `data:${val.type || 'image/png'};base64,${val.data}` : null)
626
+ if (src) {
627
+ return (
628
+ <span className='relative group'>
629
+ <img
630
+ src={src}
631
+ alt={field}
632
+ className='w-6 h-6 rounded-full object-cover'
633
+ onError={(e) => {
634
+ e.target.style.display = 'none'
635
+ }}
636
+ />
637
+ <span className='absolute left-0 bottom-full mb-2 hidden group-hover:block z-50 p-1 bg-popover border border-border rounded-lg shadow-xl'>
638
+ <img src={src} alt={field} className='max-w-48 max-h-48 rounded object-contain' />
639
+ </span>
640
+ </span>
641
+ )
642
+ }
643
+ return <ImageIcon className='w-4 h-4 text-muted-foreground' />
644
+ }
645
+ if (Array.isArray(val)) {
646
+ const isBinary = val.length > 0 && (val[0] instanceof Uint8Array || b4a.isBuffer(val[0]))
647
+ return (
648
+ <span className='inline-flex items-center gap-1 text-muted-foreground'>
649
+ {isBinary ? <Binary className='w-3 h-3 text-cyan-500' /> : <List className='w-3 h-3' />}
650
+ <span className='text-foreground'>[{val.length}]</span>
651
+ </span>
652
+ )
653
+ }
654
+ if (typeof val === 'object') {
655
+ return (
656
+ <span className='inline-flex items-center gap-1 text-muted-foreground'>
657
+ <Braces className='w-3 h-3' />
658
+ <span className='text-foreground'>{`{${Object.keys(val).length}}`}</span>
659
+ </span>
660
+ )
661
+ }
662
+ if (typeof val === 'boolean') {
663
+ return (
664
+ <span className={cn('font-medium', val ? 'text-green-500' : 'text-muted-foreground')}>
665
+ {String(val)}
666
+ </span>
667
+ )
668
+ }
669
+ return String(val)
670
+ }
671
+
672
+ function buildColumns(fields) {
673
+ return [
674
+ {
675
+ id: '_expand',
676
+ header: () => null,
677
+ cell: ({ row }) => (
678
+ <button type='button' onClick={() => row.toggleExpanded()} className='p-0'>
679
+ {row.getIsExpanded() ? (
680
+ <ChevronUp className='w-3.5 h-3.5 text-muted-foreground' />
681
+ ) : (
682
+ <ChevronDown className='w-3.5 h-3.5 text-muted-foreground' />
683
+ )}
684
+ </button>
685
+ ),
686
+ enableSorting: false,
687
+ enableHiding: false,
688
+ size: 32
689
+ },
690
+ ...fields.map((field) => ({
691
+ accessorKey: field,
692
+ header: ({ column }) => (
693
+ <Button
694
+ variant='ghost'
695
+ size='sm'
696
+ className='-ml-3 h-8'
697
+ onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
698
+ >
699
+ {field}
700
+ <ArrowUpDown className='ml-1 w-3 h-3' />
701
+ </Button>
702
+ ),
703
+ cell: ({ row }) => {
704
+ const val = row.getValue(field)
705
+ return <div className='font-mono text-xs'>{formatCell(field, val)}</div>
706
+ },
707
+ filterFn: (row, columnId, filterValue) => {
708
+ const val = row.getValue(columnId)
709
+ if (val === null || val === undefined) return false
710
+ return String(val).toLowerCase().includes(filterValue.toLowerCase())
711
+ }
712
+ }))
713
+ ]
714
+ }
715
+
716
+ function SystemTable({ data, loading, type, searchRef }) {
717
+ const [sorting, setSorting] = useState([])
718
+ const [columnVisibility, setColumnVisibility] = useState({})
719
+ const [globalFilter, setGlobalFilter] = useState('')
720
+ const [expanded, setExpanded] = useState({})
721
+ const inputRef = useRef(null)
722
+
723
+ useEffect(() => {
724
+ if (searchRef) searchRef.current = inputRef.current
725
+ }, [searchRef])
726
+
727
+ const items = Array.isArray(data) ? data : data ? [data] : []
728
+ const fieldsKey =
729
+ items.length > 0
730
+ ? Object.keys(items[0] || {})
731
+ .filter((f) => !f.startsWith('_'))
732
+ .join(',')
733
+ : ''
734
+ const fields = useMemo(() => (fieldsKey ? fieldsKey.split(',') : []), [fieldsKey])
735
+ const columns = useMemo(() => buildColumns(fields), [fieldsKey])
736
+
737
+ const table = useReactTable({
738
+ data: items,
739
+ columns,
740
+ state: { sorting, columnVisibility, globalFilter, expanded },
741
+ onSortingChange: setSorting,
742
+ onColumnVisibilityChange: setColumnVisibility,
743
+ onGlobalFilterChange: setGlobalFilter,
744
+ onExpandedChange: setExpanded,
745
+ getCoreRowModel: getCoreRowModel(),
746
+ getSortedRowModel: getSortedRowModel(),
747
+ getFilteredRowModel: getFilteredRowModel(),
748
+ globalFilterFn: (row, columnId, filterValue) => {
749
+ const q = filterValue.toLowerCase()
750
+ return fields.some((f) => {
751
+ const val = row.getValue(f)
752
+ if (val === null || val === undefined) return false
753
+ return String(val).toLowerCase().includes(q)
754
+ })
755
+ }
756
+ })
757
+
758
+ const exportJson = () => {
759
+ const rows = table.getFilteredRowModel().rows.map((r) => r.original)
760
+ const blob = new Blob([JSON.stringify(rows, null, 2)], { type: 'application/json' })
761
+ const url = URL.createObjectURL(blob)
762
+ const a = document.createElement('a')
763
+ a.href = url
764
+ a.download = `${type || 'data'}.json`
765
+ a.click()
766
+ URL.revokeObjectURL(url)
767
+ }
768
+
769
+ return (
770
+ <div className='flex flex-col h-full'>
771
+ {/* Toolbar */}
772
+ <div className='px-3 py-2 border-b border-border flex items-center gap-2 bg-muted/20'>
773
+ <div className='relative flex-1 max-w-64'>
774
+ <Search className='absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground' />
775
+ <Input
776
+ ref={inputRef}
777
+ value={globalFilter}
778
+ onChange={(e) => setGlobalFilter(e.target.value)}
779
+ placeholder='Search... (⌘K)'
780
+ className='pl-8 h-7 text-xs'
781
+ />
782
+ </div>
783
+ <span className='text-xs text-muted-foreground'>
784
+ {loading
785
+ ? 'Loading...'
786
+ : `${table.getFilteredRowModel().rows.length}${globalFilter ? ` / ${items.length}` : ''} rows`}
787
+ </span>
788
+ {fields.length > 0 && (
789
+ <div className='flex items-center gap-1 ml-auto'>
790
+ <Button
791
+ variant='ghost'
792
+ size='sm'
793
+ onClick={exportJson}
794
+ className='h-7 gap-1.5'
795
+ title='Export as JSON'
796
+ >
797
+ <Download className='w-3.5 h-3.5' />
798
+ Export
799
+ </Button>
800
+ <DropdownMenu modal={false}>
801
+ <DropdownMenuTrigger
802
+ className={cn(buttonVariants({ variant: 'outline', size: 'sm' }), 'h-7')}
803
+ >
804
+ Columns <ChevronDown className='ml-1 w-3.5 h-3.5' />
805
+ </DropdownMenuTrigger>
806
+ <DropdownMenuContent align='end'>
807
+ {table
808
+ .getAllColumns()
809
+ .filter((col) => col.getCanHide())
810
+ .map((col) => (
811
+ <DropdownMenuCheckboxItem
812
+ key={col.id}
813
+ checked={col.getIsVisible()}
814
+ onCheckedChange={(value) => col.toggleVisibility(!!value)}
815
+ >
816
+ {col.id}
817
+ </DropdownMenuCheckboxItem>
818
+ ))}
819
+ </DropdownMenuContent>
820
+ </DropdownMenu>
821
+ </div>
822
+ )}
823
+ </div>
824
+
825
+ {/* Table */}
826
+ <div className='flex-1 min-h-0 overflow-auto'>
827
+ {loading ? (
828
+ <div className='p-10 text-center text-muted-foreground'>Loading...</div>
829
+ ) : items.length === 0 ? (
830
+ <div className='p-10 text-center text-muted-foreground'>No data</div>
831
+ ) : (
832
+ <Table>
833
+ <TableHeader>
834
+ {table.getHeaderGroups().map((group) => (
835
+ <TableRow key={group.id}>
836
+ {group.headers.map((header) => (
837
+ <TableHead key={header.id}>
838
+ {header.isPlaceholder
839
+ ? null
840
+ : flexRender(header.column.columnDef.header, header.getContext())}
841
+ </TableHead>
842
+ ))}
843
+ </TableRow>
844
+ ))}
845
+ </TableHeader>
846
+ <TableBody>
847
+ {table.getRowModel().rows.length === 0 ? (
848
+ <TableRow>
849
+ <TableCell
850
+ colSpan={columns.length}
851
+ className='h-24 text-center text-muted-foreground'
852
+ >
853
+ No results
854
+ </TableCell>
855
+ </TableRow>
856
+ ) : (
857
+ table.getRowModel().rows.map((row) => (
858
+ <Fragment key={row.id}>
859
+ <TableRow
860
+ className={cn('cursor-pointer', row.getIsExpanded() && 'bg-muted/30')}
861
+ onClick={() => row.toggleExpanded()}
862
+ >
863
+ {row.getVisibleCells().map((cell) => (
864
+ <TableCell key={cell.id}>
865
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
866
+ </TableCell>
867
+ ))}
868
+ </TableRow>
869
+ {row.getIsExpanded() && (
870
+ <TableRow>
871
+ <TableCell
872
+ colSpan={row.getVisibleCells().length}
873
+ className='p-0 bg-muted/10'
874
+ >
875
+ <div className='p-4 border-t border-border'>
876
+ <div className='text-[10px] uppercase text-muted-foreground mb-2 font-medium'>
877
+ Full Document
878
+ </div>
879
+ <pre className='text-xs font-mono bg-background/50 p-3 rounded border border-border overflow-auto max-h-64'>
880
+ {JSON.stringify(
881
+ row.original,
882
+ (k, v) => {
883
+ if (v instanceof Uint8Array || b4a.isBuffer(v))
884
+ return v.toString('hex')
885
+ return v
886
+ },
887
+ 2
888
+ )}
889
+ </pre>
890
+ </div>
891
+ </TableCell>
892
+ </TableRow>
893
+ )}
894
+ </Fragment>
895
+ ))
896
+ )}
897
+ </TableBody>
898
+ </Table>
899
+ )}
900
+ </div>
901
+ </div>
902
+ )
903
+ }
904
+
905
+ function SystemView({ cero, selected, room, openedRoom, searchRef }) {
906
+ const identityProfile = useIdentityData(cero, 'profile')
907
+ const identityDevices = useIdentityData(cero, 'devices')
908
+ const identityInvites = useIdentityData(cero, 'invites')
909
+ const identityRooms = useIdentityData(cero, 'rooms')
910
+ const identitySettings = useIdentityData(cero, 'settings')
911
+
912
+ const roomProfile = useRoomData(cero, openedRoom, 'profile')
913
+ const roomMembers = useRoomData(cero, openedRoom, 'members')
914
+ const roomDevices = useRoomData(cero, openedRoom, 'devices')
915
+ const roomInvites = useRoomData(cero, openedRoom, 'invites')
916
+
917
+ if (!selected) {
918
+ return (
919
+ <div className='flex-1 flex items-center justify-center text-muted-foreground'>
920
+ Select a system collection
921
+ </div>
922
+ )
923
+ }
924
+
925
+ const getData = () => {
926
+ if (selected.scope === 'identity') {
927
+ switch (selected.type) {
928
+ case 'profile':
929
+ return identityProfile
930
+ case 'devices':
931
+ return identityDevices
932
+ case 'invites':
933
+ return identityInvites
934
+ case 'rooms':
935
+ return identityRooms
936
+ case 'settings':
937
+ return identitySettings
938
+ }
939
+ } else if (selected.scope === 'room') {
940
+ if (!room) {
941
+ return { data: null, loading: false }
942
+ }
943
+ switch (selected.type) {
944
+ case 'profile':
945
+ return roomProfile
946
+ case 'members':
947
+ return roomMembers
948
+ case 'devices':
949
+ return roomDevices
950
+ case 'invites':
951
+ return roomInvites
952
+ }
953
+ }
954
+ return { data: null, loading: false }
955
+ }
956
+
957
+ const { data, loading } = getData()
958
+ const title =
959
+ selected.scope === 'identity' ? `Identity ${selected.type}` : `Room ${selected.type}`
960
+
961
+ return (
962
+ <div className='flex-1 flex flex-col min-w-0'>
963
+ {selected.scope === 'room' && !room ? (
964
+ <div className='p-10 text-center text-amber-500 text-xs'>Select a room to view data</div>
965
+ ) : (
966
+ <SystemTable
967
+ key={`${selected.scope}-${selected.type}`}
968
+ data={data}
969
+ loading={loading}
970
+ type={selected.type}
971
+ searchRef={searchRef}
972
+ />
973
+ )}
974
+ </div>
975
+ )
976
+ }
977
+
978
+ // ============================================================================
979
+ // Main Panel
980
+ // ============================================================================
981
+
982
+ function PanelButton({ onClick }) {
983
+ return (
984
+ <Button
985
+ onClick={onClick}
986
+ title='Open Data Panel'
987
+ size='icon'
988
+ className='fixed bottom-4 left-4 z-99999 rounded-full shadow-lg'
989
+ >
990
+ <Database className='w-4 h-4' />
991
+ </Button>
992
+ )
993
+ }
994
+
995
+ export function CeroPanel({ db, defaultOpen = false }) {
996
+ const [open, setOpen] = useState(defaultOpen)
997
+ const [tab, setTab] = useState('data')
998
+ const [collection, setCollection] = useState(null)
999
+ const [systemSelected, setSystemSelected] = useState(null)
1000
+ const [room, setRoom] = useState(null)
1001
+ const [openedRoom, setOpenedRoom] = useState(null)
1002
+ const [rooms, setRooms] = useState([])
1003
+ const collections = useSchema(db)
1004
+ const searchRef = useRef(null)
1005
+
1006
+ const cero = db?.cero || db
1007
+
1008
+ // Keyboard shortcuts
1009
+ useEffect(() => {
1010
+ if (!open) return
1011
+
1012
+ const onKeyDown = (e) => {
1013
+ // Esc to close
1014
+ if (e.key === 'Escape') {
1015
+ setOpen(false)
1016
+ return
1017
+ }
1018
+ // Cmd+K to focus search
1019
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
1020
+ e.preventDefault()
1021
+ searchRef.current?.focus()
1022
+ }
1023
+ }
1024
+
1025
+ window.addEventListener('keydown', onKeyDown)
1026
+ return () => window.removeEventListener('keydown', onKeyDown)
1027
+ }, [open])
1028
+
1029
+ useEffect(() => {
1030
+ const target = db?.rooms || cero?.rooms
1031
+ if (target?.list)
1032
+ target
1033
+ .list()
1034
+ .then(setRooms)
1035
+ .catch(() => {})
1036
+ }, [db, cero])
1037
+
1038
+ // Open room for system tab
1039
+ useEffect(() => {
1040
+ if (!room) {
1041
+ setOpenedRoom(null)
1042
+ return
1043
+ }
1044
+ let cancelled = false
1045
+ const target = db?.rooms || cero?.rooms
1046
+ if (target?.open) {
1047
+ target
1048
+ .open(room.id)
1049
+ .then((r) => {
1050
+ if (!cancelled) setOpenedRoom(r)
1051
+ })
1052
+ .catch(() => {})
1053
+ }
1054
+ return () => {
1055
+ cancelled = true
1056
+ }
1057
+ }, [db, cero, room])
1058
+
1059
+ if (!open) {
1060
+ return <PanelButton onClick={() => setOpen(true)} />
1061
+ }
1062
+
1063
+ return (
1064
+ <div className='fixed inset-0 z-99998 flex flex-col bg-background text-foreground text-xs'>
1065
+ {/* Header */}
1066
+ <div className='flex items-center justify-between px-3 py-1.5 border-b border-border bg-muted/30 shrink-0'>
1067
+ <div className='flex items-center gap-3'>
1068
+ <div className='flex items-center gap-2'>
1069
+ <Database className='w-4 h-4 text-primary' />
1070
+ <span className='font-semibold'>Cero</span>
1071
+ </div>
1072
+ <Tabs value={tab} onValueChange={setTab}>
1073
+ <TabsList>
1074
+ <TabsTrigger value='data'>Data</TabsTrigger>
1075
+ <TabsTrigger value='system'>System</TabsTrigger>
1076
+ </TabsList>
1077
+ </Tabs>
1078
+ </div>
1079
+ <div className='flex items-center gap-2'>
1080
+ <span className='text-[10px] text-muted-foreground'>⌘K search · Esc close</span>
1081
+ <Button variant='ghost' size='icon-xs' onClick={() => setOpen(false)}>
1082
+ <X className='w-4 h-4' />
1083
+ </Button>
1084
+ </div>
1085
+ </div>
1086
+
1087
+ {/* Content */}
1088
+ <ResizablePanelGroup direction='horizontal' className='flex-1'>
1089
+ <ResizablePanel defaultSize='15%' minSize='10%' maxSize='30%'>
1090
+ {tab === 'data' ? (
1091
+ <DataSidebar collections={collections} selected={collection} onSelect={setCollection} />
1092
+ ) : (
1093
+ <SystemSidebar
1094
+ selected={systemSelected}
1095
+ onSelect={setSystemSelected}
1096
+ rooms={rooms}
1097
+ room={room}
1098
+ onRoomChange={setRoom}
1099
+ />
1100
+ )}
1101
+ </ResizablePanel>
1102
+ <ResizableHandle withHandle />
1103
+ <ResizablePanel defaultSize='85%' className='overflow-hidden'>
1104
+ {tab === 'data' ? (
1105
+ collection ? (
1106
+ <CollectionView
1107
+ db={db}
1108
+ collection={collection}
1109
+ room={room}
1110
+ rooms={rooms}
1111
+ onRoomChange={setRoom}
1112
+ />
1113
+ ) : (
1114
+ <div className='flex-1 h-full flex items-center justify-center text-muted-foreground'>
1115
+ Select a collection
1116
+ </div>
1117
+ )
1118
+ ) : (
1119
+ <SystemView
1120
+ cero={cero}
1121
+ selected={systemSelected}
1122
+ room={room}
1123
+ openedRoom={openedRoom}
1124
+ searchRef={searchRef}
1125
+ />
1126
+ )}
1127
+ </ResizablePanel>
1128
+ </ResizablePanelGroup>
1129
+ </div>
1130
+ )
1131
+ }