@liorandb/studio 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,189 @@
1
+ 'use client';
2
+
3
+ import React, { useState } from 'react';
4
+ import { Play, Copy } from 'lucide-react';
5
+ import { useAppStore } from '@/store';
6
+ import { LioranDBService } from '@/lib/lioran';
7
+ import { useToast } from './Toast';
8
+ import { formatJSON, copyToClipboard } from '@/lib/utils';
9
+ import { JsonViewer } from './JsonViewer';
10
+
11
+ interface QueryEditorProps {}
12
+
13
+ export function QueryEditor({}: QueryEditorProps) {
14
+ const { currentDatabase, selectedCollection, queryResults, isLoading } = useAppStore();
15
+ const { addToast } = useToast();
16
+
17
+ const [queryMode, setQueryMode] = useState<'find' | 'aggregate'>('find');
18
+ const [filter, setFilter] = useState('{}');
19
+ const [resultMode, setResultMode] = useState<'json' | 'count'>('json');
20
+
21
+ async function executeQuery() {
22
+ if (!currentDatabase || !selectedCollection) {
23
+ addToast('Please select a database and collection', 'warning');
24
+ return;
25
+ }
26
+
27
+ try {
28
+ let filterObj: Record<string, any> = {};
29
+ try {
30
+ filterObj = JSON.parse(filter);
31
+ } catch {
32
+ addToast('Invalid JSON filter', 'error');
33
+ return;
34
+ }
35
+
36
+ useAppStore.setState({ isLoading: true });
37
+
38
+ const startTime = performance.now();
39
+ const { documents, count } = await LioranDBService.find(
40
+ currentDatabase,
41
+ selectedCollection,
42
+ filterObj,
43
+ 100
44
+ );
45
+ const executionTime = Math.round(performance.now() - startTime);
46
+
47
+ useAppStore.setState({
48
+ queryResults: {
49
+ data: documents,
50
+ count,
51
+ executionTime,
52
+ },
53
+ });
54
+
55
+ addToast(`Query executed in ${executionTime}ms`, 'success');
56
+ } catch (error) {
57
+ addToast(`Query error: ${error}`, 'error');
58
+ } finally {
59
+ useAppStore.setState({ isLoading: false });
60
+ }
61
+ }
62
+
63
+ async function copyResults() {
64
+ if (!queryResults) return;
65
+ try {
66
+ await copyToClipboard(formatJSON(queryResults.data));
67
+ addToast('Results copied to clipboard', 'success');
68
+ } catch {
69
+ addToast('Failed to copy', 'error');
70
+ }
71
+ }
72
+
73
+ return (
74
+ <div className="flex flex-col h-full gap-4 p-4">
75
+ {/* Query Editor */}
76
+ <div className="flex-1 flex flex-col gap-2">
77
+ <div className="flex items-center justify-between">
78
+ <h3 className="text-sm font-semibold text-slate-100">Query</h3>
79
+ <div className="flex gap-2">
80
+ <button
81
+ onClick={() => setQueryMode('find')}
82
+ className={`px-2 py-1 text-xs rounded transition ${
83
+ queryMode === 'find'
84
+ ? 'bg-emerald-600 text-white'
85
+ : 'bg-slate-800 text-slate-400 hover:text-slate-200'
86
+ }`}
87
+ >
88
+ Find
89
+ </button>
90
+ <button
91
+ onClick={() => setQueryMode('aggregate')}
92
+ className={`px-2 py-1 text-xs rounded transition ${
93
+ queryMode === 'aggregate'
94
+ ? 'bg-emerald-600 text-white'
95
+ : 'bg-slate-800 text-slate-400 hover:text-slate-200'
96
+ }`}
97
+ disabled
98
+ >
99
+ Aggregate (Soon)
100
+ </button>
101
+ </div>
102
+ </div>
103
+
104
+ <textarea
105
+ value={filter}
106
+ onChange={(e) => setFilter(e.target.value)}
107
+ placeholder='{"field": "value"}'
108
+ className="flex-1 bg-slate-900 border border-slate-800 rounded font-mono text-sm text-slate-100 p-3 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 resize-none"
109
+ />
110
+
111
+ <button
112
+ onClick={executeQuery}
113
+ disabled={isLoading || !currentDatabase || !selectedCollection}
114
+ className="flex items-center justify-center gap-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded py-2 transition disabled:opacity-50 disabled:cursor-not-allowed font-medium"
115
+ >
116
+ <Play size={16} />
117
+ <span>Execute Query</span>
118
+ </button>
119
+ </div>
120
+
121
+ {/* Results */}
122
+ {queryResults && (
123
+ <div className="flex-1 flex flex-col gap-2 border-t border-slate-800 pt-4">
124
+ <div className="flex items-center justify-between">
125
+ <div className="flex items-center gap-3">
126
+ <h3 className="text-sm font-semibold text-slate-100">Results</h3>
127
+ <span className="text-xs text-slate-400">
128
+ {queryResults.count} documents • {queryResults.executionTime}ms
129
+ </span>
130
+ </div>
131
+
132
+ <div className="flex gap-2">
133
+ <button
134
+ onClick={() => setResultMode('json')}
135
+ className={`px-2 py-1 text-xs rounded transition ${
136
+ resultMode === 'json'
137
+ ? 'bg-emerald-600 text-white'
138
+ : 'bg-slate-800 text-slate-400 hover:text-slate-200'
139
+ }`}
140
+ >
141
+ JSON
142
+ </button>
143
+ <button
144
+ onClick={() => setResultMode('count')}
145
+ className={`px-2 py-1 text-xs rounded transition ${
146
+ resultMode === 'count'
147
+ ? 'bg-emerald-600 text-white'
148
+ : 'bg-slate-800 text-slate-400 hover:text-slate-200'
149
+ }`}
150
+ >
151
+ Count
152
+ </button>
153
+ <button
154
+ onClick={copyResults}
155
+ className="px-2 py-1 text-xs rounded bg-slate-800 text-slate-400 hover:text-slate-200 transition flex items-center gap-1"
156
+ >
157
+ <Copy size={12} />
158
+ Copy
159
+ </button>
160
+ </div>
161
+ </div>
162
+
163
+ <div className="flex-1 overflow-auto bg-slate-900 rounded border border-slate-800 p-3">
164
+ {resultMode === 'count' ? (
165
+ <div className="text-center text-slate-300">
166
+ <div className="text-4xl font-bold text-emerald-400 mb-2">
167
+ {queryResults.count}
168
+ </div>
169
+ <p className="text-slate-400">documents matched</p>
170
+ </div>
171
+ ) : (
172
+ <div className="font-mono text-xs space-y-2">
173
+ {queryResults.data.length === 0 ? (
174
+ <p className="text-slate-400">No results</p>
175
+ ) : (
176
+ queryResults.data.map((doc, idx) => (
177
+ <div key={idx} className="text-cyan-400">
178
+ <JsonViewer data={doc} collapsed={true} />
179
+ </div>
180
+ ))
181
+ )}
182
+ </div>
183
+ )}
184
+ </div>
185
+ </div>
186
+ )}
187
+ </div>
188
+ );
189
+ }
@@ -0,0 +1,196 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useState } from 'react';
4
+ import { ChevronRight, ChevronDown, Plus, Trash2 } from 'lucide-react';
5
+ import { useAppStore } from '@/store';
6
+ import { LioranDBService } from '@/lib/lioran';
7
+ import { Database, Collection } from '@/types';
8
+ import { useToast } from './Toast';
9
+
10
+ interface SidebarProps {
11
+ onDatabaseSelect: (dbName: string) => void;
12
+ onCollectionSelect: (dbName: string, collectionName: string) => void;
13
+ onCreateDatabase: () => void;
14
+ onCreateCollection: () => void;
15
+ }
16
+
17
+ export function Sidebar({
18
+ onDatabaseSelect,
19
+ onCollectionSelect,
20
+ onCreateDatabase,
21
+ onCreateCollection,
22
+ }: SidebarProps) {
23
+ const [expandedDbs, setExpandedDbs] = useState<Set<string>>(new Set());
24
+ const [isLoading, setIsLoading] = useState(false);
25
+
26
+ const { databases, currentDatabase, selectedCollection } = useAppStore();
27
+ const { addToast } = useToast();
28
+
29
+ const toggleDatabase = (dbName: string) => {
30
+ setExpandedDbs((prev) => {
31
+ const next = new Set(prev);
32
+ next.has(dbName) ? next.delete(dbName) : next.add(dbName);
33
+ return next;
34
+ });
35
+ };
36
+
37
+ const handleDeleteDatabase = async (dbName: string) => {
38
+ if (!confirm(`Delete database "${dbName}"? This action cannot be undone.`)) return;
39
+
40
+ try {
41
+ setIsLoading(true);
42
+ await LioranDBService.dropDatabase(dbName);
43
+ const dbs = await LioranDBService.listDatabases();
44
+ useAppStore.setState({ databases: dbs });
45
+ if (currentDatabase === dbName) {
46
+ useAppStore.setState({ currentDatabase: null, selectedCollection: null });
47
+ }
48
+ addToast(`Database "${dbName}" deleted`, 'success');
49
+ } catch (error) {
50
+ addToast(`Error deleting database: ${error}`, 'error');
51
+ } finally {
52
+ setIsLoading(false);
53
+ }
54
+ };
55
+
56
+ const handleDeleteCollection = async (dbName: string, collectionName: string) => {
57
+ if (!confirm(`Delete collection "${collectionName}"? This action cannot be undone.`)) return;
58
+
59
+ try {
60
+ setIsLoading(true);
61
+ await LioranDBService.dropCollection(dbName, collectionName);
62
+ const collections = await LioranDBService.listCollections(dbName);
63
+ useAppStore.setState((state) => ({
64
+ collections: {
65
+ ...state.collections,
66
+ [dbName]: collections,
67
+ },
68
+ }));
69
+ if (selectedCollection === collectionName) {
70
+ useAppStore.setState({ selectedCollection: null });
71
+ }
72
+ addToast(`Collection "${collectionName}" deleted`, 'success');
73
+ } catch (error) {
74
+ addToast(`Error deleting collection: ${error}`, 'error');
75
+ } finally {
76
+ setIsLoading(false);
77
+ }
78
+ };
79
+
80
+ return (
81
+ <div className="w-64 bg-slate-950 border-r border-slate-800 flex flex-col h-full">
82
+ {/* Header */}
83
+ <div className="p-4 border-b border-slate-800">
84
+ <div className="flex items-center justify-between">
85
+ <h2 className="text-lg font-semibold text-slate-100">Databases</h2>
86
+ <button
87
+ onClick={onCreateDatabase}
88
+ disabled={isLoading}
89
+ className="p-1.5 hover:bg-slate-800 rounded transition disabled:opacity-50"
90
+ title="Create database"
91
+ >
92
+ <Plus size={18} className="text-emerald-400" />
93
+ </button>
94
+ </div>
95
+ </div>
96
+
97
+ {/* Database List */}
98
+ <div className="flex-1 overflow-y-auto">
99
+ {databases.length === 0 ? (
100
+ <div className="p-4 text-slate-400 text-sm text-center">
101
+ No databases. Create one to get started.
102
+ </div>
103
+ ) : (
104
+ <div className="space-y-1 p-2">
105
+ {databases.map((db) => (
106
+ <div key={db.name}>
107
+ {/* Database Row */}
108
+ <div
109
+ className={`flex items-center gap-2 px-3 py-2 rounded cursor-pointer transition ${
110
+ currentDatabase === db.name
111
+ ? 'bg-emerald-900/30 text-emerald-400'
112
+ : 'text-slate-300 hover:bg-slate-800'
113
+ }`}
114
+ >
115
+ <button
116
+ onClick={() => toggleDatabase(db.name)}
117
+ className="p-0.5 hover:bg-slate-700 rounded transition"
118
+ >
119
+ {expandedDbs.has(db.name) ? (
120
+ <ChevronDown size={16} />
121
+ ) : (
122
+ <ChevronRight size={16} />
123
+ )}
124
+ </button>
125
+
126
+ <button
127
+ onClick={() => onDatabaseSelect(db.name)}
128
+ className="flex-1 text-left truncate font-medium"
129
+ >
130
+ {db.name}
131
+ </button>
132
+
133
+ <button
134
+ onClick={() => handleDeleteDatabase(db.name)}
135
+ disabled={isLoading}
136
+ className="p-0.5 hover:bg-red-900/50 hover:text-red-400 rounded transition disabled:opacity-50"
137
+ title="Delete database"
138
+ >
139
+ <Trash2 size={16} />
140
+ </button>
141
+ </div>
142
+
143
+ {/* Collections */}
144
+ {expandedDbs.has(db.name) && (
145
+ <div className="pl-6 space-y-1">
146
+ {(useAppStore.getState().collections[db.name] || []).map(
147
+ (col: Collection) => (
148
+ <div
149
+ key={col.name}
150
+ className={`flex items-center gap-2 px-3 py-2 rounded cursor-pointer transition ${
151
+ selectedCollection === col.name && currentDatabase === db.name
152
+ ? 'bg-cyan-900/30 text-cyan-400'
153
+ : 'text-slate-400 hover:bg-slate-800'
154
+ }`}
155
+ >
156
+ <span className="w-4" />
157
+ <button
158
+ onClick={() => onCollectionSelect(db.name, col.name)}
159
+ className="flex-1 text-left truncate text-sm"
160
+ >
161
+ {col.name}
162
+ </button>
163
+ <button
164
+ onClick={() => handleDeleteCollection(db.name, col.name)}
165
+ disabled={isLoading}
166
+ className="p-0.5 hover:bg-red-900/50 hover:text-red-400 rounded transition disabled:opacity-50"
167
+ title="Delete collection"
168
+ >
169
+ <Trash2 size={14} />
170
+ </button>
171
+ </div>
172
+ )
173
+ )}
174
+
175
+ {/* Create Collection Button */}
176
+ <button
177
+ onClick={() => {
178
+ useAppStore.setState({ currentDatabase: db.name });
179
+ onCreateCollection();
180
+ }}
181
+ disabled={isLoading}
182
+ className="flex items-center gap-2 px-3 py-2 text-slate-400 hover:text-emerald-400 text-sm transition disabled:opacity-50 w-full rounded hover:bg-slate-800"
183
+ >
184
+ <Plus size={14} />
185
+ <span>New Collection</span>
186
+ </button>
187
+ </div>
188
+ )}
189
+ </div>
190
+ ))}
191
+ </div>
192
+ )}
193
+ </div>
194
+ </div>
195
+ );
196
+ }
@@ -0,0 +1,91 @@
1
+ 'use client';
2
+
3
+ import React, { createContext, useState, useCallback } from 'react';
4
+
5
+ export interface Toast {
6
+ id: string;
7
+ message: string;
8
+ type: 'success' | 'error' | 'info' | 'warning';
9
+ duration?: number;
10
+ }
11
+
12
+ interface ToastContextType {
13
+ toasts: Toast[];
14
+ addToast: (message: string, type: Toast['type'], duration?: number) => void;
15
+ removeToast: (id: string) => void;
16
+ }
17
+
18
+ export const ToastContext = createContext<ToastContextType | undefined>(undefined);
19
+
20
+ export function ToastProvider({ children }: { children: React.ReactNode }) {
21
+ const [toasts, setToasts] = useState<Toast[]>([]);
22
+
23
+ const addToast = useCallback(
24
+ (message: string, type: Toast['type'], duration = 3000) => {
25
+ const id = Math.random().toString(36).substr(2, 9);
26
+ setToasts((prev) => [...prev, { id, message, type, duration }]);
27
+
28
+ if (duration > 0) {
29
+ setTimeout(() => removeToast(id), duration);
30
+ }
31
+ },
32
+ []
33
+ );
34
+
35
+ const removeToast = useCallback((id: string) => {
36
+ setToasts((prev) => prev.filter((toast) => toast.id !== id));
37
+ }, []);
38
+
39
+ return (
40
+ <ToastContext.Provider value={{ toasts, addToast, removeToast }}>
41
+ {children}
42
+ <ToastContainer />
43
+ </ToastContext.Provider>
44
+ );
45
+ }
46
+
47
+ function ToastContainer() {
48
+ const context = React.useContext(ToastContext);
49
+ if (!context) return null;
50
+
51
+ const { toasts, removeToast } = context;
52
+
53
+ return (
54
+ <div className="fixed bottom-4 right-4 z-50 space-y-2">
55
+ {toasts.map((toast) => (
56
+ <Toast key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
57
+ ))}
58
+ </div>
59
+ );
60
+ }
61
+
62
+ function Toast({ toast, onClose }: { toast: Toast; onClose: () => void }) {
63
+ const bgColor = {
64
+ success: 'bg-emerald-600',
65
+ error: 'bg-red-600',
66
+ info: 'bg-blue-600',
67
+ warning: 'bg-amber-600',
68
+ }[toast.type];
69
+
70
+ return (
71
+ <div
72
+ className={`${bgColor} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 animate-in fade-in slide-in-from-bottom-4 duration-300`}
73
+ >
74
+ <span className="flex-1">{toast.message}</span>
75
+ <button
76
+ onClick={onClose}
77
+ className="text-white hover:opacity-80 transition"
78
+ >
79
+
80
+ </button>
81
+ </div>
82
+ );
83
+ }
84
+
85
+ export function useToast() {
86
+ const context = React.useContext(ToastContext);
87
+ if (!context) {
88
+ throw new Error('useToast must be used within ToastProvider');
89
+ }
90
+ return context;
91
+ }