@geekmidas/studio 0.1.0 → 0.4.0

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.
Files changed (75) hide show
  1. package/dist/{DataBrowser-hGwiTffZ.d.cts → DataBrowser-B-jz8KBR.d.mts} +5 -2
  2. package/dist/DataBrowser-B-jz8KBR.d.mts.map +1 -0
  3. package/dist/{DataBrowser-SOcqmZb2.d.mts → DataBrowser-BTe9HWJy.d.cts} +5 -2
  4. package/dist/DataBrowser-BTe9HWJy.d.cts.map +1 -0
  5. package/dist/{DataBrowser-c-Gs6PZB.cjs → DataBrowser-D8c_pBf4.cjs} +4 -4
  6. package/dist/DataBrowser-D8c_pBf4.cjs.map +1 -0
  7. package/dist/{DataBrowser-DQ3-ZxdV.mjs → DataBrowser-kgcI9ApJ.mjs} +4 -4
  8. package/dist/DataBrowser-kgcI9ApJ.mjs.map +1 -0
  9. package/dist/Studio-CYzz3wD2.d.cts +152 -0
  10. package/dist/Studio-CYzz3wD2.d.cts.map +1 -0
  11. package/dist/Studio-D5yGscb8.d.mts +152 -0
  12. package/dist/Studio-D5yGscb8.d.mts.map +1 -0
  13. package/dist/data/index.cjs +1 -1
  14. package/dist/data/index.d.cts +1 -1
  15. package/dist/data/index.d.mts +1 -1
  16. package/dist/data/index.mjs +1 -1
  17. package/dist/index.cjs +33 -3
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.d.cts +4 -131
  20. package/dist/index.d.mts +4 -131
  21. package/dist/index.mjs +33 -3
  22. package/dist/index.mjs.map +1 -1
  23. package/dist/server/hono.cjs +168 -21
  24. package/dist/server/hono.cjs.map +1 -1
  25. package/dist/server/hono.d.cts +13 -2
  26. package/dist/server/hono.d.cts.map +1 -0
  27. package/dist/server/hono.d.mts +13 -2
  28. package/dist/server/hono.d.mts.map +1 -0
  29. package/dist/server/hono.mjs +168 -21
  30. package/dist/server/hono.mjs.map +1 -1
  31. package/dist/types-BZv87Ikv.mjs.map +1 -1
  32. package/dist/types-CMttUZYk.cjs.map +1 -1
  33. package/package.json +14 -6
  34. package/src/Studio.ts +341 -292
  35. package/src/__tests__/Studio.spec.ts +447 -0
  36. package/src/data/DataBrowser.ts +147 -143
  37. package/src/data/__tests__/DataBrowser.integration.spec.ts +404 -404
  38. package/src/data/__tests__/filtering.integration.spec.ts +726 -726
  39. package/src/data/__tests__/introspection.integration.spec.ts +340 -340
  40. package/src/data/__tests__/pagination.spec.ts +123 -0
  41. package/src/data/filtering.ts +154 -154
  42. package/src/data/introspection.ts +141 -141
  43. package/src/data/pagination.ts +15 -15
  44. package/src/index.ts +22 -24
  45. package/src/server/__tests__/hono.integration.spec.ts +605 -347
  46. package/src/server/hono.ts +392 -190
  47. package/src/types.ts +138 -138
  48. package/src/ui-assets.ts +10 -13
  49. package/tsconfig.json +9 -0
  50. package/tsdown.config.ts +9 -9
  51. package/ui/package.json +28 -22
  52. package/ui/src/App.tsx +95 -235
  53. package/ui/src/api.ts +184 -42
  54. package/ui/src/components/FilterPanel.tsx +198 -198
  55. package/ui/src/components/NavRail.tsx +183 -0
  56. package/ui/src/components/RowDetail.tsx +106 -106
  57. package/ui/src/components/StudioHeader.tsx +109 -0
  58. package/ui/src/components/TableList.tsx +49 -49
  59. package/ui/src/components/TableView.tsx +530 -485
  60. package/ui/src/main.tsx +3 -3
  61. package/ui/src/pages/DashboardPage.tsx +500 -0
  62. package/ui/src/pages/DatabasePage.tsx +226 -0
  63. package/ui/src/pages/EndpointDetailsPage.tsx +288 -0
  64. package/ui/src/pages/ExceptionsPage.tsx +268 -0
  65. package/ui/src/pages/LogsPage.tsx +228 -0
  66. package/ui/src/pages/MonitoringPage.tsx +46 -0
  67. package/ui/src/pages/PerformancePage.tsx +307 -0
  68. package/ui/src/pages/RequestsPage.tsx +379 -0
  69. package/ui/src/providers/StudioProvider.tsx +194 -0
  70. package/ui/src/styles.css +53 -142
  71. package/ui/src/types.ts +154 -30
  72. package/ui/tsconfig.tsbuildinfo +1 -1
  73. package/ui/vite.config.ts +6 -6
  74. package/dist/DataBrowser-DQ3-ZxdV.mjs.map +0 -1
  75. package/dist/DataBrowser-c-Gs6PZB.cjs.map +0 -1
package/ui/src/api.ts CHANGED
@@ -1,71 +1,213 @@
1
1
  import type {
2
- FilterConfig,
3
- QueryResult,
4
- SchemaInfo,
5
- SortConfig,
6
- TableInfo,
7
- TableSummary,
2
+ EndpointDetails,
3
+ EndpointMetrics,
4
+ ExceptionEntry,
5
+ FilterConfig,
6
+ LogEntry,
7
+ QueryResult,
8
+ RequestEntry,
9
+ RequestMetrics,
10
+ SchemaInfo,
11
+ SortConfig,
12
+ StatusDistribution,
13
+ StudioStats,
14
+ TableInfo,
15
+ TableSummary,
8
16
  } from './types';
9
17
 
10
18
  const BASE_URL = '/__studio';
11
19
 
12
20
  async function fetchJson<T>(url: string): Promise<T> {
13
- const res = await fetch(`${BASE_URL}${url}`);
14
- if (!res.ok) {
15
- throw new Error(`HTTP ${res.status}: ${res.statusText}`);
16
- }
17
- return res.json();
21
+ const res = await fetch(`${BASE_URL}${url}`);
22
+ if (!res.ok) {
23
+ throw new Error(`HTTP ${res.status}: ${res.statusText}`);
24
+ }
25
+ return res.json();
18
26
  }
19
27
 
28
+ // ============================================================================
29
+ // Database API
30
+ // ============================================================================
31
+
20
32
  export async function getSchema(refresh = false): Promise<SchemaInfo> {
21
- const url = refresh ? '/api/schema?refresh=true' : '/api/schema';
22
- return fetchJson(url);
33
+ const url = refresh ? '/api/schema?refresh=true' : '/api/schema';
34
+ return fetchJson(url);
23
35
  }
24
36
 
25
37
  export async function getTables(): Promise<{ tables: TableSummary[] }> {
26
- return fetchJson('/api/tables');
38
+ return fetchJson('/api/tables');
27
39
  }
28
40
 
29
41
  export async function getTableInfo(tableName: string): Promise<TableInfo> {
30
- return fetchJson(`/api/tables/${encodeURIComponent(tableName)}`);
42
+ return fetchJson(`/api/tables/${encodeURIComponent(tableName)}`);
31
43
  }
32
44
 
33
45
  export interface QueryOptions {
34
- pageSize?: number;
35
- cursor?: string;
36
- filters?: FilterConfig[];
37
- sort?: SortConfig[];
46
+ pageSize?: number;
47
+ cursor?: string;
48
+ filters?: FilterConfig[];
49
+ sort?: SortConfig[];
38
50
  }
39
51
 
40
52
  export async function queryTable(
41
- tableName: string,
42
- options: QueryOptions = {},
53
+ tableName: string,
54
+ options: QueryOptions = {},
43
55
  ): Promise<QueryResult> {
44
- const params = new URLSearchParams();
56
+ const params = new URLSearchParams();
57
+
58
+ if (options.pageSize) {
59
+ params.set('pageSize', String(options.pageSize));
60
+ }
61
+
62
+ if (options.cursor) {
63
+ params.set('cursor', options.cursor);
64
+ }
65
+
66
+ if (options.filters) {
67
+ for (const filter of options.filters) {
68
+ params.set(`filter[${filter.column}][${filter.operator}]`, filter.value);
69
+ }
70
+ }
71
+
72
+ if (options.sort && options.sort.length > 0) {
73
+ const sortStr = options.sort
74
+ .map((s) => `${s.column}:${s.direction}`)
75
+ .join(',');
76
+ params.set('sort', sortStr);
77
+ }
45
78
 
46
- if (options.pageSize) {
47
- params.set('pageSize', String(options.pageSize));
48
- }
79
+ const queryStr = params.toString();
80
+ const url = `/api/tables/${encodeURIComponent(tableName)}/rows${queryStr ? `?${queryStr}` : ''}`;
49
81
 
50
- if (options.cursor) {
51
- params.set('cursor', options.cursor);
52
- }
82
+ return fetchJson(url);
83
+ }
84
+
85
+ // ============================================================================
86
+ // Monitoring API (from Telescope)
87
+ // ============================================================================
53
88
 
54
- if (options.filters) {
55
- for (const filter of options.filters) {
56
- params.set(`filter[${filter.column}][${filter.operator}]`, filter.value);
57
- }
58
- }
89
+ export async function getStats(): Promise<StudioStats> {
90
+ return fetchJson('/api/stats');
91
+ }
92
+
93
+ export interface MonitoringQueryOptions {
94
+ limit?: number;
95
+ search?: string;
96
+ method?: string;
97
+ status?: string;
98
+ level?: string;
99
+ }
59
100
 
60
- if (options.sort && options.sort.length > 0) {
61
- const sortStr = options.sort
62
- .map((s) => `${s.column}:${s.direction}`)
63
- .join(',');
64
- params.set('sort', sortStr);
65
- }
101
+ export async function getRequests(
102
+ options: MonitoringQueryOptions = {},
103
+ ): Promise<RequestEntry[]> {
104
+ const params = new URLSearchParams();
105
+ if (options.limit) params.set('limit', String(options.limit));
106
+ if (options.search) params.set('search', options.search);
107
+ if (options.method) params.set('method', options.method);
108
+ if (options.status) params.set('status', options.status);
109
+
110
+ const queryStr = params.toString();
111
+ return fetchJson(`/api/requests${queryStr ? `?${queryStr}` : ''}`);
112
+ }
113
+
114
+ export async function getRequest(id: string): Promise<RequestEntry> {
115
+ return fetchJson(`/api/requests/${encodeURIComponent(id)}`);
116
+ }
117
+
118
+ export async function getExceptions(
119
+ options: MonitoringQueryOptions = {},
120
+ ): Promise<ExceptionEntry[]> {
121
+ const params = new URLSearchParams();
122
+ if (options.limit) params.set('limit', String(options.limit));
123
+ if (options.search) params.set('search', options.search);
124
+
125
+ const queryStr = params.toString();
126
+ return fetchJson(`/api/exceptions${queryStr ? `?${queryStr}` : ''}`);
127
+ }
128
+
129
+ export async function getException(id: string): Promise<ExceptionEntry> {
130
+ return fetchJson(`/api/exceptions/${encodeURIComponent(id)}`);
131
+ }
132
+
133
+ export async function getLogs(
134
+ options: MonitoringQueryOptions = {},
135
+ ): Promise<LogEntry[]> {
136
+ const params = new URLSearchParams();
137
+ if (options.limit) params.set('limit', String(options.limit));
138
+ if (options.search) params.set('search', options.search);
139
+ if (options.level) params.set('level', options.level);
140
+
141
+ const queryStr = params.toString();
142
+ return fetchJson(`/api/logs${queryStr ? `?${queryStr}` : ''}`);
143
+ }
144
+
145
+ // ============================================================================
146
+ // Metrics API
147
+ // ============================================================================
148
+
149
+ export interface MetricsQueryOptions {
150
+ start?: string;
151
+ end?: string;
152
+ bucketSize?: number;
153
+ limit?: number;
154
+ }
155
+
156
+ export async function getMetrics(
157
+ options: MetricsQueryOptions = {},
158
+ ): Promise<RequestMetrics> {
159
+ const params = new URLSearchParams();
160
+ if (options.start) params.set('start', options.start);
161
+ if (options.end) params.set('end', options.end);
162
+ if (options.bucketSize) params.set('bucketSize', String(options.bucketSize));
163
+
164
+ const queryStr = params.toString();
165
+ return fetchJson(`/api/metrics${queryStr ? `?${queryStr}` : ''}`);
166
+ }
167
+
168
+ export async function getEndpointMetrics(
169
+ options: MetricsQueryOptions = {},
170
+ ): Promise<EndpointMetrics[]> {
171
+ const params = new URLSearchParams();
172
+ if (options.start) params.set('start', options.start);
173
+ if (options.end) params.set('end', options.end);
174
+ if (options.limit) params.set('limit', String(options.limit));
175
+
176
+ const queryStr = params.toString();
177
+ return fetchJson(`/api/metrics/endpoints${queryStr ? `?${queryStr}` : ''}`);
178
+ }
179
+
180
+ export async function getEndpointDetails(
181
+ method: string,
182
+ path: string,
183
+ options: MetricsQueryOptions = {},
184
+ ): Promise<EndpointDetails> {
185
+ const params = new URLSearchParams();
186
+ params.set('method', method);
187
+ params.set('path', path);
188
+ if (options.start) params.set('start', options.start);
189
+ if (options.end) params.set('end', options.end);
190
+
191
+ return fetchJson(`/api/metrics/endpoint?${params.toString()}`);
192
+ }
193
+
194
+ export async function getStatusDistribution(
195
+ options: MetricsQueryOptions = {},
196
+ ): Promise<StatusDistribution> {
197
+ const params = new URLSearchParams();
198
+ if (options.start) params.set('start', options.start);
199
+ if (options.end) params.set('end', options.end);
200
+
201
+ const queryStr = params.toString();
202
+ return fetchJson(`/api/metrics/status${queryStr ? `?${queryStr}` : ''}`);
203
+ }
66
204
 
67
- const queryStr = params.toString();
68
- const url = `/api/tables/${encodeURIComponent(tableName)}/rows${queryStr ? `?${queryStr}` : ''}`;
205
+ // ============================================================================
206
+ // WebSocket
207
+ // ============================================================================
69
208
 
70
- return fetchJson(url);
209
+ export function createWebSocket(): WebSocket {
210
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
211
+ const wsUrl = `${protocol}//${window.location.host}${BASE_URL}/ws`;
212
+ return new WebSocket(wsUrl);
71
213
  }
@@ -2,212 +2,212 @@ import { useCallback } from 'react';
2
2
  import type { ColumnInfo, FilterConfig } from '../types';
3
3
 
4
4
  interface FilterPanelProps {
5
- columns: ColumnInfo[];
6
- filters: FilterConfig[];
7
- onFiltersChange: (filters: FilterConfig[]) => void;
8
- onClose: () => void;
5
+ columns: ColumnInfo[];
6
+ filters: FilterConfig[];
7
+ onFiltersChange: (filters: FilterConfig[]) => void;
8
+ onClose: () => void;
9
9
  }
10
10
 
11
11
  const OPERATORS: { value: string; label: string; types?: string[] }[] = [
12
- { value: 'eq', label: 'equals' },
13
- { value: 'neq', label: 'not equals' },
14
- { value: 'gt', label: 'greater than', types: ['number', 'date', 'datetime'] },
15
- {
16
- value: 'gte',
17
- label: 'greater than or equal',
18
- types: ['number', 'date', 'datetime'],
19
- },
20
- { value: 'lt', label: 'less than', types: ['number', 'date', 'datetime'] },
21
- {
22
- value: 'lte',
23
- label: 'less than or equal',
24
- types: ['number', 'date', 'datetime'],
25
- },
26
- { value: 'like', label: 'contains', types: ['string'] },
27
- { value: 'ilike', label: 'contains (case insensitive)', types: ['string'] },
28
- { value: 'is_null', label: 'is null' },
29
- { value: 'is_not_null', label: 'is not null' },
12
+ { value: 'eq', label: 'equals' },
13
+ { value: 'neq', label: 'not equals' },
14
+ { value: 'gt', label: 'greater than', types: ['number', 'date', 'datetime'] },
15
+ {
16
+ value: 'gte',
17
+ label: 'greater than or equal',
18
+ types: ['number', 'date', 'datetime'],
19
+ },
20
+ { value: 'lt', label: 'less than', types: ['number', 'date', 'datetime'] },
21
+ {
22
+ value: 'lte',
23
+ label: 'less than or equal',
24
+ types: ['number', 'date', 'datetime'],
25
+ },
26
+ { value: 'like', label: 'contains', types: ['string'] },
27
+ { value: 'ilike', label: 'contains (case insensitive)', types: ['string'] },
28
+ { value: 'is_null', label: 'is null' },
29
+ { value: 'is_not_null', label: 'is not null' },
30
30
  ];
31
31
 
32
32
  function getOperatorsForColumn(column: ColumnInfo) {
33
- return OPERATORS.filter(
34
- (op) => !op.types || op.types.includes(column.type || 'string'),
35
- );
33
+ return OPERATORS.filter(
34
+ (op) => !op.types || op.types.includes(column.type || 'string'),
35
+ );
36
36
  }
37
37
 
38
38
  export function FilterPanel({
39
- columns,
40
- filters,
41
- onFiltersChange,
42
- onClose,
39
+ columns,
40
+ filters,
41
+ onFiltersChange,
42
+ onClose,
43
43
  }: FilterPanelProps) {
44
- const addFilter = useCallback(() => {
45
- const firstColumn = columns[0];
46
- if (!firstColumn) return;
47
-
48
- onFiltersChange([
49
- ...filters,
50
- { column: firstColumn.name, operator: 'eq', value: '' },
51
- ]);
52
- }, [columns, filters, onFiltersChange]);
53
-
54
- const updateFilter = useCallback(
55
- (index: number, updates: Partial<FilterConfig>) => {
56
- const newFilters = [...filters];
57
- newFilters[index] = { ...newFilters[index], ...updates };
58
- onFiltersChange(newFilters);
59
- },
60
- [filters, onFiltersChange],
61
- );
62
-
63
- const removeFilter = useCallback(
64
- (index: number) => {
65
- onFiltersChange(filters.filter((_, i) => i !== index));
66
- },
67
- [filters, onFiltersChange],
68
- );
69
-
70
- const clearAll = useCallback(() => {
71
- onFiltersChange([]);
72
- }, [onFiltersChange]);
73
-
74
- return (
75
- <div className="filter-panel bg-studio-surface border-b border-studio-border p-4">
76
- <div className="flex items-center justify-between mb-3">
77
- <h3 className="text-sm font-medium text-slate-300">Filters</h3>
78
- <div className="flex items-center gap-2">
79
- {filters.length > 0 && (
80
- <button
81
- onClick={clearAll}
82
- className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
83
- >
84
- Clear all
85
- </button>
86
- )}
87
- <button
88
- onClick={onClose}
89
- className="p-1 hover:bg-studio-hover rounded transition-colors"
90
- >
91
- <svg
92
- className="w-4 h-4 text-slate-400"
93
- fill="none"
94
- stroke="currentColor"
95
- viewBox="0 0 24 24"
96
- >
97
- <path
98
- strokeLinecap="round"
99
- strokeLinejoin="round"
100
- strokeWidth={2}
101
- d="M6 18L18 6M6 6l12 12"
102
- />
103
- </svg>
104
- </button>
105
- </div>
106
- </div>
107
-
108
- <div className="space-y-2">
109
- {filters.map((filter, index) => {
110
- const column = columns.find((c) => c.name === filter.column);
111
- const operators = column ? getOperatorsForColumn(column) : OPERATORS;
112
- const needsValue = !['is_null', 'is_not_null'].includes(
113
- filter.operator,
114
- );
115
-
116
- return (
117
- <div key={index} className="flex items-center gap-2">
118
- {index > 0 && (
119
- <span className="text-xs text-slate-500 w-8">and</span>
120
- )}
121
- {index === 0 && (
122
- <span className="text-xs text-slate-500 w-8">Where</span>
123
- )}
124
-
125
- {/* Column select */}
126
- <select
127
- value={filter.column}
128
- onChange={(e) =>
129
- updateFilter(index, { column: e.target.value })
130
- }
131
- className="select flex-1 min-w-0"
132
- >
133
- {columns.map((col) => (
134
- <option key={col.name} value={col.name}>
135
- {col.name}
136
- </option>
137
- ))}
138
- </select>
139
-
140
- {/* Operator select */}
141
- <select
142
- value={filter.operator}
143
- onChange={(e) =>
144
- updateFilter(index, { operator: e.target.value })
145
- }
146
- className="select w-48"
147
- >
148
- {operators.map((op) => (
149
- <option key={op.value} value={op.value}>
150
- {op.label}
151
- </option>
152
- ))}
153
- </select>
154
-
155
- {/* Value input */}
156
- {needsValue && (
157
- <input
158
- type="text"
159
- value={filter.value}
160
- onChange={(e) =>
161
- updateFilter(index, { value: e.target.value })
162
- }
163
- placeholder="Enter value..."
164
- className="input flex-1 min-w-0"
165
- />
166
- )}
167
-
168
- {/* Remove button */}
169
- <button
170
- onClick={() => removeFilter(index)}
171
- className="p-1.5 hover:bg-studio-hover rounded transition-colors text-slate-500 hover:text-slate-300"
172
- >
173
- <svg
174
- className="w-4 h-4"
175
- fill="none"
176
- stroke="currentColor"
177
- viewBox="0 0 24 24"
178
- >
179
- <path
180
- strokeLinecap="round"
181
- strokeLinejoin="round"
182
- strokeWidth={2}
183
- d="M6 18L18 6M6 6l12 12"
184
- />
185
- </svg>
186
- </button>
187
- </div>
188
- );
189
- })}
190
- </div>
191
-
192
- <button
193
- onClick={addFilter}
194
- className="mt-3 flex items-center gap-2 text-sm text-emerald-400 hover:text-emerald-300 transition-colors"
195
- >
196
- <svg
197
- className="w-4 h-4"
198
- fill="none"
199
- stroke="currentColor"
200
- viewBox="0 0 24 24"
201
- >
202
- <path
203
- strokeLinecap="round"
204
- strokeLinejoin="round"
205
- strokeWidth={2}
206
- d="M12 4v16m8-8H4"
207
- />
208
- </svg>
209
- Add filter
210
- </button>
211
- </div>
212
- );
44
+ const addFilter = useCallback(() => {
45
+ const firstColumn = columns[0];
46
+ if (!firstColumn) return;
47
+
48
+ onFiltersChange([
49
+ ...filters,
50
+ { column: firstColumn.name, operator: 'eq', value: '' },
51
+ ]);
52
+ }, [columns, filters, onFiltersChange]);
53
+
54
+ const updateFilter = useCallback(
55
+ (index: number, updates: Partial<FilterConfig>) => {
56
+ const newFilters = [...filters];
57
+ newFilters[index] = { ...newFilters[index], ...updates };
58
+ onFiltersChange(newFilters);
59
+ },
60
+ [filters, onFiltersChange],
61
+ );
62
+
63
+ const removeFilter = useCallback(
64
+ (index: number) => {
65
+ onFiltersChange(filters.filter((_, i) => i !== index));
66
+ },
67
+ [filters, onFiltersChange],
68
+ );
69
+
70
+ const clearAll = useCallback(() => {
71
+ onFiltersChange([]);
72
+ }, [onFiltersChange]);
73
+
74
+ return (
75
+ <div className="filter-panel bg-studio-surface border-b border-studio-border p-4">
76
+ <div className="flex items-center justify-between mb-3">
77
+ <h3 className="text-sm font-medium text-slate-300">Filters</h3>
78
+ <div className="flex items-center gap-2">
79
+ {filters.length > 0 && (
80
+ <button
81
+ onClick={clearAll}
82
+ className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
83
+ >
84
+ Clear all
85
+ </button>
86
+ )}
87
+ <button
88
+ onClick={onClose}
89
+ className="p-1 hover:bg-studio-hover rounded transition-colors"
90
+ >
91
+ <svg
92
+ className="w-4 h-4 text-slate-400"
93
+ fill="none"
94
+ stroke="currentColor"
95
+ viewBox="0 0 24 24"
96
+ >
97
+ <path
98
+ strokeLinecap="round"
99
+ strokeLinejoin="round"
100
+ strokeWidth={2}
101
+ d="M6 18L18 6M6 6l12 12"
102
+ />
103
+ </svg>
104
+ </button>
105
+ </div>
106
+ </div>
107
+
108
+ <div className="space-y-2">
109
+ {filters.map((filter, index) => {
110
+ const column = columns.find((c) => c.name === filter.column);
111
+ const operators = column ? getOperatorsForColumn(column) : OPERATORS;
112
+ const needsValue = !['is_null', 'is_not_null'].includes(
113
+ filter.operator,
114
+ );
115
+
116
+ return (
117
+ <div key={index} className="flex items-center gap-2">
118
+ {index > 0 && (
119
+ <span className="text-xs text-slate-500 w-8">and</span>
120
+ )}
121
+ {index === 0 && (
122
+ <span className="text-xs text-slate-500 w-8">Where</span>
123
+ )}
124
+
125
+ {/* Column select */}
126
+ <select
127
+ value={filter.column}
128
+ onChange={(e) =>
129
+ updateFilter(index, { column: e.target.value })
130
+ }
131
+ className="select flex-1 min-w-0"
132
+ >
133
+ {columns.map((col) => (
134
+ <option key={col.name} value={col.name}>
135
+ {col.name}
136
+ </option>
137
+ ))}
138
+ </select>
139
+
140
+ {/* Operator select */}
141
+ <select
142
+ value={filter.operator}
143
+ onChange={(e) =>
144
+ updateFilter(index, { operator: e.target.value })
145
+ }
146
+ className="select w-48"
147
+ >
148
+ {operators.map((op) => (
149
+ <option key={op.value} value={op.value}>
150
+ {op.label}
151
+ </option>
152
+ ))}
153
+ </select>
154
+
155
+ {/* Value input */}
156
+ {needsValue && (
157
+ <input
158
+ type="text"
159
+ value={filter.value}
160
+ onChange={(e) =>
161
+ updateFilter(index, { value: e.target.value })
162
+ }
163
+ placeholder="Enter value..."
164
+ className="input flex-1 min-w-0"
165
+ />
166
+ )}
167
+
168
+ {/* Remove button */}
169
+ <button
170
+ onClick={() => removeFilter(index)}
171
+ className="p-1.5 hover:bg-studio-hover rounded transition-colors text-slate-500 hover:text-slate-300"
172
+ >
173
+ <svg
174
+ className="w-4 h-4"
175
+ fill="none"
176
+ stroke="currentColor"
177
+ viewBox="0 0 24 24"
178
+ >
179
+ <path
180
+ strokeLinecap="round"
181
+ strokeLinejoin="round"
182
+ strokeWidth={2}
183
+ d="M6 18L18 6M6 6l12 12"
184
+ />
185
+ </svg>
186
+ </button>
187
+ </div>
188
+ );
189
+ })}
190
+ </div>
191
+
192
+ <button
193
+ onClick={addFilter}
194
+ className="mt-3 flex items-center gap-2 text-sm text-emerald-400 hover:text-emerald-300 transition-colors"
195
+ >
196
+ <svg
197
+ className="w-4 h-4"
198
+ fill="none"
199
+ stroke="currentColor"
200
+ viewBox="0 0 24 24"
201
+ >
202
+ <path
203
+ strokeLinecap="round"
204
+ strokeLinejoin="round"
205
+ strokeWidth={2}
206
+ d="M12 4v16m8-8H4"
207
+ />
208
+ </svg>
209
+ Add filter
210
+ </button>
211
+ </div>
212
+ );
213
213
  }