@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 +42 -0
- package/package.json +41 -0
- package/src/components/data-table.jsx +245 -0
- package/src/components/document-form.jsx +211 -0
- package/src/components/panel.jsx +1131 -0
- package/src/components/sidebar.jsx +151 -0
- package/src/components/ui/avatar.jsx +38 -0
- package/src/components/ui/badge.jsx +26 -0
- package/src/components/ui/button.jsx +51 -0
- package/src/components/ui/checkbox.jsx +43 -0
- package/src/components/ui/dialog.jsx +126 -0
- package/src/components/ui/dropdown-menu.jsx +213 -0
- package/src/components/ui/input.jsx +19 -0
- package/src/components/ui/resizable.jsx +34 -0
- package/src/components/ui/scroll-area.jsx +32 -0
- package/src/components/ui/select.jsx +169 -0
- package/src/components/ui/table.jsx +88 -0
- package/src/components/ui/tabs.jsx +43 -0
- package/src/components/ui/tooltip.jsx +33 -0
- package/src/global.css +86 -0
- package/src/hooks/index.js +3 -0
- package/src/hooks/use-collection.js +27 -0
- package/src/hooks/use-rooms.js +80 -0
- package/src/hooks/use-schema.js +71 -0
- package/src/hooks/use-system.js +23 -0
- package/src/hooks/util.js +10 -0
- package/src/index.js +5 -0
- package/src/lib/field-types.js +148 -0
- package/src/lib/utils.js +19 -0
- package/src/open-panel.js +30 -0
- package/src/panel-window.html +24 -0
- package/src/panel-window.jsx +195 -0
- package/src/theme.css +57 -0
|
@@ -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
|
+
}
|