@rozenite/storage-plugin 1.8.0 → 1.9.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.
package/src/ui/panel.tsx CHANGED
@@ -1,9 +1,10 @@
1
1
  import { useRozeniteDevToolsClient } from '@rozenite/plugin-bridge';
2
- import { useEffect, useMemo, useState } from 'react';
3
- import { Search, Plus } from 'lucide-react';
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { Download, Plus, Search, Upload } from 'lucide-react';
4
4
  import type {
5
5
  StorageDeleteEntryEvent,
6
6
  StorageEventMap,
7
+ StorageImportResultEvent,
7
8
  StorageSetEntryEvent,
8
9
  StorageSnapshotEvent,
9
10
  } from '../shared/messaging';
@@ -14,10 +15,18 @@ import type {
14
15
  StorageTarget,
15
16
  } from '../shared/types';
16
17
  import { getStorageViewId } from '../shared/types';
18
+ import {
19
+ buildSnapshot,
20
+ computePreview,
21
+ parseSnapshot,
22
+ } from '../shared/snapshot';
17
23
  import { EditableTable } from './editable-table';
18
24
  import { AddEntryDialog } from './add-entry-dialog';
19
25
  import { EntryDetailDialog } from './entry-detail-dialog';
20
26
  import { EditEntryDialog } from './edit-entry-dialog';
27
+ import { ConfirmDialog } from './confirm-dialog';
28
+ import { ImportDialog, type ImportFlightState } from './import-dialog';
29
+ import { buildExportFilename, downloadJson } from './utils';
21
30
  import './globals.css';
22
31
 
23
32
  type StorageSnapshotState = {
@@ -25,10 +34,22 @@ type StorageSnapshotState = {
25
34
  adapterName: string;
26
35
  storageName: string;
27
36
  capabilities: StorageCapabilities;
37
+ blacklist?: RegExp;
28
38
  entries: StorageEntry[];
29
39
  };
30
40
 
31
- const getEntryTypeFromValue = (value: StorageEntryValue): StorageEntry['type'] => {
41
+ type AlertState = {
42
+ isOpen: boolean;
43
+ title: string;
44
+ message: string;
45
+ };
46
+
47
+ const sameTarget = (a: StorageTarget, b: StorageTarget) =>
48
+ a.adapterId === b.adapterId && a.storageId === b.storageId;
49
+
50
+ const getEntryTypeFromValue = (
51
+ value: StorageEntryValue,
52
+ ): StorageEntry['type'] => {
32
53
  if (typeof value === 'string') {
33
54
  return 'string';
34
55
  }
@@ -46,11 +67,11 @@ const getEntryTypeFromValue = (value: StorageEntryValue): StorageEntry['type'] =
46
67
 
47
68
  export default function StoragePanel() {
48
69
  const [snapshots, setSnapshots] = useState<Map<string, StorageSnapshotState>>(
49
- new Map()
50
- );
51
- const [selectedStorageViewId, setSelectedStorageViewId] = useState<string | null>(
52
- null
70
+ new Map(),
53
71
  );
72
+ const [selectedStorageViewId, setSelectedStorageViewId] = useState<
73
+ string | null
74
+ >(null);
54
75
  const [loading, setLoading] = useState(false);
55
76
  const [searchTerm, setSearchTerm] = useState('');
56
77
  const [showAddDialog, setShowAddDialog] = useState(false);
@@ -58,6 +79,15 @@ export default function StoragePanel() {
58
79
  const [showDetailDialog, setShowDetailDialog] = useState(false);
59
80
  const [editingEntry, setEditingEntry] = useState<StorageEntry | null>(null);
60
81
  const [showEditDialog, setShowEditDialog] = useState(false);
82
+ const [importFlight, setImportFlight] = useState<ImportFlightState | null>(
83
+ null,
84
+ );
85
+ const [alertState, setAlertState] = useState<AlertState>({
86
+ isOpen: false,
87
+ title: '',
88
+ message: '',
89
+ });
90
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
61
91
 
62
92
  const client = useRozeniteDevToolsClient<StorageEventMap>({
63
93
  pluginId: '@rozenite/storage-plugin',
@@ -71,61 +101,91 @@ export default function StoragePanel() {
71
101
  const snapshotSubscription = client.onMessage(
72
102
  'snapshot',
73
103
  (event: StorageSnapshotEvent) => {
74
- const viewId = getStorageViewId(event.target);
75
- setSnapshots((previous) => {
76
- const next = new Map(previous);
77
- next.set(viewId, {
78
- target: event.target,
79
- adapterName: event.adapterName,
80
- storageName: event.storageName,
81
- capabilities: event.capabilities,
82
- entries: event.entries,
83
- });
104
+ const viewId = getStorageViewId(event.target);
105
+ setSnapshots((previous) => {
106
+ const next = new Map(previous);
107
+ next.set(viewId, {
108
+ target: event.target,
109
+ adapterName: event.adapterName,
110
+ storageName: event.storageName,
111
+ capabilities: event.capabilities,
112
+ blacklist: event.blacklist
113
+ ? new RegExp(event.blacklist.source, event.blacklist.flags)
114
+ : undefined,
115
+ entries: event.entries,
116
+ });
84
117
 
85
- if (previous.size === 0 && !selectedStorageViewId) {
86
- setSelectedStorageViewId(viewId);
87
- }
118
+ if (previous.size === 0 && !selectedStorageViewId) {
119
+ setSelectedStorageViewId(viewId);
120
+ }
88
121
 
89
- return next;
90
- });
122
+ return next;
123
+ });
91
124
 
92
- if (viewId === selectedStorageViewId) {
93
- setLoading(false);
94
- }
95
- }
125
+ if (viewId === selectedStorageViewId) {
126
+ setLoading(false);
127
+ }
128
+ },
96
129
  );
97
130
 
98
131
  const setEntrySubscription = client.onMessage(
99
132
  'set-entry',
100
133
  (event: StorageSetEntryEvent) => {
101
- const viewId = getStorageViewId(event.target);
102
- setSnapshots((previous) => {
103
- const next = new Map(previous);
104
- const current = next.get(viewId);
134
+ const viewId = getStorageViewId(event.target);
135
+ setSnapshots((previous) => {
136
+ const next = new Map(previous);
137
+ const current = next.get(viewId);
105
138
 
106
- if (!current) {
107
- return previous;
108
- }
139
+ if (!current) {
140
+ return previous;
141
+ }
109
142
 
110
- const existingIndex = current.entries.findIndex(
111
- (entry) => entry.key === event.entry.key
112
- );
143
+ const existingIndex = current.entries.findIndex(
144
+ (entry) => entry.key === event.entry.key,
145
+ );
113
146
 
114
- const entries =
115
- existingIndex >= 0
116
- ? current.entries.map((entry) =>
117
- entry.key === event.entry.key ? event.entry : entry
118
- )
119
- : [...current.entries, event.entry];
147
+ const entries =
148
+ existingIndex >= 0
149
+ ? current.entries.map((entry) =>
150
+ entry.key === event.entry.key ? event.entry : entry,
151
+ )
152
+ : [...current.entries, event.entry];
120
153
 
121
- next.set(viewId, {
122
- ...current,
123
- entries,
154
+ next.set(viewId, {
155
+ ...current,
156
+ entries,
157
+ });
158
+
159
+ return next;
124
160
  });
125
161
 
126
- return next;
127
- });
128
- }
162
+ setImportFlight((previous) => {
163
+ if (!previous || previous.phase !== 'importing') return previous;
164
+ if (!sameTarget(event.target, previous.target)) return previous;
165
+ return { ...previous, written: previous.written + 1 };
166
+ });
167
+ },
168
+ );
169
+
170
+ const importResultSubscription = client.onMessage(
171
+ 'import-result',
172
+ (event: StorageImportResultEvent) => {
173
+ setImportFlight((previous) => {
174
+ if (!previous || previous.phase !== 'importing') return previous;
175
+ if (!sameTarget(event.target, previous.target)) return previous;
176
+ if (event.ok) {
177
+ return { phase: 'result', ok: true, written: event.written };
178
+ }
179
+ return {
180
+ phase: 'result',
181
+ ok: false,
182
+ written: event.written,
183
+ total: event.total,
184
+ failedKey: event.failedKey,
185
+ error: event.error ?? 'Unknown error',
186
+ };
187
+ });
188
+ },
129
189
  );
130
190
 
131
191
  const deleteEntrySubscription = client.onMessage(
@@ -148,7 +208,7 @@ export default function StoragePanel() {
148
208
 
149
209
  return next;
150
210
  });
151
- }
211
+ },
152
212
  );
153
213
 
154
214
  client.send('get-snapshot', {
@@ -160,6 +220,7 @@ export default function StoragePanel() {
160
220
  snapshotSubscription.remove();
161
221
  setEntrySubscription.remove();
162
222
  deleteEntrySubscription.remove();
223
+ importResultSubscription.remove();
163
224
  };
164
225
  }, [client, selectedStorageViewId]);
165
226
 
@@ -178,7 +239,7 @@ export default function StoragePanel() {
178
239
  const separatorIndex = selectedStorageViewId.indexOf(':');
179
240
  if (separatorIndex < 0) {
180
241
  console.warn(
181
- `[Rozenite] Storage Plugin: Invalid storage view id "${selectedStorageViewId}".`
242
+ `[Rozenite] Storage Plugin: Invalid storage view id "${selectedStorageViewId}".`,
182
243
  );
183
244
  setLoading(false);
184
245
  return;
@@ -198,7 +259,7 @@ export default function StoragePanel() {
198
259
  }, [client, selectedStorageViewId, snapshots]);
199
260
 
200
261
  const selectedStorage = selectedStorageViewId
201
- ? snapshots.get(selectedStorageViewId) ?? null
262
+ ? (snapshots.get(selectedStorageViewId) ?? null)
202
263
  : null;
203
264
 
204
265
  const entries = selectedStorage?.entries ?? [];
@@ -206,15 +267,15 @@ export default function StoragePanel() {
206
267
  const filteredEntries = useMemo(
207
268
  () =>
208
269
  entries.filter((entry) =>
209
- entry.key.toLowerCase().includes(searchTerm.toLowerCase())
270
+ entry.key.toLowerCase().includes(searchTerm.toLowerCase()),
210
271
  ),
211
- [entries, searchTerm]
272
+ [entries, searchTerm],
212
273
  );
213
274
 
214
275
  const supportedTypes = selectedStorage?.capabilities.supportedTypes ?? [];
215
276
 
216
277
  const updateEntriesForSelectedStorage = (
217
- mutate: (entries: StorageEntry[]) => StorageEntry[]
278
+ mutate: (entries: StorageEntry[]) => StorageEntry[],
218
279
  ) => {
219
280
  if (!selectedStorageViewId) {
220
281
  return;
@@ -266,7 +327,7 @@ export default function StoragePanel() {
266
327
  });
267
328
 
268
329
  updateEntriesForSelectedStorage((currentEntries) =>
269
- currentEntries.map((entry) => (entry.key === key ? updatedEntry : entry))
330
+ currentEntries.map((entry) => (entry.key === key ? updatedEntry : entry)),
270
331
  );
271
332
  };
272
333
 
@@ -282,7 +343,7 @@ export default function StoragePanel() {
282
343
  });
283
344
 
284
345
  updateEntriesForSelectedStorage((currentEntries) =>
285
- currentEntries.filter((entry) => entry.key !== key)
346
+ currentEntries.filter((entry) => entry.key !== key),
286
347
  );
287
348
  };
288
349
 
@@ -297,7 +358,105 @@ export default function StoragePanel() {
297
358
  entry,
298
359
  });
299
360
 
300
- updateEntriesForSelectedStorage((currentEntries) => [...currentEntries, entry]);
361
+ updateEntriesForSelectedStorage((currentEntries) => [
362
+ ...currentEntries,
363
+ entry,
364
+ ]);
365
+ };
366
+
367
+ const showAlert = (title: string, message: string) =>
368
+ setAlertState({ isOpen: true, title, message });
369
+
370
+ const handleImportClick = () => {
371
+ if (!fileInputRef.current) return;
372
+ fileInputRef.current.value = '';
373
+ fileInputRef.current.click();
374
+ };
375
+
376
+ const handleFileChange = async (
377
+ event: React.ChangeEvent<HTMLInputElement>,
378
+ ) => {
379
+ const file = event.target.files?.[0];
380
+ if (!file || !selectedStorage) {
381
+ return;
382
+ }
383
+
384
+ let raw: unknown;
385
+ try {
386
+ const text = await file.text();
387
+ raw = JSON.parse(text);
388
+ } catch (parseError) {
389
+ showAlert(
390
+ 'Could not read file',
391
+ parseError instanceof Error ? parseError.message : String(parseError),
392
+ );
393
+ return;
394
+ }
395
+
396
+ const parsed = parseSnapshot(raw);
397
+ if (!parsed.ok) {
398
+ showAlert(
399
+ 'Invalid snapshot',
400
+ `${parsed.error.path}: ${parsed.error.message}`,
401
+ );
402
+ return;
403
+ }
404
+
405
+ const preview = computePreview(parsed.snapshot, {
406
+ target: selectedStorage.target,
407
+ capabilities: selectedStorage.capabilities,
408
+ entryKeys: new Set(selectedStorage.entries.map((entry) => entry.key)),
409
+ isBlacklisted: selectedStorage.blacklist
410
+ ? (key) => selectedStorage.blacklist!.test(key)
411
+ : () => false,
412
+ });
413
+
414
+ const skippedSet = new Set(preview.skippedKeys.map((s) => s.key));
415
+ const unsupportedSet = new Set(preview.unsupportedTypes.map((u) => u.key));
416
+ const entriesToWrite = parsed.snapshot.entries.filter(
417
+ (entry) => !skippedSet.has(entry.key) && !unsupportedSet.has(entry.key),
418
+ );
419
+
420
+ setImportFlight({
421
+ phase: 'preview',
422
+ target: selectedStorage.target,
423
+ targetLabel: `${selectedStorage.adapterName} / ${selectedStorage.storageName}`,
424
+ snapshot: parsed.snapshot,
425
+ preview,
426
+ entriesToWrite,
427
+ });
428
+ };
429
+
430
+ const handleApplyImport = () => {
431
+ if (!client) return;
432
+ if (!importFlight || importFlight.phase !== 'preview') return;
433
+
434
+ client.send('import-entries', {
435
+ type: 'import-entries',
436
+ target: importFlight.target,
437
+ entries: importFlight.entriesToWrite,
438
+ });
439
+
440
+ setImportFlight({
441
+ phase: 'importing',
442
+ target: importFlight.target,
443
+ total: importFlight.entriesToWrite.length,
444
+ written: 0,
445
+ });
446
+ };
447
+
448
+ const handleCloseImport = () => setImportFlight(null);
449
+
450
+ const handleExport = () => {
451
+ if (!selectedStorage) return;
452
+ const snapshot = buildSnapshot({
453
+ target: selectedStorage.target,
454
+ adapterName: selectedStorage.adapterName,
455
+ storageName: selectedStorage.storageName,
456
+ capabilities: selectedStorage.capabilities,
457
+ entries: selectedStorage.entries,
458
+ });
459
+ downloadJson(snapshot, buildExportFilename(selectedStorage.target));
301
460
  };
302
461
 
303
462
  const storageOptions = [...snapshots.entries()].map(([viewId, snapshot]) => ({
@@ -346,6 +505,31 @@ export default function StoragePanel() {
346
505
  <Plus className="h-3 w-3" />
347
506
  Add Entry
348
507
  </button>
508
+ <button
509
+ onClick={handleImportClick}
510
+ disabled={!selectedStorage}
511
+ className="flex items-center gap-1 px-3 h-8 text-xs bg-gray-700 hover:bg-gray-600 disabled:bg-gray-800 disabled:cursor-not-allowed text-gray-100 rounded transition-colors"
512
+ title="Import entries from a JSON snapshot"
513
+ >
514
+ <Upload className="h-3 w-3" />
515
+ Import
516
+ </button>
517
+ <button
518
+ onClick={handleExport}
519
+ disabled={!selectedStorage || entries.length === 0}
520
+ className="flex items-center gap-1 px-3 h-8 text-xs bg-gray-700 hover:bg-gray-600 disabled:bg-gray-800 disabled:cursor-not-allowed text-gray-100 rounded transition-colors"
521
+ title="Export entries to a JSON snapshot"
522
+ >
523
+ <Download className="h-3 w-3" />
524
+ Export
525
+ </button>
526
+ <input
527
+ ref={fileInputRef}
528
+ type="file"
529
+ accept="application/json,.json"
530
+ className="hidden"
531
+ onChange={handleFileChange}
532
+ />
349
533
  <div className="flex-1">
350
534
  <div className="relative">
351
535
  <Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
@@ -437,6 +621,22 @@ export default function StoragePanel() {
437
621
  supportedTypes={supportedTypes}
438
622
  entry={editingEntry}
439
623
  />
624
+
625
+ <ImportDialog
626
+ state={importFlight}
627
+ onApply={handleApplyImport}
628
+ onCancel={handleCloseImport}
629
+ onClose={handleCloseImport}
630
+ />
631
+
632
+ <ConfirmDialog
633
+ isOpen={alertState.isOpen}
634
+ onClose={() => setAlertState((prev) => ({ ...prev, isOpen: false }))}
635
+ onConfirm={() => setAlertState((prev) => ({ ...prev, isOpen: false }))}
636
+ title={alertState.title}
637
+ message={alertState.message}
638
+ type="alert"
639
+ />
440
640
  </div>
441
641
  );
442
642
  }
@@ -0,0 +1,30 @@
1
+ import type { StorageTarget } from '../shared/types';
2
+
3
+ export const downloadJson = (data: unknown, filename: string): void => {
4
+ const json = JSON.stringify(data, null, 2);
5
+ const blob = new Blob([json], { type: 'application/json' });
6
+ const url = URL.createObjectURL(blob);
7
+ const link = document.createElement('a');
8
+ link.href = url;
9
+ link.download = filename;
10
+ document.body.appendChild(link);
11
+ link.click();
12
+ document.body.removeChild(link);
13
+ URL.revokeObjectURL(url);
14
+ };
15
+
16
+ const sanitize = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, '-');
17
+
18
+ export const buildExportFilename = (
19
+ target: StorageTarget,
20
+ now: Date = new Date(),
21
+ ): string => {
22
+ const yyyy = now.getFullYear().toString().padStart(4, '0');
23
+ const mm = (now.getMonth() + 1).toString().padStart(2, '0');
24
+ const dd = now.getDate().toString().padStart(2, '0');
25
+ const hh = now.getHours().toString().padStart(2, '0');
26
+ const mi = now.getMinutes().toString().padStart(2, '0');
27
+ const ss = now.getSeconds().toString().padStart(2, '0');
28
+ const timestamp = `${yyyy}${mm}${dd}-${hh}${mi}${ss}`;
29
+ return `rozenite-storage-${sanitize(target.adapterId)}-${sanitize(target.storageId)}-${timestamp}.json`;
30
+ };
@@ -1 +0,0 @@
1
- *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{--background: 0 0% 100%;--foreground: 0 0% 3.9%;--card: 0 0% 100%;--card-foreground: 0 0% 3.9%;--popover: 0 0% 100%;--popover-foreground: 0 0% 3.9%;--primary: 0 0% 9%;--primary-foreground: 0 0% 98%;--secondary: 0 0% 96.1%;--secondary-foreground: 0 0% 9%;--muted: 0 0% 96.1%;--muted-foreground: 0 0% 45.1%;--accent: 0 0% 96.1%;--accent-foreground: 0 0% 9%;--destructive: 0 84.2% 60.2%;--destructive-foreground: 0 0% 98%;--border: 0 0% 89.8%;--input: 0 0% 89.8%;--ring: 0 0% 3.9%;--chart-1: 12 76% 61%;--chart-2: 173 58% 39%;--chart-3: 197 37% 24%;--chart-4: 43 74% 66%;--chart-5: 27 87% 67%;--radius: .5rem;--sidebar-background: 0 0% 98%;--sidebar-foreground: 240 5.3% 26.1%;--sidebar-primary: 240 5.9% 10%;--sidebar-primary-foreground: 0 0% 98%;--sidebar-accent: 240 4.8% 95.9%;--sidebar-accent-foreground: 240 5.9% 10%;--sidebar-border: 220 13% 91%;--sidebar-ring: 217.2 91.2% 59.8%}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground))}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.left-2{left:.5rem}.top-0{top:0}.top-1\/2{top:50%}.z-10{z-index:10}.z-50{z-index:50}.mx-4{margin-left:1rem;margin-right:1rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-6{margin-top:1.5rem}.block{display:block}.flex{display:flex}.table{display:table}.h-3{height:.75rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-8{height:2rem}.h-full{height:100%}.h-screen{height:100vh}.max-h-96{max-height:24rem}.max-h-\[90vh\]{max-height:90vh}.min-h-0{min-height:0px}.w-3{width:.75rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-8{width:2rem}.w-96{width:24rem}.w-\[90vw\]{width:90vw}.w-full{width:100%}.max-w-2xl{max-width:42rem}.max-w-full{max-width:100%}.flex-1{flex:1 1 0%}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.self-start{align-self:flex-start}.overflow-auto{overflow:auto}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-lg{border-radius:var(--radius)}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-gray-600{--tw-border-opacity: 1;border-color:rgb(75 85 99 / var(--tw-border-opacity, 1))}.border-gray-700{--tw-border-opacity: 1;border-color:rgb(55 65 81 / var(--tw-border-opacity, 1))}.border-gray-800{--tw-border-opacity: 1;border-color:rgb(31 41 55 / var(--tw-border-opacity, 1))}.bg-black{--tw-bg-opacity: 1;background-color:rgb(0 0 0 / var(--tw-bg-opacity, 1))}.bg-blue-600{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))}.bg-gray-600{--tw-bg-opacity: 1;background-color:rgb(75 85 99 / var(--tw-bg-opacity, 1))}.bg-gray-700{--tw-bg-opacity: 1;background-color:rgb(55 65 81 / var(--tw-bg-opacity, 1))}.bg-gray-800{--tw-bg-opacity: 1;background-color:rgb(31 41 55 / var(--tw-bg-opacity, 1))}.bg-gray-900{--tw-bg-opacity: 1;background-color:rgb(17 24 39 / var(--tw-bg-opacity, 1))}.bg-green-600{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1))}.bg-purple-600{--tw-bg-opacity: 1;background-color:rgb(147 51 234 / var(--tw-bg-opacity, 1))}.bg-red-600{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.bg-yellow-600{--tw-bg-opacity: 1;background-color:rgb(202 138 4 / var(--tw-bg-opacity, 1))}.bg-opacity-50{--tw-bg-opacity: .5}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-6{padding:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.pl-8{padding-left:2rem}.pr-3{padding-right:.75rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.leading-relaxed{line-height:1.625}.text-amber-400{--tw-text-opacity: 1;color:rgb(251 191 36 / var(--tw-text-opacity, 1))}.text-blue-300{--tw-text-opacity: 1;color:rgb(147 197 253 / var(--tw-text-opacity, 1))}.text-blue-400{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.text-blue-500{--tw-text-opacity: 1;color:rgb(59 130 246 / var(--tw-text-opacity, 1))}.text-cyan-500{--tw-text-opacity: 1;color:rgb(6 182 212 / var(--tw-text-opacity, 1))}.text-gray-100{--tw-text-opacity: 1;color:rgb(243 244 246 / var(--tw-text-opacity, 1))}.text-gray-200{--tw-text-opacity: 1;color:rgb(229 231 235 / var(--tw-text-opacity, 1))}.text-gray-300{--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.text-green-300{--tw-text-opacity: 1;color:rgb(134 239 172 / var(--tw-text-opacity, 1))}.text-green-400{--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.text-green-500{--tw-text-opacity: 1;color:rgb(34 197 94 / var(--tw-text-opacity, 1))}.text-orange-500{--tw-text-opacity: 1;color:rgb(249 115 22 / var(--tw-text-opacity, 1))}.text-pink-500{--tw-text-opacity: 1;color:rgb(236 72 153 / var(--tw-text-opacity, 1))}.text-purple-300{--tw-text-opacity: 1;color:rgb(216 180 254 / var(--tw-text-opacity, 1))}.text-purple-500{--tw-text-opacity: 1;color:rgb(168 85 247 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-red-500{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-yellow-500{--tw-text-opacity: 1;color:rgb(234 179 8 / var(--tw-text-opacity, 1))}.opacity-0{opacity:0}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}@keyframes enter{0%{opacity:var(--tw-enter-opacity, 1);transform:translate3d(var(--tw-enter-translate-x, 0),var(--tw-enter-translate-y, 0),0) scale3d(var(--tw-enter-scale, 1),var(--tw-enter-scale, 1),var(--tw-enter-scale, 1)) rotate(var(--tw-enter-rotate, 0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity, 1);transform:translate3d(var(--tw-exit-translate-x, 0),var(--tw-exit-translate-y, 0),0) scale3d(var(--tw-exit-scale, 1),var(--tw-exit-scale, 1),var(--tw-exit-scale, 1)) rotate(var(--tw-exit-rotate, 0))}}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:#1f2937;border-radius:4px}::-webkit-scrollbar-thumb{background:#374151;border-radius:4px;border:1px solid #1f2937}::-webkit-scrollbar-thumb:hover{background:#4b5563}::-webkit-scrollbar-thumb:active{background:#6b7280}::-webkit-scrollbar-corner{background:#1f2937}.placeholder\:text-gray-400::-moz-placeholder{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.placeholder\:text-gray-400::placeholder{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.hover\:bg-blue-700:hover{--tw-bg-opacity: 1;background-color:rgb(29 78 216 / var(--tw-bg-opacity, 1))}.hover\:bg-gray-600:hover{--tw-bg-opacity: 1;background-color:rgb(75 85 99 / var(--tw-bg-opacity, 1))}.hover\:bg-gray-700:hover{--tw-bg-opacity: 1;background-color:rgb(55 65 81 / var(--tw-bg-opacity, 1))}.hover\:bg-gray-800:hover{--tw-bg-opacity: 1;background-color:rgb(31 41 55 / var(--tw-bg-opacity, 1))}.hover\:bg-red-700:hover{--tw-bg-opacity: 1;background-color:rgb(185 28 28 / var(--tw-bg-opacity, 1))}.hover\:text-blue-400:hover{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.hover\:text-gray-200:hover{--tw-text-opacity: 1;color:rgb(229 231 235 / var(--tw-text-opacity, 1))}.hover\:text-red-400:hover{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.hover\:text-white:hover{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:bg-gray-600:disabled{--tw-bg-opacity: 1;background-color:rgb(75 85 99 / var(--tw-bg-opacity, 1))}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:opacity-100{opacity:1}