@instantdb/components 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.
Files changed (128) hide show
  1. package/.env +2 -0
  2. package/.turbo/turbo-build.log +18 -0
  3. package/README.md +78 -0
  4. package/app/App.css +38 -0
  5. package/app/App.tsx +61 -0
  6. package/app/index.css +18 -0
  7. package/app/main.tsx +10 -0
  8. package/dist/components/StyleMe.d.ts +15 -0
  9. package/dist/components/StyleMe.d.ts.map +1 -0
  10. package/dist/components/error-boundary.d.ts +17 -0
  11. package/dist/components/error-boundary.d.ts.map +1 -0
  12. package/dist/components/explorer/edit-namespace-dialog.d.ts +14 -0
  13. package/dist/components/explorer/edit-namespace-dialog.d.ts.map +1 -0
  14. package/dist/components/explorer/edit-row-dialog.d.ts +10 -0
  15. package/dist/components/explorer/edit-row-dialog.d.ts.map +1 -0
  16. package/dist/components/explorer/expandable-deleted-attr.d.ts +15 -0
  17. package/dist/components/explorer/expandable-deleted-attr.d.ts.map +1 -0
  18. package/dist/components/explorer/explorer-layout.d.ts +8 -0
  19. package/dist/components/explorer/explorer-layout.d.ts.map +1 -0
  20. package/dist/components/explorer/index.d.ts +44 -0
  21. package/dist/components/explorer/index.d.ts.map +1 -0
  22. package/dist/components/explorer/inner-explorer.d.ts +16 -0
  23. package/dist/components/explorer/inner-explorer.d.ts.map +1 -0
  24. package/dist/components/explorer/new-namespace-dialog.d.ts +10 -0
  25. package/dist/components/explorer/new-namespace-dialog.d.ts.map +1 -0
  26. package/dist/components/explorer/query-inspector.d.ts +11 -0
  27. package/dist/components/explorer/query-inspector.d.ts.map +1 -0
  28. package/dist/components/explorer/recently-deleted.d.ts +36 -0
  29. package/dist/components/explorer/recently-deleted.d.ts.map +1 -0
  30. package/dist/components/explorer/search-input.d.ts +9 -0
  31. package/dist/components/explorer/search-input.d.ts.map +1 -0
  32. package/dist/components/explorer/table-components.d.ts +16 -0
  33. package/dist/components/explorer/table-components.d.ts.map +1 -0
  34. package/dist/components/explorer/view-settings.d.ts +10 -0
  35. package/dist/components/explorer/view-settings.d.ts.map +1 -0
  36. package/dist/components/rosePineDawnTheme.d.ts +13 -0
  37. package/dist/components/rosePineDawnTheme.d.ts.map +1 -0
  38. package/dist/components/select.d.ts +16 -0
  39. package/dist/components/select.d.ts.map +1 -0
  40. package/dist/components/toast.d.ts +4 -0
  41. package/dist/components/toast.d.ts.map +1 -0
  42. package/dist/components/ui.d.ts +336 -0
  43. package/dist/components/ui.d.ts.map +1 -0
  44. package/dist/config.d.ts +14 -0
  45. package/dist/config.d.ts.map +1 -0
  46. package/dist/hooks/explorer.d.ts +29 -0
  47. package/dist/hooks/explorer.d.ts.map +1 -0
  48. package/dist/hooks/useAttrNotes.d.ts +10 -0
  49. package/dist/hooks/useAttrNotes.d.ts.map +1 -0
  50. package/dist/hooks/useClickOutside.d.ts +3 -0
  51. package/dist/hooks/useClickOutside.d.ts.map +1 -0
  52. package/dist/hooks/useColumnVisibility.d.ts +12 -0
  53. package/dist/hooks/useColumnVisibility.d.ts.map +1 -0
  54. package/dist/hooks/useEditBlobConstraints.d.ts +32 -0
  55. package/dist/hooks/useEditBlobConstraints.d.ts.map +1 -0
  56. package/dist/hooks/useExplorerHistory.d.ts +1 -0
  57. package/dist/hooks/useExplorerHistory.d.ts.map +1 -0
  58. package/dist/hooks/useIsOverflow.d.ts +6 -0
  59. package/dist/hooks/useIsOverflow.d.ts.map +1 -0
  60. package/dist/hooks/useLocalStorage.d.ts +2 -0
  61. package/dist/hooks/useLocalStorage.d.ts.map +1 -0
  62. package/dist/hooks/useMonacoJSONSchema.d.ts +3 -0
  63. package/dist/hooks/useMonacoJSONSchema.d.ts.map +1 -0
  64. package/dist/hooks/useStableDB.d.ts +7 -0
  65. package/dist/hooks/useStableDB.d.ts.map +1 -0
  66. package/dist/index.cjs +15 -0
  67. package/dist/index.d.ts +7 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +9270 -0
  70. package/dist/schema.d.ts +5 -0
  71. package/dist/schema.d.ts.map +1 -0
  72. package/dist/style.css +1 -0
  73. package/dist/types.d.ts +241 -0
  74. package/dist/types.d.ts.map +1 -0
  75. package/dist/utils/format.d.ts +2 -0
  76. package/dist/utils/format.d.ts.map +1 -0
  77. package/dist/utils/indexingJobs.d.ts +24 -0
  78. package/dist/utils/indexingJobs.d.ts.map +1 -0
  79. package/dist/utils/parsePermsJSON.d.ts +11 -0
  80. package/dist/utils/parsePermsJSON.d.ts.map +1 -0
  81. package/dist/utils/renames.d.ts +3 -0
  82. package/dist/utils/renames.d.ts.map +1 -0
  83. package/dist/utils/tableWidthSize.d.ts +9 -0
  84. package/dist/utils/tableWidthSize.d.ts.map +1 -0
  85. package/index.html +13 -0
  86. package/package.json +109 -0
  87. package/src/components/StyleMe.tsx +97 -0
  88. package/src/components/error-boundary.tsx +76 -0
  89. package/src/components/explorer/edit-namespace-dialog.tsx +1886 -0
  90. package/src/components/explorer/edit-row-dialog.tsx +1151 -0
  91. package/src/components/explorer/expandable-deleted-attr.tsx +170 -0
  92. package/src/components/explorer/explorer-layout.tsx +156 -0
  93. package/src/components/explorer/index.tsx +217 -0
  94. package/src/components/explorer/inner-explorer.tsx +1341 -0
  95. package/src/components/explorer/new-namespace-dialog.tsx +54 -0
  96. package/src/components/explorer/query-inspector.tsx +394 -0
  97. package/src/components/explorer/recently-deleted.tsx +344 -0
  98. package/src/components/explorer/search-input.tsx +358 -0
  99. package/src/components/explorer/table-components.tsx +341 -0
  100. package/src/components/explorer/view-settings.tsx +75 -0
  101. package/src/components/rosePineDawnTheme.ts +45 -0
  102. package/src/components/select.tsx +198 -0
  103. package/src/components/toast.tsx +18 -0
  104. package/src/components/ui.tsx +1561 -0
  105. package/src/config.ts +61 -0
  106. package/src/hooks/explorer.tsx +125 -0
  107. package/src/hooks/useAttrNotes.ts +27 -0
  108. package/src/hooks/useClickOutside.ts +23 -0
  109. package/src/hooks/useColumnVisibility.ts +39 -0
  110. package/src/hooks/useEditBlobConstraints.ts +185 -0
  111. package/src/hooks/useExplorerHistory.ts +0 -0
  112. package/src/hooks/useIsOverflow.ts +24 -0
  113. package/src/hooks/useLocalStorage.ts +51 -0
  114. package/src/hooks/useMonacoJSONSchema.ts +41 -0
  115. package/src/hooks/useStableDB.ts +30 -0
  116. package/src/index.tsx +8 -0
  117. package/src/schema.ts +285 -0
  118. package/src/style.css +5 -0
  119. package/src/types.ts +359 -0
  120. package/src/utils/format.ts +13 -0
  121. package/src/utils/indexingJobs.ts +126 -0
  122. package/src/utils/parsePermsJSON.ts +35 -0
  123. package/src/utils/renames.ts +42 -0
  124. package/src/utils/tableWidthSize.ts +62 -0
  125. package/tailwind.config.cjs +42 -0
  126. package/tsconfig.json +22 -0
  127. package/vite-env.d.ts +1 -0
  128. package/vite.config.ts +49 -0
@@ -0,0 +1,1341 @@
1
+ import {
2
+ closestCenter,
3
+ DndContext,
4
+ type DragEndEvent,
5
+ KeyboardSensor,
6
+ MouseSensor,
7
+ TouchSensor,
8
+ useSensor,
9
+ useSensors,
10
+ } from '@dnd-kit/core';
11
+ import { restrictToHorizontalAxis } from '@dnd-kit/modifiers';
12
+ import {
13
+ arrayMove,
14
+ horizontalListSortingStrategy,
15
+ SortableContext,
16
+ } from '@dnd-kit/sortable';
17
+ import {
18
+ ArrowUpOnSquareIcon,
19
+ PencilSquareIcon,
20
+ TrashIcon,
21
+ XMarkIcon,
22
+ } from '@heroicons/react/24/outline';
23
+ import { coerceToDate, tx } from '@instantdb/core';
24
+ import { InstantReactAbstractDatabase } from '@instantdb/react';
25
+ import {
26
+ ColumnDef,
27
+ ColumnSizingState,
28
+ getCoreRowModel,
29
+ useReactTable,
30
+ } from '@tanstack/react-table';
31
+ import {
32
+ ArrowLeftIcon,
33
+ ArrowRightIcon,
34
+ CurlyBraces,
35
+ FileDown,
36
+ PlusIcon,
37
+ Table,
38
+ } from 'lucide-react';
39
+ import React, {
40
+ useEffect,
41
+ useLayoutEffect,
42
+ useMemo,
43
+ useRef,
44
+ useState,
45
+ } from 'react';
46
+ import { useExplorerProps, useExplorerState } from '.';
47
+ import { SearchInput } from './search-input';
48
+
49
+ import { errorToast, successToast } from '@lib/components/toast';
50
+ import {
51
+ ActionButton,
52
+ ActionForm,
53
+ Button,
54
+ Checkbox,
55
+ cn,
56
+ Content,
57
+ Dialog,
58
+ DropdownMenu,
59
+ DropdownMenuContent,
60
+ DropdownMenuItem,
61
+ DropdownMenuSeparator,
62
+ DropdownMenuTrigger,
63
+ Fence,
64
+ IconButton,
65
+ Select,
66
+ } from '@lib/components/ui';
67
+ import { SearchFilter, useNamespacesQuery } from '@lib/hooks/explorer';
68
+ import { useColumnVisibility } from '@lib/hooks/useColumnVisibility';
69
+ import { useLocalStorage } from '@lib/hooks/useLocalStorage';
70
+ import { SchemaAttr, SchemaNamespace } from '@lib/types';
71
+ import { formatBytes } from '@lib/utils/format';
72
+ import { getTableWidthSize } from '@lib/utils/tableWidthSize';
73
+ import { ArrowRightFromLine } from 'lucide-react';
74
+ import { TableCell, TableHeader } from './table-components';
75
+ import { ViewSettings } from './view-settings';
76
+
77
+ import { isObject } from 'lodash';
78
+ import { EditNamespaceDialog } from './edit-namespace-dialog';
79
+ import { EditRowDialog } from './edit-row-dialog';
80
+
81
+ const fallbackItems: any[] = [];
82
+
83
+ export type TableColMeta = {
84
+ sortable?: boolean;
85
+ disablePadding: boolean;
86
+ isLink?: boolean;
87
+ attr: SchemaAttr;
88
+ copyable?: boolean;
89
+ };
90
+
91
+ export const InnerExplorer: React.FC<{
92
+ db: InstantReactAbstractDatabase<any, any>;
93
+ namespaces: SchemaNamespace[];
94
+ }> = ({ db, namespaces }) => {
95
+ const { explorerState, history } = useExplorerState();
96
+ const explorerProps = useExplorerProps();
97
+
98
+ const currentNav = explorerState;
99
+ const selectedNamespace = namespaces.find(
100
+ (ns) => ns.id === currentNav.namespace,
101
+ );
102
+
103
+ const [limit, setLimit] = useState(50);
104
+ const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
105
+ const [uploadingFile, setUploadingFile] = useState(false);
106
+ const [customPath, setCustomPath] = useState('');
107
+ const [deleteDataConfirmationOpen, setDeleteDataConfirmationOpen] =
108
+ useState(false);
109
+ const [editNs, setEditNs] = useState<SchemaNamespace | null>(null);
110
+ const [editableRowId, setEditableRowId] = useState<string | null>(null);
111
+ const [addItemDialogOpen, setAddItemDialogOpen] = useState(false);
112
+ const nsRef = useRef<HTMLDivElement>(null);
113
+ const lastSelectedIdRef = useRef<string | null>(null);
114
+ const [offsets, setOffsets] = useState<{ [namespace: string]: number }>({});
115
+
116
+ const fileInputRef = useRef<HTMLInputElement>(null);
117
+ const isSystemCatalogNs = selectedNamespace?.name?.startsWith('$') ?? false;
118
+ const sanitizedNsName = selectedNamespace?.name ?? '';
119
+ const readOnlyNs =
120
+ isSystemCatalogNs && !['$users', '$files'].includes(sanitizedNsName);
121
+ const offset = offsets[sanitizedNsName] || 0;
122
+
123
+ const sortAttr = currentNav?.sortAttr || 'serverCreatedAt';
124
+ const sortAsc = currentNav?.sortAsc ?? true;
125
+
126
+ const handleRangeSelection = (currentId: string, checked: boolean) => {
127
+ const allItemIds = table.options.data.map((i) => i.id as string);
128
+ const currentIndex = allItemIds.indexOf(currentId);
129
+ const lastSelectedIndex = allItemIds.indexOf(lastSelectedIdRef.current!);
130
+ const [start, end] = [
131
+ Math.min(currentIndex, lastSelectedIndex),
132
+ Math.max(currentIndex, lastSelectedIndex),
133
+ ];
134
+
135
+ setCheckedIds((prev) => {
136
+ const newCheckedIds = { ...prev };
137
+ for (let i = start; i <= end; i++) {
138
+ const id = allItemIds[i];
139
+ if (checked) {
140
+ newCheckedIds[id] = true;
141
+ } else {
142
+ delete newCheckedIds[id];
143
+ }
144
+ }
145
+ return newCheckedIds;
146
+ });
147
+ };
148
+
149
+ const [searchFilters, setSearchFilters] = useState<SearchFilter[]>([]);
150
+
151
+ const { itemsRes, allCount } = useNamespacesQuery(
152
+ db,
153
+ selectedNamespace,
154
+ currentNav?.where,
155
+ currentNav?.filters || searchFilters,
156
+ limit,
157
+ offset,
158
+ sortAttr,
159
+ sortAsc,
160
+ );
161
+
162
+ const allItems =
163
+ itemsRes.data?.[selectedNamespace?.name ?? ''] ?? fallbackItems;
164
+
165
+ function getSelectedRows(
166
+ allItems: ({ id: string } & Record<string, any>)[],
167
+ checkedIds: Record<string, true | false>,
168
+ ) {
169
+ return allItems.filter((item) => checkedIds[item.id]);
170
+ }
171
+
172
+ const numPages = allCount ? Math.ceil(allCount / limit) : 1;
173
+
174
+ const currentPage = offset / limit + 1;
175
+
176
+ const [localDates, setLocalDates] = useLocalStorage('localDates', false);
177
+
178
+ const handleUploadFile = async () => {
179
+ try {
180
+ setUploadingFile(true);
181
+ if (selectedFiles.length === 0) {
182
+ return;
183
+ }
184
+
185
+ const [file] = selectedFiles;
186
+ const success = await upload(
187
+ explorerProps.adminToken,
188
+ explorerProps.appId,
189
+ file,
190
+ customPath,
191
+ explorerProps.apiURI,
192
+ );
193
+
194
+ if (success) {
195
+ setSelectedFiles([]);
196
+ setCustomPath('');
197
+ fileInputRef.current && (fileInputRef.current.value = '');
198
+ }
199
+
200
+ // await refreshFiles();
201
+ successToast('Successfully uploaded!');
202
+ } catch (err: any) {
203
+ console.error('Failed to upload:', err);
204
+ errorToast(`Failed to upload: ${err.body.message}`);
205
+ } finally {
206
+ setUploadingFile(false);
207
+ }
208
+ };
209
+
210
+ const tableItems = useMemo(() => {
211
+ return allItems;
212
+ }, [allItems]);
213
+
214
+ const tableRef = useRef<HTMLDivElement>(null);
215
+ const [leftShadowOpacity, setLeftShadowOpacity] = useState(0);
216
+ const [rightShadowOpacity, setRightShadowOpacity] = useState(1);
217
+ const [tableSmallerThanViewport, setTableSmallerThanViewport] =
218
+ useState(false);
219
+
220
+ const setMinViableColWidth = (columnId: string) => {
221
+ // for some reason the id column wants to resize bigger
222
+ if (table?.getColumn(columnId)?.columnDef.header === 'id') {
223
+ setColumnWidth(columnId, 285);
224
+ return;
225
+ }
226
+ const size = getTableWidthSize(columnId, 800);
227
+ setColumnWidth(columnId, size);
228
+ };
229
+
230
+ const setColumnWidth = (columnId: string, width = 200) => {
231
+ if (!selectedNamespace) {
232
+ return;
233
+ }
234
+ const result: Record<string, number> = {};
235
+ selectedNamespace?.attrs.forEach((attr) => {
236
+ result[attr.id + attr.name] =
237
+ table.getColumn(attr.id + attr.name)?.getSize() || 0;
238
+ });
239
+ table.setColumnSizing({
240
+ ...result,
241
+ [columnId]: width,
242
+ });
243
+ };
244
+
245
+ const columns = useMemo(() => {
246
+ const result: ColumnDef<any>[] = [];
247
+
248
+ result.push({
249
+ id: 'select-col',
250
+ enableSorting: false,
251
+ accessorFn: () => null,
252
+ enableHiding: false,
253
+ enableResizing: false,
254
+ size: 52,
255
+ header: ({ table }) => {
256
+ return (
257
+ <Checkbox
258
+ className="relative z-10 text-[#2563EB] dark:checked:border-[#2563EB] dark:checked:bg-[#2563EB]"
259
+ style={{
260
+ pointerEvents: 'auto',
261
+ }}
262
+ checked={table.getIsAllRowsSelected()}
263
+ onChange={(checked) => {
264
+ if (checked) {
265
+ table.toggleAllRowsSelected();
266
+ // Use the first item as the last selected ID
267
+ if (allItems.length > 0) {
268
+ lastSelectedIdRef.current = allItems[0].id as string;
269
+ }
270
+ } else {
271
+ setCheckedIds({});
272
+ lastSelectedIdRef.current = null;
273
+ }
274
+ }}
275
+ />
276
+ );
277
+ },
278
+ cell: ({ row, column }) => {
279
+ return (
280
+ <div className="flex h-1 justify-around gap-2">
281
+ <Checkbox
282
+ className="relative z-10 text-[#2563EB] dark:checked:border-[#2563EB] dark:checked:bg-[#2563EB]"
283
+ checked={row.getIsSelected()}
284
+ onChange={(_, e) => {
285
+ const isShiftPressed = e.nativeEvent
286
+ ? (e.nativeEvent as MouseEvent).shiftKey
287
+ : false;
288
+
289
+ if (isShiftPressed && lastSelectedIdRef.current) {
290
+ handleRangeSelection(row.id as string, e.target.checked);
291
+ } else {
292
+ // Regular single click selection
293
+ setCheckedIds((prev) => {
294
+ const newCheckedIds = { ...prev };
295
+ if (e.target.checked) {
296
+ newCheckedIds[row.id] = true;
297
+ } else {
298
+ delete newCheckedIds[row.id];
299
+ }
300
+ return newCheckedIds;
301
+ });
302
+ }
303
+
304
+ lastSelectedIdRef.current = row.id;
305
+ }}
306
+ />
307
+ {readOnlyNs ? null : (
308
+ <button
309
+ className="translate-y-0.5 opacity-0 transition-opacity group-hover:opacity-100"
310
+ onClick={() => setEditableRowId(row.id)}
311
+ >
312
+ <PencilSquareIcon className="h-4 w-4 text-neutral-500 dark:text-neutral-400" />
313
+ </button>
314
+ )}
315
+ </div>
316
+ );
317
+ },
318
+ });
319
+
320
+ selectedNamespace?.attrs?.forEach((attr) => {
321
+ result.push({
322
+ id: attr.id + attr.name,
323
+ header: attr.name,
324
+ enableSorting: true,
325
+ enableResizing: true,
326
+ accessorFn: (row) => row[attr.name],
327
+ meta: {
328
+ sortable: attr.sortable || attr.name === 'id',
329
+ copyable: true,
330
+ isLink: attr.type === 'ref',
331
+ attr,
332
+ disablePadding: attr.namespace === '$files' && attr.name === 'url',
333
+ } satisfies TableColMeta,
334
+ cell: (info) => {
335
+ if (
336
+ info.row.original[attr.name] === null ||
337
+ info.row.original[attr.name] === undefined
338
+ ) {
339
+ return <div className="h-1">-</div>;
340
+ }
341
+ if (attr.type === 'ref') {
342
+ const linkCount = info.row.original[attr.name].length;
343
+ return (
344
+ <div
345
+ className={cn(
346
+ 'h-1 translate-y-0.5',
347
+ linkCount < 1 && 'opacity-50',
348
+ )}
349
+ >
350
+ {linkCount} link{linkCount === 1 ? '' : 's'}
351
+ </div>
352
+ );
353
+ }
354
+
355
+ if (attr.namespace === '$files') {
356
+ if (attr.name === 'url') {
357
+ return (
358
+ <a
359
+ className="h-full w-full pl-2 align-middle text-xs font-bold underline hover:text-black dark:hover:text-white"
360
+ href={info.row.original['url'] as string}
361
+ target="_blank"
362
+ >
363
+ View File
364
+ </a>
365
+ );
366
+ } else if (attr.name === 'size') {
367
+ return formatBytes(info.row.original[attr.name]);
368
+ }
369
+ }
370
+
371
+ if (attr.checkedDataType === 'boolean') {
372
+ return info.row.original[attr.name] ? 'true' : 'false';
373
+ }
374
+ if (attr.checkedDataType === 'date') {
375
+ const coerced = coerceToDate(info.row.original[attr.name]);
376
+
377
+ if (localDates) {
378
+ return coerced?.toLocaleString() || info.row.original[attr.name];
379
+ } else {
380
+ return info.row.original[attr.name];
381
+ }
382
+ }
383
+ if (isObject(info.row.original[attr.name])) {
384
+ return <Val data={info.row.original[attr.name]}></Val>;
385
+ }
386
+ return info.row.original[attr.name];
387
+ },
388
+ });
389
+ });
390
+
391
+ return result;
392
+ }, [selectedNamespace?.attrs, localDates]);
393
+
394
+ const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
395
+
396
+ const distributeRemainingWidth = () => {
397
+ const result: Record<string, number> = table.getState().columnSizing;
398
+
399
+ const fullWidth = tableRef.current?.clientWidth || -1;
400
+
401
+ const totalWidth = Object.values(result).reduce(
402
+ (acc, width) => acc + width,
403
+ 0,
404
+ );
405
+ const remainingWidth = fullWidth - 52 - totalWidth;
406
+
407
+ if (remainingWidth > 0) {
408
+ const numColumns = Object.keys(result).length;
409
+ const extraWidth = remainingWidth / numColumns;
410
+
411
+ Object.keys(result).forEach((key) => {
412
+ result[key] += extraWidth;
413
+ });
414
+ }
415
+ setTableSmallerThanViewport(false);
416
+ table.setColumnSizing(() => {
417
+ return { ...result };
418
+ });
419
+ };
420
+
421
+ const columnResizeMode = 'onChange';
422
+
423
+ const columnResizeDirection = 'ltr';
424
+
425
+ const colVisiblity = useColumnVisibility({
426
+ appId: explorerProps.appId,
427
+ attrs: selectedNamespace?.attrs,
428
+ namespaceId: selectedNamespace?.id,
429
+ });
430
+
431
+ const [columnOrder, setColumnOrder] = useState<string[]>(() =>
432
+ columns.map((c) => c.id!),
433
+ );
434
+
435
+ // Sync columnOrder when namespace changes
436
+ useLayoutEffect(() => {
437
+ if (selectedNamespace?.attrs) {
438
+ const savedOrder = localStorage.getItem(
439
+ `order-${selectedNamespace.id}-${explorerProps.appId}`,
440
+ );
441
+ if (savedOrder) {
442
+ setColumnOrder(JSON.parse(savedOrder));
443
+ } else {
444
+ const newOrder = selectedNamespace.attrs.map(
445
+ (attr) => attr.id + attr.name,
446
+ );
447
+ setColumnOrder(['select-col', ...newOrder]);
448
+ }
449
+ }
450
+ }, [selectedNamespace?.attrs]);
451
+
452
+ // Persist columnOrder to localStorage when it changes
453
+ useEffect(() => {
454
+ if (selectedNamespace?.id) {
455
+ localStorage.setItem(
456
+ `order-${selectedNamespace.id}-${explorerProps.appId}`,
457
+ JSON.stringify(columnOrder),
458
+ );
459
+ }
460
+ }, [columnOrder, selectedNamespace?.id]);
461
+
462
+ const [checkedIds, setCheckedIds] = useState<Record<string, true | false>>(
463
+ {},
464
+ );
465
+
466
+ // Clear selection when namespace changes
467
+ useEffect(() => {
468
+ setCheckedIds({});
469
+ lastSelectedIdRef.current = null;
470
+ }, [selectedNamespace?.id]);
471
+
472
+ const numItemsSelected = Object.keys(checkedIds).length;
473
+ const rowText =
474
+ sanitizedNsName === '$files'
475
+ ? numItemsSelected === 1
476
+ ? 'file'
477
+ : 'files'
478
+ : numItemsSelected === 1
479
+ ? 'row'
480
+ : 'rows';
481
+
482
+ const table = useReactTable({
483
+ columnResizeDirection,
484
+ columnResizeMode,
485
+ onColumnVisibilityChange: colVisiblity.setVisibility,
486
+ columns: columns,
487
+ data: tableItems,
488
+ enableColumnResizing: true,
489
+ enableRowSelection: true,
490
+ getCoreRowModel: getCoreRowModel(),
491
+ getRowId: (row) => row.id,
492
+ onColumnOrderChange: setColumnOrder,
493
+ onRowSelectionChange: setCheckedIds,
494
+ onColumnSizingChange: setColumnSizing,
495
+ state: {
496
+ columnSizing: columnSizing,
497
+ columnOrder,
498
+ columnVisibility: colVisiblity.visibility,
499
+ rowSelection: checkedIds,
500
+ },
501
+ });
502
+
503
+ const [isShiftPressed, setIsShiftPressed] = useState(false);
504
+
505
+ useEffect(() => {
506
+ const handleKeyDown = (e: KeyboardEvent) => {
507
+ if (e.key === 'Shift') {
508
+ setIsShiftPressed(true);
509
+ }
510
+ };
511
+
512
+ const handleKeyUp = (e: KeyboardEvent) => {
513
+ if (e.key === 'Shift') {
514
+ setIsShiftPressed(false);
515
+ }
516
+ };
517
+
518
+ const handleWindowBlur = () => {
519
+ setIsShiftPressed(false);
520
+ };
521
+
522
+ window.addEventListener('keydown', handleKeyDown);
523
+ window.addEventListener('keyup', handleKeyUp);
524
+ window.addEventListener('blur', handleWindowBlur);
525
+
526
+ return () => {
527
+ window.removeEventListener('keydown', handleKeyDown);
528
+ window.removeEventListener('keyup', handleKeyUp);
529
+ window.removeEventListener('blur-sm', handleWindowBlur);
530
+ };
531
+ }, []);
532
+
533
+ const [dropdownOpen, setDropdownOpen] = useState(false);
534
+
535
+ function handleDragEnd(event: DragEndEvent) {
536
+ const { active, over } = event;
537
+ // Prevent dragging the select column or dragging over it
538
+ if (
539
+ active &&
540
+ over &&
541
+ active.id !== over.id &&
542
+ active.id !== 'select-col' &&
543
+ over.id !== 'select-col'
544
+ ) {
545
+ setColumnOrder((columnOrder) => {
546
+ const oldIndex = columnOrder.indexOf(active.id as string);
547
+ const newIndex = columnOrder.indexOf(over.id as string);
548
+ return arrayMove(columnOrder, oldIndex, newIndex); //this is just a splice util
549
+ });
550
+ }
551
+ }
552
+
553
+ const selectedEditableItem = useMemo(
554
+ () => allItems.find((i) => i.id === editableRowId),
555
+ [allItems.length, editableRowId],
556
+ );
557
+
558
+ // Handle scroll to update shadow opacity
559
+ useEffect(() => {
560
+ const tableElement = tableRef.current;
561
+ if (!tableElement) return;
562
+
563
+ const handleScroll = () => {
564
+ const tableWidth = table.getCenterTotalSize();
565
+ const viewportWidth = tableElement.clientWidth;
566
+
567
+ setTableSmallerThanViewport(tableWidth < viewportWidth - 5);
568
+
569
+ const { scrollLeft, scrollWidth, clientWidth } = tableElement;
570
+ const maxScroll = scrollWidth - clientWidth;
571
+ if (maxScroll <= 0) {
572
+ setLeftShadowOpacity(0);
573
+ setRightShadowOpacity(0);
574
+ return;
575
+ }
576
+ const leftOpacity = Math.min(scrollLeft / 30, 1);
577
+ setLeftShadowOpacity(leftOpacity);
578
+
579
+ const rightOpacity = Math.min((maxScroll - scrollLeft) / 30, 1);
580
+ setRightShadowOpacity(rightOpacity);
581
+ };
582
+
583
+ handleScroll();
584
+ tableElement.addEventListener('scroll', handleScroll);
585
+
586
+ const resizeObserver = new ResizeObserver(handleScroll);
587
+ resizeObserver.observe(tableElement);
588
+ const tableContent = tableElement.firstElementChild;
589
+ if (tableContent) {
590
+ resizeObserver.observe(tableContent);
591
+ }
592
+
593
+ window.addEventListener('resize', handleScroll);
594
+
595
+ return () => {
596
+ tableElement.removeEventListener('scroll', handleScroll);
597
+ resizeObserver.disconnect();
598
+ window.removeEventListener('resize', handleScroll);
599
+ };
600
+ }, [selectedNamespace, tableItems]);
601
+
602
+ const sensors = useSensors(
603
+ useSensor(MouseSensor, {}),
604
+ useSensor(TouchSensor, {}),
605
+ useSensor(KeyboardSensor, {}),
606
+ );
607
+
608
+ // history.items is a list of PAST histories, not including current
609
+ const showBackButton = history.items.length >= 1;
610
+
611
+ const transformAttrNameToWidth = (name: string) => {
612
+ if (name === 'id') {
613
+ return 140;
614
+ }
615
+ if (name === 'url') {
616
+ return 120;
617
+ }
618
+ return name.length * 7.2 + 50;
619
+ };
620
+ // evenly space width of columns on first render
621
+ useLayoutEffect(() => {
622
+ if (selectedNamespace?.id) {
623
+ if (
624
+ localStorage.getItem(
625
+ `$sizing-${selectedNamespace.id}-${explorerProps.appId}`,
626
+ )
627
+ ) {
628
+ const savedSizing = JSON.parse(
629
+ localStorage.getItem(
630
+ `sizing-${selectedNamespace.id}-${explorerProps.appId}`,
631
+ ) || '{}',
632
+ );
633
+ table.setColumnSizing(() => {
634
+ return { ...savedSizing };
635
+ });
636
+ return;
637
+ }
638
+
639
+ const fullWidth = tableRef.current?.clientWidth || -1;
640
+ const result: Record<string, number> = {};
641
+
642
+ selectedNamespace?.attrs.forEach((attr) => {
643
+ result[attr.id + attr.name] = transformAttrNameToWidth(attr.name);
644
+ });
645
+
646
+ const totalWidth = Object.values(result).reduce(
647
+ (acc, width) => acc + width,
648
+ 0,
649
+ );
650
+
651
+ // Distribute the remaining width equally
652
+ const remainingWidth = fullWidth - 52 - totalWidth;
653
+ if (remainingWidth > 0) {
654
+ const numColumns = Object.keys(result).length;
655
+ const extraWidth = remainingWidth / numColumns;
656
+
657
+ Object.keys(result).forEach((key) => {
658
+ result[key] += extraWidth;
659
+ });
660
+ }
661
+
662
+ table.setColumnSizing(result);
663
+ }
664
+ }, [tableRef.current, selectedNamespace]);
665
+
666
+ if (!selectedNamespace) {
667
+ return null;
668
+ }
669
+
670
+ const selectedNamespaceId = selectedNamespace.id;
671
+
672
+ return (
673
+ <>
674
+ <Dialog
675
+ open={deleteDataConfirmationOpen}
676
+ onClose={() => setDeleteDataConfirmationOpen(false)}
677
+ >
678
+ {selectedNamespace ? (
679
+ <ActionForm className="min flex flex-col gap-4">
680
+ <h5 className="flex text-lg font-bold">Delete {rowText}</h5>
681
+
682
+ <Content>
683
+ Deleting is an{' '}
684
+ <strong className="dark:text-white">
685
+ irreversible operation
686
+ </strong>{' '}
687
+ and will{' '}
688
+ <strong className="dark:text-white">
689
+ delete {numItemsSelected} {rowText}{' '}
690
+ </strong>
691
+ associated with{' '}
692
+ <strong className="dark:text-white">
693
+ {selectedNamespace.name}
694
+ </strong>
695
+ .
696
+ </Content>
697
+
698
+ <ActionButton
699
+ type="submit"
700
+ disabled={readOnlyNs}
701
+ label={`Delete ${rowText}`}
702
+ submitLabel={`Deleting ${rowText}...`}
703
+ errorMessage={`Failed to delete ${rowText}`}
704
+ className="border-red-500 text-red-500"
705
+ title={
706
+ readOnlyNs
707
+ ? `The ${selectedNamespace?.name} namespace is read-only.`
708
+ : undefined
709
+ }
710
+ onClick={async () => {
711
+ try {
712
+ if (selectedNamespace.name === '$files') {
713
+ const filenames = allItems
714
+ .filter((i) => i.id in checkedIds)
715
+ .map((i) => i.path as string);
716
+ await bulkDeleteFiles(
717
+ explorerProps.adminToken,
718
+ explorerProps.appId,
719
+ filenames,
720
+ explorerProps.apiURI,
721
+ );
722
+ } else {
723
+ await db.transact(
724
+ Object.keys(checkedIds).map((id) =>
725
+ tx[selectedNamespace.name][id].delete(),
726
+ ),
727
+ );
728
+ }
729
+ } catch (error: any) {
730
+ const errorMessage = error.message;
731
+ errorToast(
732
+ `Failed to delete ${rowText}${errorMessage ? `: ${errorMessage}` : ''}`,
733
+ );
734
+ return;
735
+ }
736
+
737
+ setCheckedIds({});
738
+ setDeleteDataConfirmationOpen(false);
739
+ }}
740
+ />
741
+ </ActionForm>
742
+ ) : null}
743
+ </Dialog>
744
+ <Dialog
745
+ open={addItemDialogOpen}
746
+ onClose={() => setAddItemDialogOpen(false)}
747
+ >
748
+ {selectedNamespace ? (
749
+ <EditRowDialog
750
+ db={db}
751
+ item={{}}
752
+ namespace={selectedNamespace}
753
+ onClose={() => setAddItemDialogOpen(false)}
754
+ />
755
+ ) : null}
756
+ </Dialog>
757
+ <Dialog
758
+ open={!!selectedEditableItem}
759
+ onClose={() => setEditableRowId(null)}
760
+ >
761
+ {!!selectedNamespace && !!selectedEditableItem ? (
762
+ <EditRowDialog
763
+ db={db}
764
+ namespace={selectedNamespace}
765
+ item={selectedEditableItem}
766
+ onClose={() => setEditableRowId(null)}
767
+ />
768
+ ) : null}
769
+ </Dialog>
770
+ <Dialog
771
+ stopFocusPropagation={true}
772
+ open={Boolean(editNs)}
773
+ onClose={() => setEditNs(null)}
774
+ >
775
+ {selectedNamespace ? (
776
+ <EditNamespaceDialog
777
+ readOnly={readOnlyNs}
778
+ isSystemCatalogNs={isSystemCatalogNs}
779
+ db={db}
780
+ namespace={selectedNamespace}
781
+ namespaces={namespaces ?? []}
782
+ onClose={(p) => {
783
+ setEditNs(null);
784
+ if (p?.ok) {
785
+ history.push({ namespace: namespaces?.[0].id });
786
+ }
787
+ }}
788
+ />
789
+ ) : null}
790
+ </Dialog>
791
+ <div className="flex flex-1 grow flex-col overflow-hidden bg-white dark:bg-neutral-800">
792
+ <div className="flex items-center overflow-hidden border-b border-b-gray-200 dark:border-neutral-700">
793
+ <div className="flex flex-1 flex-col justify-between py-2 md:flex-row md:items-center">
794
+ <div className="flex items-center overflow-hidden border-b px-2 py-1 pl-4 md:border-b-0 dark:border-neutral-700">
795
+ {showBackButton ? (
796
+ <ArrowLeftIcon
797
+ className="mr-4 inline cursor-pointer"
798
+ height="1rem"
799
+ onClick={() => history.pop()}
800
+ />
801
+ ) : null}
802
+ {currentNav.where ? (
803
+ <XMarkIcon
804
+ className="mr-4 inline cursor-pointer"
805
+ height="1rem"
806
+ onClick={() => {
807
+ history.push(
808
+ {
809
+ namespace: selectedNamespace.id,
810
+ },
811
+ true,
812
+ );
813
+ }}
814
+ />
815
+ ) : null}
816
+ <div className="text-ellipses shrink truncate overflow-hidden font-mono text-xs whitespace-nowrap dark:text-white">
817
+ <strong>{selectedNamespace.name}</strong>{' '}
818
+ {currentNav.where ? (
819
+ <>
820
+ {' '}
821
+ where <strong>{currentNav.where[0]}</strong> ={' '}
822
+ <em className="rounded-xs border bg-white px-1 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white">
823
+ {JSON.stringify(currentNav.where[1])}
824
+ </em>
825
+ </>
826
+ ) : null}
827
+ {currentNav?.filters?.length ? (
828
+ <span
829
+ title={currentNav.filters
830
+ .map(([attr, op, search]) => `${attr} ${op} ${search}`)
831
+ .join(' || ')}
832
+ >
833
+ {currentNav.filters.map(([attr, op, search], i) => (
834
+ <span key={attr}>
835
+ <em className="rounded-xs border bg-white px-1 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white">
836
+ {attr} {op} {search}
837
+ </em>
838
+ {currentNav?.filters?.length &&
839
+ i < currentNav.filters.length - 1
840
+ ? ' || '
841
+ : null}
842
+ </span>
843
+ ))}
844
+ </span>
845
+ ) : null}
846
+ </div>
847
+ </div>
848
+ <div className="flex justify-between gap-2 px-2 py-1 md:justify-start">
849
+ <Button
850
+ className="rounded-sm dark:bg-neutral-700/50"
851
+ variant="secondary"
852
+ size="mini"
853
+ onClick={() => {
854
+ setEditNs(selectedNamespace);
855
+ }}
856
+ >
857
+ Edit Schema
858
+ </Button>
859
+ <SearchInput
860
+ key={selectedNamespaceId}
861
+ onSearchChange={(filters) => setSearchFilters(filters)}
862
+ attrs={selectedNamespace?.attrs}
863
+ initialFilters={currentNav?.filters || []}
864
+ />
865
+ </div>
866
+ </div>
867
+ </div>
868
+ {selectedNamespace.name === '$files' ? (
869
+ <div className="flex gap-2 px-2 py-2">
870
+ <div className="flex w-full gap-2">
871
+ <div className="flex shrink-0 gap-2">
872
+ <input
873
+ ref={fileInputRef}
874
+ type="file"
875
+ className="flex cursor-pointer rounded-sm border border-neutral-200 bg-transparent px-1 pt-1.5 text-sm shadow-xs transition-colors file:rounded-xs file:border-none file:border-neutral-200 file:bg-transparent file:p-2 file:pt-1 file:text-sm file:font-medium file:shadow-none placeholder:text-neutral-500 focus-visible:ring-1 focus-visible:ring-neutral-950 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white dark:file:border-neutral-700 dark:file:text-white dark:placeholder:text-neutral-400 dark:focus-visible:ring-neutral-400"
876
+ onChange={(e: React.ChangeEvent<any>) => {
877
+ const files = e.target.files;
878
+ setSelectedFiles(files);
879
+ if (files?.[0]) {
880
+ setCustomPath(files[0].name);
881
+ }
882
+ }}
883
+ />
884
+ <Button
885
+ variant="primary"
886
+ disabled={selectedFiles.length === 0}
887
+ size="mini"
888
+ loading={uploadingFile}
889
+ onClick={handleUploadFile}
890
+ className="rounded-sm"
891
+ >
892
+ {uploadingFile ? 'Uploading...' : 'Upload file'}
893
+ </Button>
894
+ </div>
895
+ <div className="relative flex max-w-[67vw] min-w-0 flex-1 rounded-sm border border-neutral-200 focus-within:ring-2 focus-within:ring-blue-700 dark:border-neutral-700 dark:focus-within:ring-blue-500">
896
+ <span className="absolute inset-y-0 left-0 flex items-center rounded-l bg-neutral-100 px-3 text-sm text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400">
897
+ File Path:
898
+ </span>
899
+ <input
900
+ type="text"
901
+ placeholder="Enter a custom path (optional)"
902
+ value={customPath}
903
+ onChange={(e) => setCustomPath(e.target.value)}
904
+ className="h-9 w-full rounded-sm border-0 bg-transparent py-1 pr-3 pl-24 text-sm ring-0 placeholder:text-neutral-500 focus:outline-none dark:bg-neutral-800 dark:text-white dark:placeholder:text-neutral-400"
905
+ />
906
+ </div>
907
+ </div>
908
+ </div>
909
+ ) : null}
910
+ <div className="flex items-center justify-start space-x-2 border-b border-b-gray-200 p-1 text-xs dark:border-neutral-700 dark:text-white">
911
+ {selectedNamespace.name !== '$files' ? (
912
+ <Button
913
+ disabled={readOnlyNs}
914
+ title={
915
+ readOnlyNs
916
+ ? `The ${selectedNamespace?.name} namespace is read-only.`
917
+ : undefined
918
+ }
919
+ size="mini"
920
+ variant="secondary"
921
+ onClick={() => {
922
+ setAddItemDialogOpen(true);
923
+ }}
924
+ >
925
+ <PlusIcon width={12} />
926
+ Add row
927
+ </Button>
928
+ ) : null}
929
+ <div
930
+ className={cn(
931
+ 'px-1',
932
+ selectedNamespace.name === '$files' && 'pb-1',
933
+ )}
934
+ >
935
+ <Select
936
+ className="rounded-sm text-xs"
937
+ onChange={(opt) => {
938
+ if (!opt) return;
939
+
940
+ const newLimit = parseInt(opt.value, 10);
941
+ setLimit(newLimit);
942
+ history.push((prev) => ({
943
+ ...prev,
944
+ limit: newLimit,
945
+ }));
946
+ }}
947
+ value={`${limit}`}
948
+ options={[
949
+ { label: '25/page', value: '25' },
950
+ { label: '50/page', value: '50' },
951
+ { label: '100/page', value: '100' },
952
+ ]}
953
+ />
954
+ </div>
955
+ <div className="w-[62px]">
956
+ {allCount !== undefined &&
957
+ (allCount === 0 ? (
958
+ <>No Results</>
959
+ ) : (
960
+ <>
961
+ {(currentPage - 1) * limit + 1} -{' '}
962
+ {Math.min(allCount, currentPage * limit)} of {allCount}
963
+ </>
964
+ ))}
965
+ </div>
966
+ <button
967
+ className="flex items-center justify-center"
968
+ disabled={currentPage <= 1}
969
+ onClick={() => {
970
+ setOffsets({
971
+ ...offsets,
972
+ [selectedNamespace.name]: Math.max(0, offset - limit),
973
+ });
974
+ history.push((prev) => ({
975
+ ...prev,
976
+ page: Math.max(1, currentPage - 1),
977
+ }));
978
+ }}
979
+ >
980
+ <ArrowLeftIcon
981
+ className={cn('inline', {
982
+ 'opacity-40': currentPage <= 1,
983
+ })}
984
+ height="1rem"
985
+ />
986
+ </button>
987
+ <div className="flex items-center space-x-1 overflow-hidden">
988
+ {[...new Array(numPages)].map((_, i) => {
989
+ const page = i + 1;
990
+ if (
991
+ numPages > 6 &&
992
+ page !== 1 &&
993
+ page !== numPages &&
994
+ page !== currentPage &&
995
+ page !== currentPage - 1 &&
996
+ page !== currentPage + 1
997
+ ) {
998
+ if (page === currentPage - 2 || page === currentPage + 2) {
999
+ return <div key={page}>...</div>;
1000
+ }
1001
+ return null;
1002
+ }
1003
+ return (
1004
+ <button
1005
+ key={page}
1006
+ className={cn(
1007
+ 'rounded-md px-3 py-1 text-neutral-600 dark:text-neutral-300',
1008
+ page === currentPage
1009
+ ? 'bg-neutral-200 dark:bg-neutral-700'
1010
+ : 'hover:bg-neutral-100 dark:hover:bg-neutral-800',
1011
+ )}
1012
+ onClick={() => {
1013
+ setOffsets({
1014
+ ...offsets,
1015
+ [selectedNamespace.name]: i * limit,
1016
+ });
1017
+ history.push((prev) => ({
1018
+ ...prev,
1019
+ page,
1020
+ }));
1021
+ }}
1022
+ disabled={page === currentPage}
1023
+ >
1024
+ {page}
1025
+ </button>
1026
+ );
1027
+ })}
1028
+ </div>
1029
+ <button
1030
+ className="flex items-center justify-center"
1031
+ disabled={currentPage >= numPages}
1032
+ onClick={() => {
1033
+ setOffsets({
1034
+ ...offsets,
1035
+ [selectedNamespace.name]: offset + limit,
1036
+ });
1037
+ history.push((prev) => ({
1038
+ ...prev,
1039
+ page: Math.min(numPages, currentPage + 1),
1040
+ }));
1041
+ }}
1042
+ >
1043
+ <ArrowRightIcon
1044
+ className={cn('inline', {
1045
+ 'opacity-40': currentPage >= numPages,
1046
+ })}
1047
+ height="1rem"
1048
+ />
1049
+ </button>
1050
+ {numItemsSelected > 0 && (
1051
+ <div className="flex items-center gap-2 pl-4">
1052
+ <DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
1053
+ <DropdownMenuTrigger>
1054
+ <Button
1055
+ onClick={() => {
1056
+ setDropdownOpen(true);
1057
+ }}
1058
+ variant="secondary"
1059
+ >
1060
+ <ArrowUpOnSquareIcon width={14} />
1061
+ Export ({numItemsSelected})
1062
+ </Button>
1063
+ </DropdownMenuTrigger>
1064
+ <DropdownMenuContent
1065
+ className="z-100"
1066
+ align="end"
1067
+ side="bottom"
1068
+ sideOffset={5}
1069
+ >
1070
+ <DropdownMenuItem
1071
+ onSelect={(e) => {
1072
+ e.preventDefault();
1073
+ const selectedRows = getSelectedRows(
1074
+ allItems,
1075
+ checkedIds,
1076
+ );
1077
+ // exportToCSV(
1078
+ // selectedRows,
1079
+ // columns,
1080
+ // selectedNamespace.name,
1081
+ // isShiftPressed,
1082
+ // );
1083
+ setDropdownOpen(false);
1084
+ }}
1085
+ className="flex items-center gap-2"
1086
+ >
1087
+ <Table width={12} />
1088
+ {isShiftPressed ? 'Download as CSV' : 'Copy as CSV'}
1089
+ </DropdownMenuItem>
1090
+ <DropdownMenuItem
1091
+ onSelect={(e) => {
1092
+ e.preventDefault();
1093
+ const selectedRows = getSelectedRows(
1094
+ allItems,
1095
+ checkedIds,
1096
+ );
1097
+ // exportToMarkdown(
1098
+ // selectedRows,
1099
+ // columns,
1100
+ // selectedNamespace.name,
1101
+ // isShiftPressed,
1102
+ // );
1103
+ setDropdownOpen(false);
1104
+ }}
1105
+ className="flex items-center gap-2"
1106
+ >
1107
+ <FileDown width={12} />
1108
+ {isShiftPressed
1109
+ ? 'Download as Markdown'
1110
+ : 'Copy as Markdown'}
1111
+ </DropdownMenuItem>
1112
+ <DropdownMenuItem
1113
+ onSelect={(e) => {
1114
+ e.preventDefault();
1115
+ const selectedRows = getSelectedRows(
1116
+ allItems,
1117
+ checkedIds,
1118
+ );
1119
+ // exportToJSON(
1120
+ // selectedRows,
1121
+ // columns,
1122
+ // selectedNamespace.name,
1123
+ // isShiftPressed,
1124
+ // );
1125
+ setDropdownOpen(false);
1126
+ }}
1127
+ className="flex items-center gap-2"
1128
+ >
1129
+ <CurlyBraces width={12} />
1130
+ {isShiftPressed ? 'Download as JSON' : 'Copy as JSON'}
1131
+ </DropdownMenuItem>
1132
+ {!isShiftPressed && (
1133
+ <>
1134
+ <DropdownMenuSeparator />
1135
+ <DropdownMenuItem
1136
+ className="text-xs text-neutral-500 dark:text-neutral-400"
1137
+ disabled
1138
+ >
1139
+ Hold shift to download as file
1140
+ </DropdownMenuItem>
1141
+ </>
1142
+ )}
1143
+ </DropdownMenuContent>
1144
+ </DropdownMenu>
1145
+ <Button
1146
+ onClick={() => {
1147
+ setDeleteDataConfirmationOpen(true);
1148
+ }}
1149
+ className="px-2"
1150
+ variant="destructive"
1151
+ >
1152
+ <TrashIcon width={14} />
1153
+ Delete Selected Rows
1154
+ </Button>
1155
+ </div>
1156
+ )}
1157
+ <div className="grow" />
1158
+ <div className="px-2">
1159
+ <ViewSettings
1160
+ localDates={localDates}
1161
+ setLocalDates={setLocalDates}
1162
+ visiblity={colVisiblity}
1163
+ />
1164
+ </div>
1165
+ </div>
1166
+
1167
+ <DndContext
1168
+ collisionDetection={closestCenter}
1169
+ modifiers={[restrictToHorizontalAxis]}
1170
+ onDragEnd={handleDragEnd}
1171
+ sensors={sensors}
1172
+ >
1173
+ <div className="relative flex-1 overflow-hidden bg-neutral-100 dark:bg-neutral-900/50">
1174
+ {!tableSmallerThanViewport && (
1175
+ <div
1176
+ className="absolute top-0 right-0 bottom-0 z-50 w-[30px] bg-linear-to-l from-black/20 via-black/5 to-transparent transition-opacity duration-150"
1177
+ style={{
1178
+ pointerEvents: 'none',
1179
+ opacity: rightShadowOpacity,
1180
+ display: rightShadowOpacity == 0 ? 'none' : undefined,
1181
+ }}
1182
+ />
1183
+ )}
1184
+ <div
1185
+ className="absolute top-0 bottom-0 left-0 z-50 w-[30px] bg-linear-to-r from-black/10 via-black/0 to-transparent transition-opacity duration-150"
1186
+ style={{
1187
+ pointerEvents: 'none',
1188
+ opacity: leftShadowOpacity,
1189
+ display: leftShadowOpacity == 0 ? 'none' : undefined,
1190
+ }}
1191
+ />
1192
+ <div ref={tableRef} className="h-full w-full overflow-auto">
1193
+ <div
1194
+ style={{
1195
+ width: table.getCenterTotalSize(),
1196
+ }}
1197
+ className="z-0 inline-block text-left align-top font-mono text-xs text-neutral-500 dark:text-neutral-400"
1198
+ >
1199
+ <div className="sticky top-0 z-10 border-r border-b border-gray-200 border-r-gray-200 bg-white text-neutral-700 shadow-sm dark:border-r-neutral-700 dark:border-b-neutral-600 dark:bg-[#303030] dark:text-neutral-300">
1200
+ {table.getHeaderGroups().map((headerGroup) => (
1201
+ <div className={'flex w-full'} key={headerGroup.id}>
1202
+ <SortableContext
1203
+ items={columnOrder}
1204
+ strategy={horizontalListSortingStrategy}
1205
+ >
1206
+ {headerGroup.headers.map((header, i) => (
1207
+ <TableHeader
1208
+ key={header.id}
1209
+ header={header}
1210
+ table={table}
1211
+ headerGroup={headerGroup}
1212
+ index={i}
1213
+ setMinViableColWidth={setMinViableColWidth}
1214
+ onSort={(attrName, currentAttr, currentAsc) => {
1215
+ history.push((prev) => ({
1216
+ ...prev,
1217
+ sortAttr: attrName,
1218
+ sortAsc:
1219
+ currentAttr !== attrName ? true : !currentAsc,
1220
+ }));
1221
+ }}
1222
+ currentSortAttr={currentNav?.sortAttr}
1223
+ currentSortAsc={currentNav?.sortAsc}
1224
+ />
1225
+ ))}
1226
+ </SortableContext>
1227
+ </div>
1228
+ ))}
1229
+ </div>
1230
+ <div>
1231
+ {table.getRowModel().rows.map((row) => (
1232
+ <div
1233
+ className="group flex border-r border-b border-r-gray-200 border-b-gray-200 bg-white dark:border-neutral-700 dark:border-r-neutral-700 dark:bg-neutral-800"
1234
+ key={row.id}
1235
+ >
1236
+ {row.getVisibleCells().map((cell) => (
1237
+ <SortableContext
1238
+ key={cell.id}
1239
+ items={columnOrder}
1240
+ strategy={horizontalListSortingStrategy}
1241
+ >
1242
+ <TableCell key={cell.id} cell={cell} />
1243
+ </SortableContext>
1244
+ ))}
1245
+ </div>
1246
+ ))}
1247
+ </div>
1248
+ </div>
1249
+ {tableSmallerThanViewport && (
1250
+ <div className="sticky top-0 inline-block align-top">
1251
+ <IconButton
1252
+ className="opacity-60"
1253
+ labelDirection="bottom"
1254
+ label="Fill Width"
1255
+ icon={<ArrowRightFromLine />}
1256
+ onClick={distributeRemainingWidth}
1257
+ />
1258
+ </div>
1259
+ )}
1260
+ </div>
1261
+ </div>
1262
+ </DndContext>
1263
+ </div>
1264
+ </>
1265
+ );
1266
+ };
1267
+
1268
+ function formatVal(data: any, pretty?: boolean): string {
1269
+ if (isObject(data)) {
1270
+ return JSON.stringify(data, null, pretty ? 2 : undefined);
1271
+ }
1272
+
1273
+ return String(data);
1274
+ }
1275
+
1276
+ function Val({ data, pretty }: { data: any; pretty?: boolean }) {
1277
+ const props = useExplorerProps();
1278
+ const sanitized = formatVal(data, pretty);
1279
+
1280
+ if (pretty && isObject(data)) {
1281
+ return <Fence darkMode={props.darkMode} code={sanitized} language="json" />;
1282
+ }
1283
+
1284
+ return <>{sanitized}</>;
1285
+ }
1286
+
1287
+ // Storage
1288
+ export async function jsonFetch(
1289
+ input: RequestInfo,
1290
+ init: RequestInit | undefined,
1291
+ ): Promise<any> {
1292
+ const res = await fetch(input, init);
1293
+ const json = await res.json();
1294
+ return res.status === 200
1295
+ ? Promise.resolve(json)
1296
+ : Promise.reject({ status: res.status, body: json });
1297
+ }
1298
+
1299
+ async function upload(
1300
+ token: string,
1301
+ appId: string,
1302
+ file: File,
1303
+ customFilename: string,
1304
+ apiUri: string,
1305
+ ): Promise<boolean> {
1306
+ const headers = {
1307
+ app_id: appId,
1308
+ path: customFilename || file.name,
1309
+ authorization: `Bearer ${token}`,
1310
+ 'content-type': file.type,
1311
+ };
1312
+
1313
+ const data = await jsonFetch(`${apiUri}/dash/apps/${appId}/storage/upload`, {
1314
+ method: 'PUT',
1315
+ headers,
1316
+ body: file,
1317
+ });
1318
+
1319
+ return data;
1320
+ }
1321
+
1322
+ async function bulkDeleteFiles(
1323
+ token: string,
1324
+ appId: string,
1325
+ filenames: string[],
1326
+ apiUri: string,
1327
+ ): Promise<any> {
1328
+ const { data } = await jsonFetch(
1329
+ `${apiUri}/dash/apps/${appId}/storage/files/delete`,
1330
+ {
1331
+ method: 'POST',
1332
+ headers: {
1333
+ 'content-type': 'application/json',
1334
+ authorization: `Bearer ${token}`,
1335
+ },
1336
+ body: JSON.stringify({ filenames }),
1337
+ },
1338
+ );
1339
+
1340
+ return data;
1341
+ }