@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,10 @@
1
+ import { Readable } from 'streamx'
2
+
3
+ export function empty(value) {
4
+ const s = new Readable()
5
+ queueMicrotask(() => {
6
+ s.push(value)
7
+ s.push(null)
8
+ })
9
+ return s
10
+ }
package/src/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { CeroPanel } from './components/panel.jsx'
2
+ export { useSchema, useSchemaByScope, useCollectionSchema } from './hooks/use-schema.js'
3
+ export { useCollection } from './hooks/use-collection.js'
4
+ export { useRooms, useRoomSelection } from './hooks/use-rooms.js'
5
+ export { useIdentityData, useRoomData } from './hooks/use-system.js'
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Field type utilities for displaying and editing schema values
3
+ */
4
+
5
+ /**
6
+ * Format a value for display based on its schema type
7
+ */
8
+ export function formatValue(value, type) {
9
+ if (value === null || value === undefined) return '—'
10
+
11
+ const typeName = typeof type === 'string' ? type : type?.type
12
+
13
+ switch (typeName) {
14
+ case 'bool':
15
+ case 'boolean':
16
+ return value ? 'true' : 'false'
17
+
18
+ case 'json':
19
+ case 'object':
20
+ return JSON.stringify(value)
21
+
22
+ case 'array':
23
+ if (Array.isArray(value)) {
24
+ return `[${value.length} items]`
25
+ }
26
+ return JSON.stringify(value)
27
+
28
+ case 'date':
29
+ case 'timestamp':
30
+ if (value instanceof Date) {
31
+ return value.toISOString()
32
+ }
33
+ if (typeof value === 'number') {
34
+ return new Date(value).toISOString()
35
+ }
36
+ return String(value)
37
+
38
+ case 'buffer':
39
+ case 'binary':
40
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
41
+ return `<Buffer ${value.length} bytes>`
42
+ }
43
+ return String(value)
44
+
45
+ default:
46
+ if (typeof value === 'object') {
47
+ return JSON.stringify(value)
48
+ }
49
+ return String(value)
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Get input type for form fields based on schema type
55
+ */
56
+ export function getInputType(type) {
57
+ const typeName = typeof type === 'string' ? type : type?.type
58
+
59
+ switch (typeName) {
60
+ case 'bool':
61
+ case 'boolean':
62
+ return 'checkbox'
63
+
64
+ case 'number':
65
+ case 'int':
66
+ case 'integer':
67
+ case 'float':
68
+ return 'number'
69
+
70
+ case 'json':
71
+ case 'object':
72
+ case 'array':
73
+ return 'textarea'
74
+
75
+ default:
76
+ return 'text'
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Parse input value to match schema type
82
+ */
83
+ export function parseValue(value, type) {
84
+ const typeName = typeof type === 'string' ? type : type?.type
85
+
86
+ switch (typeName) {
87
+ case 'bool':
88
+ case 'boolean':
89
+ return Boolean(value)
90
+
91
+ case 'number':
92
+ case 'float':
93
+ return parseFloat(value) || 0
94
+
95
+ case 'int':
96
+ case 'integer':
97
+ return parseInt(value, 10) || 0
98
+
99
+ case 'json':
100
+ case 'object':
101
+ case 'array':
102
+ try {
103
+ return JSON.parse(value)
104
+ } catch {
105
+ return value
106
+ }
107
+
108
+ default:
109
+ return value
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Get default value for a schema type
115
+ */
116
+ export function getDefaultValue(type) {
117
+ const typeName = typeof type === 'string' ? type : type?.type
118
+
119
+ switch (typeName) {
120
+ case 'bool':
121
+ case 'boolean':
122
+ return false
123
+
124
+ case 'number':
125
+ case 'int':
126
+ case 'integer':
127
+ case 'float':
128
+ return 0
129
+
130
+ case 'array':
131
+ return []
132
+
133
+ case 'json':
134
+ case 'object':
135
+ return {}
136
+
137
+ default:
138
+ return ''
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Truncate long strings for display
144
+ */
145
+ export function truncate(str, maxLength = 50) {
146
+ if (!str || str.length <= maxLength) return str
147
+ return str.slice(0, maxLength) + '...'
148
+ }
@@ -0,0 +1,19 @@
1
+ import { clsx } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ export function cn(...inputs) {
5
+ return twMerge(clsx(inputs))
6
+ }
7
+
8
+ export function formatValue(value, type) {
9
+ if (value === null || value === undefined) return '—'
10
+ if (type?.type === 'bool') return value ? 'true' : 'false'
11
+ if (type?.type === 'json') return JSON.stringify(value)
12
+ if (typeof value === 'object') return JSON.stringify(value)
13
+ return String(value)
14
+ }
15
+
16
+ export function truncate(str, maxLength = 50) {
17
+ if (!str || str.length <= maxLength) return str
18
+ return str.slice(0, maxLength) + '...'
19
+ }
@@ -0,0 +1,30 @@
1
+ /* global Pear */
2
+
3
+ /**
4
+ * Opens the Cero Panel in a separate Pear window.
5
+ * The panel window connects to the same storage path as the main app.
6
+ *
7
+ * @param {Object} opts
8
+ * @param {string} opts.storage - Storage path (usually Pear.config.storage)
9
+ * @param {number} [opts.width=900] - Window width
10
+ * @param {number} [opts.height=600] - Window height
11
+ */
12
+ export async function openPanel({ storage, width = 900, height = 600 }) {
13
+ // Store config for the panel window to read
14
+ sessionStorage.setItem('cero-panel-storage', storage)
15
+
16
+ const win = new Pear.Window('./node_modules/cero-panel/build/src/panel-window.html', {
17
+ width,
18
+ height
19
+ })
20
+
21
+ // Send storage path to panel
22
+ win.on('message', (msg) => {
23
+ if (msg.type === 'ready') {
24
+ win.send({ type: 'config', storage })
25
+ }
26
+ })
27
+
28
+ await win.open({ show: true })
29
+ return win
30
+ }
@@ -0,0 +1,24 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>Cero Panel</title>
6
+ <style>
7
+ * {
8
+ box-sizing: border-box;
9
+ margin: 0;
10
+ padding: 0;
11
+ }
12
+ body {
13
+ font-family: system-ui, sans-serif;
14
+ background: #1e293b;
15
+ color: #e2e8f0;
16
+ font-size: 13px;
17
+ }
18
+ </style>
19
+ </head>
20
+ <body>
21
+ <div id="root"></div>
22
+ <script type="module" src="./panel-window.js"></script>
23
+ </body>
24
+ </html>
@@ -0,0 +1,195 @@
1
+ /* global Pear */
2
+ import { useState, useEffect } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+
5
+ // Panel receives base data via Pear.messages from parent window
6
+ function PanelApp() {
7
+ const [collections, setCollections] = useState([])
8
+ const [rooms, setRooms] = useState([])
9
+ const [selected, setSelected] = useState(null)
10
+ const [room, setRoom] = useState(null)
11
+ const [data, setData] = useState([])
12
+ const [loading, setLoading] = useState(false)
13
+
14
+ useEffect(() => {
15
+ // Listen for data from parent window
16
+ const stream = Pear.messages((msg) => {
17
+ if (msg.type === 'init') {
18
+ setCollections(msg.collections)
19
+ setRooms(msg.rooms)
20
+ } else if (msg.type === 'data') {
21
+ setData(msg.data)
22
+ setLoading(false)
23
+ }
24
+ })
25
+
26
+ // Request initial data
27
+ Pear.message({ type: 'ready' })
28
+
29
+ return () => stream.destroy?.()
30
+ }, [])
31
+
32
+ const onSelect = (col) => {
33
+ setSelected(col)
34
+ setLoading(true)
35
+ Pear.message({ type: 'subscribe', collection: col.name, roomId: room?.id })
36
+ }
37
+
38
+ const onRoomChange = (r) => {
39
+ setRoom(r)
40
+ if (selected?.scope === 'shared') {
41
+ setLoading(true)
42
+ Pear.message({ type: 'subscribe', collection: selected.name, roomId: r?.id })
43
+ }
44
+ }
45
+
46
+ const fields = selected
47
+ ? Object.keys(collections.find((c) => c.name === selected.name)?.fields || {})
48
+ : []
49
+ const grouped = {
50
+ local: collections.filter((c) => c.scope === 'local'),
51
+ private: collections.filter((c) => c.scope === 'private'),
52
+ shared: collections.filter((c) => c.scope === 'shared')
53
+ }
54
+
55
+ return (
56
+ <div style={{ display: 'flex', height: '100vh' }}>
57
+ {/* Sidebar */}
58
+ <div style={{ width: 200, borderRight: '1px solid #334155', padding: 12, overflow: 'auto' }}>
59
+ <div style={{ marginBottom: 16, fontWeight: 600, color: '#0ea5e9' }}>Cero Panel</div>
60
+ {Object.entries(grouped).map(
61
+ ([scope, cols]) =>
62
+ cols.length > 0 && (
63
+ <div key={scope} style={{ marginBottom: 16 }}>
64
+ <div
65
+ style={{
66
+ fontSize: 10,
67
+ color: '#64748b',
68
+ textTransform: 'uppercase',
69
+ marginBottom: 8
70
+ }}
71
+ >
72
+ {scope} ({cols.length})
73
+ </div>
74
+ {cols.map((c) => (
75
+ <button
76
+ key={c.name}
77
+ onClick={() => onSelect(c)}
78
+ style={{
79
+ display: 'block',
80
+ width: '100%',
81
+ padding: '8px 12px',
82
+ marginBottom: 4,
83
+ backgroundColor: selected?.name === c.name ? '#0ea5e9' : 'transparent',
84
+ color: selected?.name === c.name ? 'white' : '#e2e8f0',
85
+ border: 'none',
86
+ borderRadius: 6,
87
+ textAlign: 'left',
88
+ cursor: 'pointer',
89
+ fontSize: 12
90
+ }}
91
+ >
92
+ {c.name}
93
+ </button>
94
+ ))}
95
+ </div>
96
+ )
97
+ )}
98
+ </div>
99
+
100
+ {/* Main */}
101
+ <div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
102
+ {!selected ? (
103
+ <div style={{ color: '#64748b', textAlign: 'center', marginTop: 40 }}>
104
+ Select a collection
105
+ </div>
106
+ ) : (
107
+ <div>
108
+ <div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
109
+ <span style={{ fontWeight: 600 }}>{selected.name}</span>
110
+ <span
111
+ style={{
112
+ fontSize: 10,
113
+ color: '#64748b',
114
+ padding: '2px 6px',
115
+ backgroundColor: '#334155',
116
+ borderRadius: 4
117
+ }}
118
+ >
119
+ {selected.scope}
120
+ </span>
121
+ {selected.scope === 'shared' && (
122
+ <select
123
+ value={room?.id || ''}
124
+ onChange={(e) => onRoomChange(rooms.find((r) => r.id === e.target.value) || null)}
125
+ style={{
126
+ padding: '6px 10px',
127
+ backgroundColor: '#334155',
128
+ color: '#e2e8f0',
129
+ border: '1px solid #475569',
130
+ borderRadius: 4,
131
+ fontSize: 11
132
+ }}
133
+ >
134
+ <option value=''>Select room...</option>
135
+ {rooms.map((r) => (
136
+ <option key={r.id} value={r.id}>
137
+ {r.name || r.id?.slice(0, 8)}
138
+ </option>
139
+ ))}
140
+ </select>
141
+ )}
142
+ <span style={{ fontSize: 11, color: '#64748b' }}>
143
+ {loading ? 'Loading...' : `${data.length} documents`}
144
+ </span>
145
+ </div>
146
+
147
+ {selected.scope === 'shared' && !room ? (
148
+ <div style={{ color: '#f59e0b', fontSize: 12 }}>Select a room to view data</div>
149
+ ) : data.length === 0 ? (
150
+ <div style={{ color: '#64748b' }}>No documents</div>
151
+ ) : (
152
+ <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
153
+ <thead>
154
+ <tr>
155
+ {fields.map((f) => (
156
+ <th
157
+ key={f}
158
+ style={{
159
+ textAlign: 'left',
160
+ padding: '8px 12px',
161
+ borderBottom: '1px solid #334155',
162
+ color: '#94a3b8'
163
+ }}
164
+ >
165
+ {f}
166
+ </th>
167
+ ))}
168
+ </tr>
169
+ </thead>
170
+ <tbody>
171
+ {data.map((doc, i) => (
172
+ <tr key={i}>
173
+ {fields.map((f) => (
174
+ <td
175
+ key={f}
176
+ style={{ padding: '8px 12px', borderBottom: '1px solid #334155' }}
177
+ >
178
+ {typeof doc[f] === 'object'
179
+ ? JSON.stringify(doc[f])
180
+ : String(doc[f] ?? '')}
181
+ </td>
182
+ ))}
183
+ </tr>
184
+ ))}
185
+ </tbody>
186
+ </table>
187
+ )}
188
+ </div>
189
+ )}
190
+ </div>
191
+ </div>
192
+ )
193
+ }
194
+
195
+ createRoot(document.getElementById('root')).render(<PanelApp />)
package/src/theme.css ADDED
@@ -0,0 +1,57 @@
1
+ @custom-variant dark (&:is(.dark *));
2
+
3
+ @theme inline {
4
+ --font-sans: system-ui, sans-serif;
5
+ --color-background: var(--background);
6
+ --color-foreground: var(--foreground);
7
+ --color-card: var(--card);
8
+ --color-card-foreground: var(--card-foreground);
9
+ --color-popover: var(--popover);
10
+ --color-popover-foreground: var(--popover-foreground);
11
+ --color-primary: var(--primary);
12
+ --color-primary-foreground: var(--primary-foreground);
13
+ --color-secondary: var(--secondary);
14
+ --color-secondary-foreground: var(--secondary-foreground);
15
+ --color-muted: var(--muted);
16
+ --color-muted-foreground: var(--muted-foreground);
17
+ --color-accent: var(--accent);
18
+ --color-accent-foreground: var(--accent-foreground);
19
+ --color-destructive: var(--destructive);
20
+ --color-destructive-foreground: var(--destructive-foreground);
21
+ --color-border: var(--border);
22
+ --color-input: var(--input);
23
+ --color-ring: var(--ring);
24
+ --radius-sm: calc(var(--radius) - 4px);
25
+ --radius-md: calc(var(--radius) - 2px);
26
+ --radius-lg: var(--radius);
27
+ --radius-xl: calc(var(--radius) + 4px);
28
+ }
29
+
30
+ :root {
31
+ --background: oklch(0.145 0 0);
32
+ --foreground: oklch(0.985 0 0);
33
+ --card: oklch(0.205 0 0);
34
+ --card-foreground: oklch(0.985 0 0);
35
+ --popover: oklch(0.205 0 0);
36
+ --popover-foreground: oklch(0.985 0 0);
37
+ --primary: oklch(0.7 0.12 183);
38
+ --primary-foreground: oklch(0.28 0.04 193);
39
+ --secondary: oklch(0.274 0.006 286.033);
40
+ --secondary-foreground: oklch(0.985 0 0);
41
+ --muted: oklch(0.269 0 0);
42
+ --muted-foreground: oklch(0.708 0 0);
43
+ --accent: oklch(0.7 0.12 183);
44
+ --accent-foreground: oklch(0.28 0.04 193);
45
+ --destructive: oklch(0.604 0.191 22.216);
46
+ --destructive-foreground: oklch(1 0 0);
47
+ --border: oklch(1 0 0 / 10%);
48
+ --input: oklch(1 0 0 / 10%);
49
+ --ring: oklch(0.556 0 0);
50
+ --radius: 0.625rem;
51
+ }
52
+
53
+ @layer base {
54
+ * {
55
+ @apply border-border outline-ring/50;
56
+ }
57
+ }