@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/CHANGELOG.md +28 -0
- package/README.md +23 -0
- package/dist/devtools/assets/panel-DVxtKLI9.css +1 -0
- package/dist/devtools/assets/panel-DnjbFMGo.js +22 -0
- package/dist/devtools/panel.html +2 -2
- package/dist/react-native/chunks/index.require.cjs +1 -1
- package/dist/react-native/chunks/index.require.js +9 -5
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.cjs +1 -1
- package/dist/react-native/chunks/useRozeniteStoragePlugin.require.js +253 -191
- package/dist/react-native/index.d.ts +29 -6
- package/dist/rozenite.json +1 -1
- package/dist/sdk/index.d.ts +1 -0
- package/package.json +6 -6
- package/src/react-native/__tests__/import.test.ts +182 -0
- package/src/react-native/adapters/__tests__/mmkv.test.ts +141 -0
- package/src/react-native/adapters/mmkv.ts +15 -0
- package/src/react-native/import.ts +67 -0
- package/src/react-native/storage-view.ts +16 -8
- package/src/react-native/useRozeniteStoragePlugin.ts +61 -36
- package/src/shared/__tests__/snapshot.test.ts +361 -0
- package/src/shared/messaging.ts +25 -1
- package/src/shared/snapshot.ts +276 -0
- package/src/shared/types.ts +1 -0
- package/src/ui/import-dialog.tsx +261 -0
- package/src/ui/panel.tsx +257 -57
- package/src/ui/utils.ts +30 -0
- package/dist/devtools/assets/panel-DMhXYHH4.css +0 -1
- package/dist/devtools/assets/panel-eGOuOVos.js +0 -22
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_SUPPORTED_TYPES,
|
|
3
|
+
supportsType,
|
|
4
|
+
type StorageCapabilities,
|
|
5
|
+
type StorageEntry,
|
|
6
|
+
type StorageEntryType,
|
|
7
|
+
type StorageTarget,
|
|
8
|
+
} from './types';
|
|
9
|
+
|
|
10
|
+
const PLUGIN_ID = '@rozenite/storage-plugin';
|
|
11
|
+
const SUPPORTED_VERSION = 1;
|
|
12
|
+
|
|
13
|
+
export type StorageSnapshotV1 = {
|
|
14
|
+
version: 1;
|
|
15
|
+
plugin: string;
|
|
16
|
+
createdAt: string;
|
|
17
|
+
storage: {
|
|
18
|
+
adapterId: string;
|
|
19
|
+
storageId: string;
|
|
20
|
+
adapterName: string;
|
|
21
|
+
storageName: string;
|
|
22
|
+
capabilities: StorageCapabilities;
|
|
23
|
+
};
|
|
24
|
+
entries: StorageEntry[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ParseError = { path: string; message: string };
|
|
28
|
+
|
|
29
|
+
export type ParseResult =
|
|
30
|
+
| { ok: true; snapshot: StorageSnapshotV1 }
|
|
31
|
+
| { ok: false; error: ParseError };
|
|
32
|
+
|
|
33
|
+
class ParseException extends Error {
|
|
34
|
+
constructor(
|
|
35
|
+
public path: string,
|
|
36
|
+
message: string,
|
|
37
|
+
) {
|
|
38
|
+
super(message);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const describe = (value: unknown): string => {
|
|
43
|
+
if (value === null) return 'null';
|
|
44
|
+
if (Array.isArray(value)) return 'array';
|
|
45
|
+
return typeof value;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
49
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
50
|
+
|
|
51
|
+
const expectObject = (
|
|
52
|
+
value: unknown,
|
|
53
|
+
path: string,
|
|
54
|
+
): Record<string, unknown> => {
|
|
55
|
+
if (!isPlainObject(value)) {
|
|
56
|
+
throw new ParseException(path, `Expected object, got ${describe(value)}`);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const expectString = (value: unknown, path: string): string => {
|
|
62
|
+
if (typeof value !== 'string') {
|
|
63
|
+
throw new ParseException(path, `Expected string, got ${describe(value)}`);
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const expectArray = (value: unknown, path: string): unknown[] => {
|
|
69
|
+
if (!Array.isArray(value)) {
|
|
70
|
+
throw new ParseException(path, `Expected array, got ${describe(value)}`);
|
|
71
|
+
}
|
|
72
|
+
return value;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const expectEntryType = (value: unknown, path: string): StorageEntryType => {
|
|
76
|
+
if (
|
|
77
|
+
typeof value !== 'string' ||
|
|
78
|
+
!DEFAULT_SUPPORTED_TYPES.includes(value as StorageEntryType)
|
|
79
|
+
) {
|
|
80
|
+
throw new ParseException(
|
|
81
|
+
path,
|
|
82
|
+
`Expected one of ${DEFAULT_SUPPORTED_TYPES.join(', ')}, got ${describe(value)}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return value as StorageEntryType;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const parseCapabilities = (raw: unknown, path: string): StorageCapabilities => {
|
|
89
|
+
const obj = expectObject(raw, path);
|
|
90
|
+
const supported = expectArray(obj.supportedTypes, `${path}.supportedTypes`);
|
|
91
|
+
const supportedTypes = supported.map((type, index) =>
|
|
92
|
+
expectEntryType(type, `${path}.supportedTypes[${index}]`),
|
|
93
|
+
);
|
|
94
|
+
return { supportedTypes };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const parseStorageMeta = (raw: unknown): StorageSnapshotV1['storage'] => {
|
|
98
|
+
const obj = expectObject(raw, 'storage');
|
|
99
|
+
return {
|
|
100
|
+
adapterId: expectString(obj.adapterId, 'storage.adapterId'),
|
|
101
|
+
storageId: expectString(obj.storageId, 'storage.storageId'),
|
|
102
|
+
adapterName: expectString(obj.adapterName, 'storage.adapterName'),
|
|
103
|
+
storageName: expectString(obj.storageName, 'storage.storageName'),
|
|
104
|
+
capabilities: parseCapabilities(obj.capabilities, 'storage.capabilities'),
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const parseEntry = (raw: unknown, path: string): StorageEntry => {
|
|
109
|
+
const obj = expectObject(raw, path);
|
|
110
|
+
const key = expectString(obj.key, `${path}.key`);
|
|
111
|
+
const type = expectEntryType(obj.type, `${path}.type`);
|
|
112
|
+
const valuePath = `${path}.value`;
|
|
113
|
+
|
|
114
|
+
switch (type) {
|
|
115
|
+
case 'string': {
|
|
116
|
+
const value = obj.value;
|
|
117
|
+
if (typeof value !== 'string') {
|
|
118
|
+
throw new ParseException(
|
|
119
|
+
valuePath,
|
|
120
|
+
`Expected string for type "string", got ${describe(value)}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return { key, type: 'string', value };
|
|
124
|
+
}
|
|
125
|
+
case 'number': {
|
|
126
|
+
const value = obj.value;
|
|
127
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
128
|
+
throw new ParseException(
|
|
129
|
+
valuePath,
|
|
130
|
+
`Expected finite number for type "number", got ${describe(value)}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return { key, type: 'number', value };
|
|
134
|
+
}
|
|
135
|
+
case 'boolean': {
|
|
136
|
+
const value = obj.value;
|
|
137
|
+
if (typeof value !== 'boolean') {
|
|
138
|
+
throw new ParseException(
|
|
139
|
+
valuePath,
|
|
140
|
+
`Expected boolean for type "boolean", got ${describe(value)}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return { key, type: 'boolean', value };
|
|
144
|
+
}
|
|
145
|
+
case 'buffer': {
|
|
146
|
+
const value = obj.value;
|
|
147
|
+
if (!Array.isArray(value)) {
|
|
148
|
+
throw new ParseException(
|
|
149
|
+
valuePath,
|
|
150
|
+
`Expected number[] for type "buffer", got ${describe(value)}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const bytes = value.map((byte, index) => {
|
|
154
|
+
if (
|
|
155
|
+
typeof byte !== 'number' ||
|
|
156
|
+
!Number.isInteger(byte) ||
|
|
157
|
+
byte < 0 ||
|
|
158
|
+
byte > 255
|
|
159
|
+
) {
|
|
160
|
+
throw new ParseException(
|
|
161
|
+
`${valuePath}[${index}]`,
|
|
162
|
+
`Expected uint8 (integer 0-255), got ${describe(byte)}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return byte;
|
|
166
|
+
});
|
|
167
|
+
return { key, type: 'buffer', value: bytes };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export const parseSnapshot = (raw: unknown): ParseResult => {
|
|
173
|
+
try {
|
|
174
|
+
const root = expectObject(raw, '$');
|
|
175
|
+
const version = root.version;
|
|
176
|
+
if (version !== SUPPORTED_VERSION) {
|
|
177
|
+
throw new ParseException(
|
|
178
|
+
'version',
|
|
179
|
+
`Unsupported snapshot version: ${describe(version)} (this build supports version ${SUPPORTED_VERSION})`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
const plugin = expectString(root.plugin, 'plugin');
|
|
183
|
+
const createdAt = expectString(root.createdAt, 'createdAt');
|
|
184
|
+
const storage = parseStorageMeta(root.storage);
|
|
185
|
+
const rawEntries = expectArray(root.entries, 'entries');
|
|
186
|
+
const entries = rawEntries.map((entry, index) =>
|
|
187
|
+
parseEntry(entry, `entries[${index}]`),
|
|
188
|
+
);
|
|
189
|
+
return {
|
|
190
|
+
ok: true,
|
|
191
|
+
snapshot: {
|
|
192
|
+
version: SUPPORTED_VERSION,
|
|
193
|
+
plugin,
|
|
194
|
+
createdAt,
|
|
195
|
+
storage,
|
|
196
|
+
entries,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (error instanceof ParseException) {
|
|
201
|
+
return { ok: false, error: { path: error.path, message: error.message } };
|
|
202
|
+
}
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export const buildSnapshot = (args: {
|
|
208
|
+
target: StorageTarget;
|
|
209
|
+
adapterName: string;
|
|
210
|
+
storageName: string;
|
|
211
|
+
capabilities: StorageCapabilities;
|
|
212
|
+
entries: StorageEntry[];
|
|
213
|
+
}): StorageSnapshotV1 => ({
|
|
214
|
+
version: 1,
|
|
215
|
+
plugin: PLUGIN_ID,
|
|
216
|
+
createdAt: new Date().toISOString(),
|
|
217
|
+
storage: {
|
|
218
|
+
adapterId: args.target.adapterId,
|
|
219
|
+
storageId: args.target.storageId,
|
|
220
|
+
adapterName: args.adapterName,
|
|
221
|
+
storageName: args.storageName,
|
|
222
|
+
capabilities: args.capabilities,
|
|
223
|
+
},
|
|
224
|
+
entries: args.entries,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
export type ImportPreview = {
|
|
228
|
+
newKeys: string[];
|
|
229
|
+
overwriteKeys: string[];
|
|
230
|
+
skippedKeys: { key: string; reason: 'blacklist' }[];
|
|
231
|
+
unsupportedTypes: { key: string; type: StorageEntryType }[];
|
|
232
|
+
metadataMismatch: boolean;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export const computePreview = (
|
|
236
|
+
snapshot: StorageSnapshotV1,
|
|
237
|
+
current: {
|
|
238
|
+
target: StorageTarget;
|
|
239
|
+
capabilities: StorageCapabilities;
|
|
240
|
+
entryKeys: Set<string>;
|
|
241
|
+
isBlacklisted: (key: string) => boolean;
|
|
242
|
+
},
|
|
243
|
+
): ImportPreview => {
|
|
244
|
+
const newKeys: string[] = [];
|
|
245
|
+
const overwriteKeys: string[] = [];
|
|
246
|
+
const skippedKeys: { key: string; reason: 'blacklist' }[] = [];
|
|
247
|
+
const unsupportedTypes: { key: string; type: StorageEntryType }[] = [];
|
|
248
|
+
|
|
249
|
+
for (const entry of snapshot.entries) {
|
|
250
|
+
if (!supportsType(current.capabilities, entry.type)) {
|
|
251
|
+
unsupportedTypes.push({ key: entry.key, type: entry.type });
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (current.isBlacklisted(entry.key)) {
|
|
255
|
+
skippedKeys.push({ key: entry.key, reason: 'blacklist' });
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (current.entryKeys.has(entry.key)) {
|
|
259
|
+
overwriteKeys.push(entry.key);
|
|
260
|
+
} else {
|
|
261
|
+
newKeys.push(entry.key);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const metadataMismatch =
|
|
266
|
+
snapshot.storage.adapterId !== current.target.adapterId ||
|
|
267
|
+
snapshot.storage.storageId !== current.target.storageId;
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
newKeys,
|
|
271
|
+
overwriteKeys,
|
|
272
|
+
skippedKeys,
|
|
273
|
+
unsupportedTypes,
|
|
274
|
+
metadataMismatch,
|
|
275
|
+
};
|
|
276
|
+
};
|
package/src/shared/types.ts
CHANGED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { AlertTriangle, CheckCircle2, Loader2, X, XCircle } from 'lucide-react';
|
|
2
|
+
import type { StorageEntry, StorageTarget } from '../shared/types';
|
|
3
|
+
import type { ImportPreview, StorageSnapshotV1 } from '../shared/snapshot';
|
|
4
|
+
|
|
5
|
+
export type ImportFlightState =
|
|
6
|
+
| {
|
|
7
|
+
phase: 'preview';
|
|
8
|
+
target: StorageTarget;
|
|
9
|
+
targetLabel: string;
|
|
10
|
+
snapshot: StorageSnapshotV1;
|
|
11
|
+
preview: ImportPreview;
|
|
12
|
+
entriesToWrite: StorageEntry[];
|
|
13
|
+
}
|
|
14
|
+
| {
|
|
15
|
+
phase: 'importing';
|
|
16
|
+
target: StorageTarget;
|
|
17
|
+
total: number;
|
|
18
|
+
written: number;
|
|
19
|
+
}
|
|
20
|
+
| {
|
|
21
|
+
phase: 'result';
|
|
22
|
+
ok: true;
|
|
23
|
+
written: number;
|
|
24
|
+
}
|
|
25
|
+
| {
|
|
26
|
+
phase: 'result';
|
|
27
|
+
ok: false;
|
|
28
|
+
written: number;
|
|
29
|
+
total: number;
|
|
30
|
+
failedKey?: string;
|
|
31
|
+
error: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type ImportDialogProps = {
|
|
35
|
+
state: ImportFlightState | null;
|
|
36
|
+
onApply: () => void;
|
|
37
|
+
onCancel: () => void;
|
|
38
|
+
onClose: () => void;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const KeyList = ({ title, items }: { title: string; items: string[] }) => {
|
|
42
|
+
if (items.length === 0) return null;
|
|
43
|
+
return (
|
|
44
|
+
<div>
|
|
45
|
+
<div className="text-xs font-medium text-gray-300">
|
|
46
|
+
{title} ({items.length})
|
|
47
|
+
</div>
|
|
48
|
+
<div className="mt-1 max-h-24 overflow-auto rounded bg-gray-900 px-2 py-1 font-mono text-xs text-gray-200">
|
|
49
|
+
{items.slice(0, 50).join(', ')}
|
|
50
|
+
{items.length > 50 ? `, +${items.length - 50} more` : ''}
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const PreviewBody = ({
|
|
57
|
+
state,
|
|
58
|
+
onApply,
|
|
59
|
+
onCancel,
|
|
60
|
+
}: {
|
|
61
|
+
state: Extract<ImportFlightState, { phase: 'preview' }>;
|
|
62
|
+
onApply: () => void;
|
|
63
|
+
onCancel: () => void;
|
|
64
|
+
}) => {
|
|
65
|
+
const { snapshot, preview, entriesToWrite, targetLabel } = state;
|
|
66
|
+
const hasUnsupported = preview.unsupportedTypes.length > 0;
|
|
67
|
+
const sourceLabel = `${snapshot.storage.adapterName} / ${snapshot.storage.storageName}`;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<>
|
|
71
|
+
<div className="space-y-3">
|
|
72
|
+
{preview.metadataMismatch && (
|
|
73
|
+
<div className="flex items-start gap-2 rounded border border-yellow-700 bg-yellow-900/30 p-2 text-xs text-yellow-200">
|
|
74
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
|
75
|
+
<div>
|
|
76
|
+
This file was exported from <strong>{sourceLabel}</strong>. You
|
|
77
|
+
are importing into <strong>{targetLabel}</strong>.
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
|
|
82
|
+
{hasUnsupported && (
|
|
83
|
+
<div className="flex items-start gap-2 rounded border border-red-700 bg-red-900/30 p-2 text-xs text-red-200">
|
|
84
|
+
<XCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
|
85
|
+
<div>
|
|
86
|
+
{preview.unsupportedTypes.length}{' '}
|
|
87
|
+
{preview.unsupportedTypes.length === 1
|
|
88
|
+
? 'entry has a type'
|
|
89
|
+
: 'entries have types'}{' '}
|
|
90
|
+
not supported by this storage. Remove them from the file and try
|
|
91
|
+
again.
|
|
92
|
+
<div className="mt-1 max-h-20 overflow-auto rounded bg-gray-900 px-2 py-1 font-mono text-xs text-red-100">
|
|
93
|
+
{preview.unsupportedTypes
|
|
94
|
+
.map((u) => `${u.key} (${u.type})`)
|
|
95
|
+
.join(', ')}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
|
|
101
|
+
<div className="grid grid-cols-3 gap-2 text-xs">
|
|
102
|
+
<div className="rounded bg-gray-900 p-2">
|
|
103
|
+
<div className="text-gray-400">New</div>
|
|
104
|
+
<div className="text-base font-semibold text-green-400">
|
|
105
|
+
{preview.newKeys.length}
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
<div className="rounded bg-gray-900 p-2">
|
|
109
|
+
<div className="text-gray-400">Overwrite</div>
|
|
110
|
+
<div className="text-base font-semibold text-yellow-400">
|
|
111
|
+
{preview.overwriteKeys.length}
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
<div className="rounded bg-gray-900 p-2">
|
|
115
|
+
<div className="text-gray-400">Skipped</div>
|
|
116
|
+
<div className="text-base font-semibold text-gray-400">
|
|
117
|
+
{preview.skippedKeys.length}
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<KeyList title="New keys" items={preview.newKeys} />
|
|
123
|
+
<KeyList title="Overwrite keys" items={preview.overwriteKeys} />
|
|
124
|
+
<KeyList
|
|
125
|
+
title="Skipped (filtered by storage)"
|
|
126
|
+
items={preview.skippedKeys.map((s) => s.key)}
|
|
127
|
+
/>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div className="mt-6 flex items-center justify-end gap-2">
|
|
131
|
+
<button
|
|
132
|
+
onClick={onCancel}
|
|
133
|
+
className="rounded px-4 py-2 text-sm text-gray-300 transition-colors hover:bg-gray-700 hover:text-white"
|
|
134
|
+
>
|
|
135
|
+
Cancel
|
|
136
|
+
</button>
|
|
137
|
+
<button
|
|
138
|
+
onClick={onApply}
|
|
139
|
+
disabled={hasUnsupported}
|
|
140
|
+
className="rounded bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-600"
|
|
141
|
+
>
|
|
142
|
+
Apply ({entriesToWrite.length})
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
</>
|
|
146
|
+
);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const ImportingBody = ({
|
|
150
|
+
state,
|
|
151
|
+
}: {
|
|
152
|
+
state: Extract<ImportFlightState, { phase: 'importing' }>;
|
|
153
|
+
}) => (
|
|
154
|
+
<div className="flex flex-col items-center gap-3 py-6">
|
|
155
|
+
<Loader2 className="h-6 w-6 animate-spin text-blue-400" />
|
|
156
|
+
<div className="text-sm text-gray-200">
|
|
157
|
+
Importing… {state.written} / {state.total}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const ResultBody = ({
|
|
163
|
+
state,
|
|
164
|
+
onClose,
|
|
165
|
+
}: {
|
|
166
|
+
state: Extract<ImportFlightState, { phase: 'result' }>;
|
|
167
|
+
onClose: () => void;
|
|
168
|
+
}) => (
|
|
169
|
+
<>
|
|
170
|
+
{state.ok ? (
|
|
171
|
+
<div className="flex flex-col items-center gap-3 py-4">
|
|
172
|
+
<CheckCircle2 className="h-8 w-8 text-green-400" />
|
|
173
|
+
<div className="text-sm text-gray-200">
|
|
174
|
+
Imported {state.written} {state.written === 1 ? 'entry' : 'entries'}.
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
) : (
|
|
178
|
+
<div className="space-y-2 py-2">
|
|
179
|
+
<div className="flex items-center gap-2 text-red-300">
|
|
180
|
+
<XCircle className="h-5 w-5" />
|
|
181
|
+
<div className="text-sm font-medium">
|
|
182
|
+
Import failed after {state.written} of {state.total}.
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
{state.failedKey && (
|
|
186
|
+
<div className="text-xs text-gray-300">
|
|
187
|
+
Failed at key:{' '}
|
|
188
|
+
<span className="font-mono text-gray-100">{state.failedKey}</span>
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
<div className="rounded bg-gray-900 px-2 py-1 font-mono text-xs text-red-200">
|
|
192
|
+
{state.error}
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
<div className="mt-4 flex items-center justify-end">
|
|
198
|
+
<button
|
|
199
|
+
onClick={onClose}
|
|
200
|
+
autoFocus
|
|
201
|
+
className="rounded bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700"
|
|
202
|
+
>
|
|
203
|
+
Close
|
|
204
|
+
</button>
|
|
205
|
+
</div>
|
|
206
|
+
</>
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
export const ImportDialog = ({
|
|
210
|
+
state,
|
|
211
|
+
onApply,
|
|
212
|
+
onCancel,
|
|
213
|
+
onClose,
|
|
214
|
+
}: ImportDialogProps) => {
|
|
215
|
+
if (!state) return null;
|
|
216
|
+
|
|
217
|
+
const title =
|
|
218
|
+
state.phase === 'preview'
|
|
219
|
+
? 'Import snapshot'
|
|
220
|
+
: state.phase === 'importing'
|
|
221
|
+
? 'Importing…'
|
|
222
|
+
: state.ok
|
|
223
|
+
? 'Import complete'
|
|
224
|
+
: 'Import failed';
|
|
225
|
+
|
|
226
|
+
// Importing phase: dialog is locked; only the success/failure transition closes it.
|
|
227
|
+
const isDismissible = state.phase !== 'importing';
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<div
|
|
231
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
|
232
|
+
onClick={isDismissible ? onClose : undefined}
|
|
233
|
+
>
|
|
234
|
+
<div
|
|
235
|
+
className="mx-4 w-[28rem] max-w-full rounded-lg bg-gray-800 p-6"
|
|
236
|
+
onClick={(event) => event.stopPropagation()}
|
|
237
|
+
>
|
|
238
|
+
<div className="mb-4 flex items-center justify-between">
|
|
239
|
+
<h2 className="text-lg font-semibold text-gray-100">{title}</h2>
|
|
240
|
+
{isDismissible && (
|
|
241
|
+
<button
|
|
242
|
+
onClick={onClose}
|
|
243
|
+
className="rounded p-1 text-gray-400 transition-colors hover:bg-gray-700 hover:text-gray-200"
|
|
244
|
+
title="Close dialog"
|
|
245
|
+
>
|
|
246
|
+
<X className="h-4 w-4" />
|
|
247
|
+
</button>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
{state.phase === 'preview' && (
|
|
252
|
+
<PreviewBody state={state} onApply={onApply} onCancel={onCancel} />
|
|
253
|
+
)}
|
|
254
|
+
{state.phase === 'importing' && <ImportingBody state={state} />}
|
|
255
|
+
{state.phase === 'result' && (
|
|
256
|
+
<ResultBody state={state} onClose={onClose} />
|
|
257
|
+
)}
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
);
|
|
261
|
+
};
|