@rozenite/sqlite-plugin 1.7.0-rc.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 (56) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/LICENSE +20 -0
  3. package/README.md +102 -0
  4. package/dist/devtools/assets/panel-B3paLkwG.js +82 -0
  5. package/dist/devtools/assets/panel-CIU0JBOs.css +1 -0
  6. package/dist/devtools/panel.html +31 -0
  7. package/dist/react-native/chunks/bridge-values.cjs +5 -0
  8. package/dist/react-native/chunks/bridge-values.js +258 -0
  9. package/dist/react-native/chunks/index.require.cjs +1 -0
  10. package/dist/react-native/chunks/index.require.js +118 -0
  11. package/dist/react-native/chunks/useRozeniteSqlitePlugin.require.cjs +1 -0
  12. package/dist/react-native/chunks/useRozeniteSqlitePlugin.require.js +189 -0
  13. package/dist/react-native/index.cjs +1 -0
  14. package/dist/react-native/index.d.ts +178 -0
  15. package/dist/react-native/index.js +16 -0
  16. package/dist/rozenite.json +1 -0
  17. package/package.json +83 -0
  18. package/postcss.config.js +6 -0
  19. package/react-native.ts +55 -0
  20. package/rozenite.config.ts +8 -0
  21. package/src/react-native/adapters/__tests__/expo-sqlite.test.ts +94 -0
  22. package/src/react-native/adapters/expo-sqlite.ts +230 -0
  23. package/src/react-native/adapters/generic.ts +88 -0
  24. package/src/react-native/adapters/index.ts +9 -0
  25. package/src/react-native/sqlite-view.ts +24 -0
  26. package/src/react-native/useRozeniteSqlitePlugin.ts +262 -0
  27. package/src/shared/__tests__/bridge-values.test.ts +34 -0
  28. package/src/shared/__tests__/sql.test.ts +55 -0
  29. package/src/shared/bridge-values.ts +170 -0
  30. package/src/shared/protocol.ts +41 -0
  31. package/src/shared/sql.ts +420 -0
  32. package/src/shared/types.ts +81 -0
  33. package/src/ui/__tests__/sql-editor-utils.test.ts +135 -0
  34. package/src/ui/__tests__/sqlite-row-edit-value.test.ts +22 -0
  35. package/src/ui/__tests__/sqlite-row-mutations.test.ts +310 -0
  36. package/src/ui/__tests__/sqlite-table-column-order.test.ts +83 -0
  37. package/src/ui/__tests__/value-utils.test.tsx +12 -0
  38. package/src/ui/cell-detail-drawer.tsx +65 -0
  39. package/src/ui/globals.css +1415 -0
  40. package/src/ui/panel.tsx +2815 -0
  41. package/src/ui/query-result-table.tsx +199 -0
  42. package/src/ui/sql-editor-utils.ts +352 -0
  43. package/src/ui/sql-editor.tsx +509 -0
  44. package/src/ui/sqlite-data-table.tsx +296 -0
  45. package/src/ui/sqlite-introspection.ts +189 -0
  46. package/src/ui/sqlite-modal-controls.tsx +32 -0
  47. package/src/ui/sqlite-row-delete-modal.tsx +130 -0
  48. package/src/ui/sqlite-row-edit-modal.tsx +487 -0
  49. package/src/ui/sqlite-row-edit-value.ts +53 -0
  50. package/src/ui/sqlite-row-mutations.ts +246 -0
  51. package/src/ui/sqlite-table-column-order.ts +154 -0
  52. package/src/ui/use-sqlite-requests.ts +205 -0
  53. package/src/ui/utils.ts +107 -0
  54. package/src/ui/value-utils.tsx +162 -0
  55. package/tsconfig.json +36 -0
  56. package/vite.config.ts +20 -0
@@ -0,0 +1,296 @@
1
+ import {
2
+ flexRender,
3
+ getCoreRowModel,
4
+ useReactTable,
5
+ type ColumnDef,
6
+ type ColumnSizingState,
7
+ type Header,
8
+ type OnChangeFn,
9
+ type RowData,
10
+ } from '@tanstack/react-table';
11
+ import { useVirtualizer } from '@tanstack/react-virtual';
12
+ import {
13
+ useMemo,
14
+ useRef,
15
+ useState,
16
+ type KeyboardEvent as ReactKeyboardEvent,
17
+ type ReactNode,
18
+ } from 'react';
19
+ import { formatNumber } from './utils';
20
+ import { SQLITE_ROW_NUMBER_COLUMN_ID } from './sqlite-table-column-order';
21
+
22
+ const joinClassNames = (
23
+ ...classNames: Array<string | false | null | undefined>
24
+ ) => classNames.filter(Boolean).join(' ');
25
+
26
+ const LoadingState = ({ columns }: { columns: number }) => (
27
+ <div className="sqlite-results-loading" aria-live="polite">
28
+ {Array.from({ length: 6 }, (_, rowIndex) => (
29
+ <div
30
+ key={`loading-${rowIndex}`}
31
+ className="sqlite-results-loading-row"
32
+ style={{
33
+ gridTemplateColumns: `repeat(${Math.max(columns, 3)}, minmax(12rem, 1fr))`,
34
+ }}
35
+ >
36
+ {Array.from({ length: Math.max(columns, 3) }, (_, columnIndex) => (
37
+ <span
38
+ key={`${rowIndex}-${columnIndex}`}
39
+ className="sqlite-results-loading-bar"
40
+ />
41
+ ))}
42
+ </div>
43
+ ))}
44
+ </div>
45
+ );
46
+
47
+ type SqliteDataTableProps<TData extends RowData> = {
48
+ tableId: string;
49
+ data: TData[];
50
+ columns: ColumnDef<TData, unknown>[];
51
+ columnOrder: string[];
52
+ onColumnOrderChange: OnChangeFn<string[]>;
53
+ emptyTitle: string;
54
+ emptyDescription: string;
55
+ loading?: boolean;
56
+ loadingColumnCount?: number;
57
+ shellClassName?: string;
58
+ scrollContainerClassName?: string;
59
+ tableClassName?: string;
60
+ showRowNumbers?: boolean;
61
+ rowNumberOffset?: number;
62
+ onRowClick?: (row: TData, rowIndex: number) => void;
63
+ getRowAriaLabel?: (row: TData, rowIndex: number) => string;
64
+ };
65
+
66
+ type SortableColumnHeaderProps<TData extends RowData> = {
67
+ header: Header<TData, unknown>;
68
+ };
69
+
70
+ const SortableColumnHeader = <TData extends RowData>({
71
+ header,
72
+ }: SortableColumnHeaderProps<TData>) => {
73
+ return (
74
+ <th
75
+ scope="col"
76
+ className={joinClassNames(
77
+ header.column.id === SQLITE_ROW_NUMBER_COLUMN_ID &&
78
+ 'sqlite-results-number-col',
79
+ header.column.getIsResizing() && 'sqlite-table-column-resizing',
80
+ )}
81
+ style={{
82
+ width: header.getSize(),
83
+ }}
84
+ >
85
+ <div className="sqlite-table-header-content">
86
+ <div className="min-w-0">
87
+ {header.isPlaceholder
88
+ ? null
89
+ : flexRender(header.column.columnDef.header, header.getContext())}
90
+ </div>
91
+ </div>
92
+ {header.column.getCanResize() ? (
93
+ <div
94
+ aria-hidden="true"
95
+ className={joinClassNames(
96
+ 'sqlite-column-resizer',
97
+ header.column.getIsResizing() && 'is-active',
98
+ )}
99
+ onDoubleClick={() => header.column.resetSize()}
100
+ onMouseDown={header.getResizeHandler()}
101
+ onTouchStart={header.getResizeHandler()}
102
+ />
103
+ ) : null}
104
+ </th>
105
+ );
106
+ };
107
+
108
+ export const SqliteDataTable = <TData extends RowData>({
109
+ tableId,
110
+ data,
111
+ columns,
112
+ columnOrder,
113
+ onColumnOrderChange,
114
+ emptyTitle,
115
+ emptyDescription,
116
+ loading = false,
117
+ loadingColumnCount,
118
+ shellClassName,
119
+ scrollContainerClassName,
120
+ tableClassName,
121
+ showRowNumbers = false,
122
+ rowNumberOffset = 0,
123
+ onRowClick,
124
+ getRowAriaLabel,
125
+ }: SqliteDataTableProps<TData>) => {
126
+ const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
127
+ const scrollElementRef = useRef<HTMLDivElement | null>(null);
128
+
129
+ const rowNumberColumn = useMemo<ColumnDef<TData, unknown>>(
130
+ () => ({
131
+ id: SQLITE_ROW_NUMBER_COLUMN_ID,
132
+ header: '#',
133
+ enableResizing: false,
134
+ size: 72,
135
+ minSize: 72,
136
+ maxSize: 72,
137
+ cell: ({ row }) => (
138
+ <span className="sqlite-results-row-number sqlite-tabular">
139
+ {formatNumber(rowNumberOffset + row.index + 1)}
140
+ </span>
141
+ ),
142
+ }),
143
+ [rowNumberOffset],
144
+ );
145
+
146
+ const tableColumns = useMemo(
147
+ () => (showRowNumbers ? [rowNumberColumn, ...columns] : columns),
148
+ [columns, rowNumberColumn, showRowNumbers],
149
+ );
150
+
151
+ const table = useReactTable({
152
+ data,
153
+ columns: tableColumns,
154
+ defaultColumn: {
155
+ minSize: 120,
156
+ size: 220,
157
+ },
158
+ state: {
159
+ columnOrder,
160
+ columnSizing,
161
+ },
162
+ onColumnOrderChange,
163
+ onColumnSizingChange: setColumnSizing,
164
+ columnResizeMode: 'onChange',
165
+ getCoreRowModel: getCoreRowModel(),
166
+ });
167
+
168
+ const loadingColumns =
169
+ loadingColumnCount ?? columns.length + (showRowNumbers ? 1 : 0);
170
+ const tableRows = table.getRowModel().rows;
171
+ const rowVirtualizer = useVirtualizer({
172
+ count: tableRows.length,
173
+ getScrollElement: () => scrollElementRef.current,
174
+ estimateSize: () => 36,
175
+ overscan: 10,
176
+ getItemKey: (index) => tableRows[index]?.id ?? index,
177
+ measureElement:
178
+ typeof window !== 'undefined' &&
179
+ !window.navigator.userAgent.includes('Firefox')
180
+ ? (element) => element?.getBoundingClientRect().height ?? 0
181
+ : undefined,
182
+ });
183
+ const virtualRows = rowVirtualizer.getVirtualItems();
184
+
185
+ const handleRowKeyDown = (
186
+ event: ReactKeyboardEvent<HTMLTableRowElement>,
187
+ row: TData,
188
+ rowIndex: number,
189
+ ) => {
190
+ if (!onRowClick || (event.key !== 'Enter' && event.key !== ' ')) {
191
+ return;
192
+ }
193
+
194
+ event.preventDefault();
195
+ onRowClick(row, rowIndex);
196
+ };
197
+
198
+ const renderTable = (): ReactNode => (
199
+ <table
200
+ className={joinClassNames('sqlite-results-table', tableClassName)}
201
+ style={{ minWidth: table.getTotalSize() }}
202
+ >
203
+ <thead>
204
+ {table.getHeaderGroups().map((headerGroup) => (
205
+ <tr key={headerGroup.id} className="sqlite-table-row-shell">
206
+ {headerGroup.headers.map((header) => (
207
+ <SortableColumnHeader key={header.id} header={header} />
208
+ ))}
209
+ </tr>
210
+ ))}
211
+ </thead>
212
+ <tbody
213
+ style={{
214
+ height: rowVirtualizer.getTotalSize(),
215
+ }}
216
+ >
217
+ {virtualRows.map((virtualRow) => {
218
+ const row = tableRows[virtualRow.index];
219
+
220
+ if (!row) {
221
+ return null;
222
+ }
223
+
224
+ return (
225
+ <tr
226
+ key={row.id}
227
+ ref={(node) => {
228
+ if (node) {
229
+ rowVirtualizer.measureElement(node);
230
+ }
231
+ }}
232
+ data-index={virtualRow.index}
233
+ className={joinClassNames(onRowClick && 'sqlite-results-row')}
234
+ role={onRowClick ? 'button' : undefined}
235
+ tabIndex={onRowClick ? 0 : undefined}
236
+ aria-label={
237
+ onRowClick
238
+ ? (getRowAriaLabel?.(row.original, row.index) ??
239
+ `Inspect row ${row.index + 1}`)
240
+ : undefined
241
+ }
242
+ onClick={() => onRowClick?.(row.original, row.index)}
243
+ onKeyDown={(event) =>
244
+ handleRowKeyDown(event, row.original, row.index)
245
+ }
246
+ style={{
247
+ transform: `translateY(${virtualRow.start}px)`,
248
+ }}
249
+ >
250
+ {row.getVisibleCells().map((cell) => (
251
+ <td
252
+ key={cell.id}
253
+ className={joinClassNames(
254
+ cell.column.id === SQLITE_ROW_NUMBER_COLUMN_ID &&
255
+ 'sqlite-results-row-number',
256
+ )}
257
+ style={{ width: cell.column.getSize() }}
258
+ >
259
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
260
+ </td>
261
+ ))}
262
+ </tr>
263
+ );
264
+ })}
265
+ </tbody>
266
+ </table>
267
+ );
268
+
269
+ return (
270
+ <div
271
+ className={joinClassNames('sqlite-results-shell', shellClassName)}
272
+ data-table-id={tableId}
273
+ >
274
+ <div
275
+ ref={scrollElementRef}
276
+ className={joinClassNames(
277
+ 'sqlite-results-scroll',
278
+ scrollContainerClassName,
279
+ )}
280
+ >
281
+ {loading ? (
282
+ <LoadingState columns={loadingColumns} />
283
+ ) : data.length === 0 || columns.length === 0 ? (
284
+ <div className="sqlite-results-empty">
285
+ <div className="max-w-sm space-y-2 text-center">
286
+ <p className="text-base font-medium text-white">{emptyTitle}</p>
287
+ <p className="text-sm text-slate-400">{emptyDescription}</p>
288
+ </div>
289
+ </div>
290
+ ) : (
291
+ renderTable()
292
+ )}
293
+ </div>
294
+ </div>
295
+ );
296
+ };
@@ -0,0 +1,189 @@
1
+ import { escapeSqlString, quoteSqlIdentifier } from '../shared/sql';
2
+ import type { SqliteQueryResult } from '../shared/types';
3
+
4
+ export type SqliteEntityType = 'table' | 'view';
5
+
6
+ export type SqliteSchema = {
7
+ seq: number;
8
+ name: string;
9
+ file: string | null;
10
+ };
11
+
12
+ export type SqliteEntity = {
13
+ schemaName: string;
14
+ name: string;
15
+ type: SqliteEntityType;
16
+ sql: string | null;
17
+ };
18
+
19
+ export type SqliteColumnInfo = {
20
+ cid: number;
21
+ name: string;
22
+ type: string;
23
+ notNull: boolean;
24
+ defaultValue: string | null;
25
+ primaryKeyOrder: number;
26
+ hidden: number;
27
+ };
28
+
29
+ export type SqliteForeignKeyInfo = {
30
+ id: number;
31
+ seq: number;
32
+ table: string;
33
+ from: string;
34
+ to: string | null;
35
+ onUpdate: string;
36
+ onDelete: string;
37
+ match: string;
38
+ };
39
+
40
+ export type SqliteIndexInfo = {
41
+ seq: number;
42
+ name: string;
43
+ unique: boolean;
44
+ origin: string;
45
+ partial: boolean;
46
+ };
47
+
48
+ export type SqliteIndexColumnInfo = {
49
+ seqno: number;
50
+ cid: number;
51
+ name: string;
52
+ };
53
+
54
+ const buildQualifiedEntityName = (schemaName: string, entityName: string) =>
55
+ `${quoteSqlIdentifier(schemaName)}.${quoteSqlIdentifier(entityName)}`;
56
+
57
+ const buildPragmaPrefix = (schemaName: string) =>
58
+ `${quoteSqlIdentifier(schemaName)}.`;
59
+
60
+ const asString = (value: unknown) =>
61
+ typeof value === 'string' ? value : String(value ?? '');
62
+ const asNullableString = (value: unknown) =>
63
+ value == null ? null : String(value);
64
+ const asNumber = (value: unknown) => {
65
+ const parsed = Number(value ?? 0);
66
+ return Number.isNaN(parsed) ? 0 : parsed;
67
+ };
68
+
69
+ export const LIST_SCHEMAS_SQL = 'PRAGMA database_list';
70
+
71
+ export const buildListEntitiesSql = (schemaName: string) => `
72
+ SELECT
73
+ name,
74
+ type,
75
+ sql
76
+ FROM ${quoteSqlIdentifier(schemaName)}.sqlite_schema
77
+ WHERE type IN ('table', 'view')
78
+ AND name NOT LIKE 'sqlite_%'
79
+ ORDER BY CASE type WHEN 'table' THEN 0 ELSE 1 END, name COLLATE NOCASE
80
+ `;
81
+
82
+ export const buildBrowseEntitySql = (
83
+ schemaName: string,
84
+ entityName: string,
85
+ limit: number,
86
+ offset: number,
87
+ rowIdIdentifier?: string | null,
88
+ ) =>
89
+ `SELECT ${
90
+ rowIdIdentifier ? `${rowIdIdentifier} AS "__sqlite-hidden-rowid__", ` : ''
91
+ }* FROM ${buildQualifiedEntityName(schemaName, entityName)} LIMIT ${Math.max(1, Math.floor(limit))} OFFSET ${Math.max(0, Math.floor(offset))}`;
92
+
93
+ export const buildEntityCountSql = (schemaName: string, entityName: string) =>
94
+ `SELECT COUNT(*) AS count FROM ${buildQualifiedEntityName(schemaName, entityName)}`;
95
+
96
+ export const buildCreateSqlLookup = (
97
+ schemaName: string,
98
+ entityName: string,
99
+ ) => `
100
+ SELECT sql
101
+ FROM ${quoteSqlIdentifier(schemaName)}.sqlite_schema
102
+ WHERE type IN ('table', 'view')
103
+ AND name = ${escapeSqlString(entityName)}
104
+ LIMIT 1
105
+ `;
106
+
107
+ export const buildTableXInfoSql = (schemaName: string, entityName: string) =>
108
+ `PRAGMA ${buildPragmaPrefix(schemaName)}table_xinfo(${escapeSqlString(entityName)})`;
109
+
110
+ export const buildForeignKeySql = (schemaName: string, entityName: string) =>
111
+ `PRAGMA ${buildPragmaPrefix(schemaName)}foreign_key_list(${escapeSqlString(entityName)})`;
112
+
113
+ export const buildIndexListSql = (schemaName: string, entityName: string) =>
114
+ `PRAGMA ${buildPragmaPrefix(schemaName)}index_list(${escapeSqlString(entityName)})`;
115
+
116
+ export const buildIndexInfoSql = (schemaName: string, indexName: string) =>
117
+ `PRAGMA ${buildPragmaPrefix(schemaName)}index_info(${escapeSqlString(indexName)})`;
118
+
119
+ export const parseSchemas = (result: SqliteQueryResult): SqliteSchema[] =>
120
+ result.rows
121
+ .map((row) => ({
122
+ seq: asNumber(row.seq),
123
+ name: asString(row.name),
124
+ file: asNullableString(row.file),
125
+ }))
126
+ .filter((schema) => !!schema.name);
127
+
128
+ export const parseEntities = (
129
+ result: SqliteQueryResult,
130
+ schemaName: string,
131
+ ): SqliteEntity[] =>
132
+ result.rows
133
+ .map((row) => ({
134
+ schemaName,
135
+ name: asString(row.name),
136
+ type: (asString(row.type) === 'view'
137
+ ? 'view'
138
+ : 'table') as SqliteEntityType,
139
+ sql: asNullableString(row.sql),
140
+ }))
141
+ .filter((entity) => !!entity.name);
142
+
143
+ export const parseColumns = (result: SqliteQueryResult): SqliteColumnInfo[] =>
144
+ result.rows.map((row) => ({
145
+ cid: asNumber(row.cid),
146
+ name: asString(row.name),
147
+ type: asString(row.type),
148
+ notNull: asNumber(row.notnull) === 1,
149
+ defaultValue: asNullableString(row.dflt_value),
150
+ primaryKeyOrder: asNumber(row.pk),
151
+ hidden: asNumber(row.hidden),
152
+ }));
153
+
154
+ export const parseForeignKeys = (
155
+ result: SqliteQueryResult,
156
+ ): SqliteForeignKeyInfo[] =>
157
+ result.rows.map((row) => ({
158
+ id: asNumber(row.id),
159
+ seq: asNumber(row.seq),
160
+ table: asString(row.table),
161
+ from: asString(row.from),
162
+ to: asNullableString(row.to),
163
+ onUpdate: asString(row.on_update),
164
+ onDelete: asString(row.on_delete),
165
+ match: asString(row.match),
166
+ }));
167
+
168
+ export const parseIndexes = (result: SqliteQueryResult): SqliteIndexInfo[] =>
169
+ result.rows.map((row) => ({
170
+ seq: asNumber(row.seq),
171
+ name: asString(row.name),
172
+ unique: asNumber(row.unique) === 1,
173
+ origin: asString(row.origin),
174
+ partial: asNumber(row.partial) === 1,
175
+ }));
176
+
177
+ export const parseIndexColumns = (
178
+ result: SqliteQueryResult,
179
+ ): SqliteIndexColumnInfo[] =>
180
+ result.rows
181
+ .map((row) => ({
182
+ seqno: asNumber(row.seqno),
183
+ cid: asNumber(row.cid),
184
+ name: asString(row.name),
185
+ }))
186
+ .filter((column) => !!column.name);
187
+
188
+ export const parseCount = (result: SqliteQueryResult) =>
189
+ asNumber(result.rows[0]?.count);
@@ -0,0 +1,32 @@
1
+ import { X } from 'lucide-react';
2
+
3
+ const toneButtonClassName =
4
+ 'sqlite-button inline-flex items-center justify-center gap-2 rounded-xl px-3 py-2 text-sm font-medium';
5
+
6
+ export const sqliteSecondaryButtonClassName = `${toneButtonClassName} sqlite-button-secondary`;
7
+ export const sqlitePrimaryButtonClassName = `${toneButtonClassName} sqlite-button-primary`;
8
+ export const sqliteModalIconButtonClassName = `${sqliteSecondaryButtonClassName} h-10 w-10 shrink-0 px-0 py-0`;
9
+
10
+ type SqliteModalCloseButtonProps = {
11
+ onClose: () => void;
12
+ disabled?: boolean;
13
+ ariaLabel?: string;
14
+ };
15
+
16
+ export const SqliteModalCloseButton = ({
17
+ onClose,
18
+ disabled = false,
19
+ ariaLabel = 'Close modal',
20
+ }: SqliteModalCloseButtonProps) => {
21
+ return (
22
+ <button
23
+ type="button"
24
+ className={sqliteModalIconButtonClassName}
25
+ aria-label={ariaLabel}
26
+ onClick={onClose}
27
+ disabled={disabled}
28
+ >
29
+ <X aria-hidden="true" className="h-4 w-4" />
30
+ </button>
31
+ );
32
+ };
@@ -0,0 +1,130 @@
1
+ import { Modal, useOverlayState } from '@heroui/react';
2
+ import { AlertTriangle, Trash2 } from 'lucide-react';
3
+ import { useEffect, useState } from 'react';
4
+ import {
5
+ SqliteModalCloseButton,
6
+ sqliteSecondaryButtonClassName,
7
+ } from './sqlite-modal-controls';
8
+
9
+ type SqliteRowDeleteModalProps = {
10
+ isOpen: boolean;
11
+ rowNumber: number;
12
+ entityName: string;
13
+ onClose: () => void;
14
+ onDelete: () => Promise<void>;
15
+ };
16
+
17
+ const toneButtonClassName =
18
+ 'sqlite-button inline-flex items-center justify-center gap-2 rounded-xl px-3 py-2 text-sm font-medium';
19
+ const dangerButtonClassName = `${toneButtonClassName} border border-rose-400/30 bg-rose-500/16 text-rose-50 hover:bg-rose-500/24`;
20
+
21
+ export const SqliteRowDeleteModal = ({
22
+ isOpen,
23
+ rowNumber,
24
+ entityName,
25
+ onClose,
26
+ onDelete,
27
+ }: SqliteRowDeleteModalProps) => {
28
+ const overlay = useOverlayState({
29
+ isOpen,
30
+ onOpenChange: (open: boolean) => {
31
+ if (!open) {
32
+ onClose();
33
+ }
34
+ },
35
+ });
36
+ const [deleting, setDeleting] = useState(false);
37
+ const [error, setError] = useState<string | null>(null);
38
+
39
+ useEffect(() => {
40
+ if (isOpen) {
41
+ setDeleting(false);
42
+ setError(null);
43
+ }
44
+ }, [isOpen]);
45
+
46
+ const handleDelete = async () => {
47
+ try {
48
+ setDeleting(true);
49
+ setError(null);
50
+ await onDelete();
51
+ onClose();
52
+ } catch (nextError) {
53
+ setError(
54
+ nextError instanceof Error ? nextError.message : String(nextError),
55
+ );
56
+ } finally {
57
+ setDeleting(false);
58
+ }
59
+ };
60
+
61
+ return (
62
+ <Modal state={overlay}>
63
+ <Modal.Backdrop
64
+ variant="blur"
65
+ isDismissable={!deleting}
66
+ className="bg-[rgba(5,10,16,0.24)] backdrop-blur-[2px]"
67
+ >
68
+ <Modal.Container placement="center" size="md" scroll="inside">
69
+ <Modal.Dialog
70
+ aria-label={`Delete row ${rowNumber} from ${entityName}`}
71
+ className="w-full max-w-xl overflow-hidden border border-white/10 bg-[#0a121b] p-0 text-white shadow-[0_30px_90px_rgba(0,0,0,0.42)]"
72
+ >
73
+ <div className="flex items-center justify-between gap-4 border-b border-white/8 px-5 py-5">
74
+ <div className="flex items-center gap-3">
75
+ <div className="flex h-10 w-10 items-center justify-center rounded-full bg-rose-500/12 text-rose-200">
76
+ <AlertTriangle aria-hidden="true" className="h-5 w-5" />
77
+ </div>
78
+ <div>
79
+ <h2 className="text-lg font-semibold text-white">
80
+ Delete Row {rowNumber}
81
+ </h2>
82
+ <p className="mt-1 text-sm text-slate-400">{entityName}</p>
83
+ </div>
84
+ </div>
85
+ <SqliteModalCloseButton onClose={onClose} disabled={deleting} />
86
+ </div>
87
+
88
+ <Modal.Body className="space-y-0 p-0">
89
+ <div className="space-y-5 px-5 py-5">
90
+ <p className="text-sm leading-6 text-slate-300">
91
+ This will permanently delete the selected row and immediately
92
+ refetch the current page.
93
+ </p>
94
+
95
+ {error ? (
96
+ <div className="sqlite-inline-error" aria-live="polite">
97
+ <div>
98
+ <p className="font-medium text-rose-100">Delete Failed</p>
99
+ <p className="mt-1 text-sm text-rose-100/90">{error}</p>
100
+ </div>
101
+ </div>
102
+ ) : null}
103
+ </div>
104
+
105
+ <div className="flex items-center justify-end gap-3 border-t border-white/8 px-5 py-5">
106
+ <button
107
+ type="button"
108
+ className={sqliteSecondaryButtonClassName}
109
+ onClick={onClose}
110
+ disabled={deleting}
111
+ >
112
+ Cancel
113
+ </button>
114
+ <button
115
+ type="button"
116
+ className={dangerButtonClassName}
117
+ onClick={() => void handleDelete()}
118
+ disabled={deleting}
119
+ >
120
+ <Trash2 aria-hidden="true" className="h-4 w-4" />
121
+ {deleting ? 'Deleting…' : 'Delete Row'}
122
+ </button>
123
+ </div>
124
+ </Modal.Body>
125
+ </Modal.Dialog>
126
+ </Modal.Container>
127
+ </Modal.Backdrop>
128
+ </Modal>
129
+ );
130
+ };