RestoreModal.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. import { useState, useRef, useEffect } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Upload, X, AlertTriangle, CheckCircle, SkipForward, RefreshCw, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
  4. import { Card, CardContent } from './Card';
  5. import { Button } from './Button';
  6. import { Toggle } from './Toggle';
  7. interface RestoreResult {
  8. success: boolean;
  9. message: string;
  10. restored?: Record<string, number>;
  11. skipped?: Record<string, number>;
  12. skipped_details?: Record<string, string[]>;
  13. files_restored?: number;
  14. total_skipped?: number;
  15. new_api_keys?: Array<{ name: string; key: string; key_prefix: string }>;
  16. }
  17. interface RestoreModalProps {
  18. onClose: () => void;
  19. onRestore: (file: File, overwrite: boolean) => Promise<RestoreResult>;
  20. onSuccess: () => void;
  21. }
  22. type ModalState = 'options' | 'restoring' | 'result';
  23. export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProps) {
  24. const { t } = useTranslation();
  25. const [state, setState] = useState<ModalState>('options');
  26. const [overwrite, setOverwrite] = useState(false);
  27. const [selectedFile, setSelectedFile] = useState<File | null>(null);
  28. const [result, setResult] = useState<RestoreResult | null>(null);
  29. const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
  30. const fileInputRef = useRef<HTMLInputElement>(null);
  31. useEffect(() => {
  32. const handleKeyDown = (e: KeyboardEvent) => {
  33. if (e.key === 'Escape' && state !== 'restoring') {
  34. // Use handleClose for result state to trigger onSuccess
  35. if (state === 'result' && result?.success) {
  36. onSuccess();
  37. }
  38. onClose();
  39. }
  40. };
  41. window.addEventListener('keydown', handleKeyDown);
  42. return () => window.removeEventListener('keydown', handleKeyDown);
  43. }, [onClose, onSuccess, state, result]);
  44. const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
  45. const file = e.target.files?.[0];
  46. if (file) {
  47. setSelectedFile(file);
  48. }
  49. };
  50. const handleRestore = async () => {
  51. if (!selectedFile) return;
  52. setState('restoring');
  53. try {
  54. const restoreResult = await onRestore(selectedFile, overwrite);
  55. setResult(restoreResult);
  56. setState('result');
  57. // Don't call onSuccess here - wait until modal closes
  58. // This prevents race condition with query cache
  59. } catch {
  60. setResult({
  61. success: false,
  62. message: t('backup.failedToRestore'),
  63. });
  64. setState('result');
  65. }
  66. };
  67. const handleClose = () => {
  68. // If restore was successful, trigger refresh before closing
  69. if (result?.success) {
  70. onSuccess();
  71. }
  72. onClose();
  73. };
  74. const toggleCategory = (category: string) => {
  75. setExpandedCategories(prev => {
  76. const next = new Set(prev);
  77. if (next.has(category)) {
  78. next.delete(category);
  79. } else {
  80. next.add(category);
  81. }
  82. return next;
  83. });
  84. };
  85. const totalRestored = result?.restored
  86. ? Object.values(result.restored).reduce((a, b) => a + b, 0) + (result.files_restored || 0)
  87. : 0;
  88. const totalSkipped = result?.total_skipped || 0;
  89. return (
  90. <div
  91. className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
  92. onMouseDown={(e) => {
  93. // Only close if clicking directly on the backdrop, not on children
  94. if (e.target === e.currentTarget && state !== 'restoring') {
  95. onClose();
  96. }
  97. }}
  98. >
  99. <Card className="w-full max-w-lg">
  100. <CardContent className="p-0">
  101. {/* Header */}
  102. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  103. <div className="flex items-center gap-3">
  104. <div className={`p-2 rounded-full ${
  105. state === 'result' && result?.success
  106. ? 'bg-bambu-green/20 text-bambu-green'
  107. : state === 'result' && !result?.success
  108. ? 'bg-red-500/20 text-red-500'
  109. : 'bg-blue-500/20 text-blue-500'
  110. }`}>
  111. {state === 'result' && result?.success ? (
  112. <CheckCircle className="w-5 h-5" />
  113. ) : state === 'result' && !result?.success ? (
  114. <AlertTriangle className="w-5 h-5" />
  115. ) : (
  116. <Upload className="w-5 h-5" />
  117. )}
  118. </div>
  119. <div>
  120. <h3 className="text-lg font-semibold text-white">
  121. {state === 'options' && t('backup.restoreBackup')}
  122. {state === 'restoring' && t('backup.restoring')}
  123. {state === 'result' && (result?.success ? t('backup.restoreComplete') : t('backup.restoreFailed2'))}
  124. </h3>
  125. <p className="text-sm text-bambu-gray">
  126. {state === 'options' && t('backup.importSettings')}
  127. {state === 'restoring' && t('backup.pleaseWaitRestoring')}
  128. {state === 'result' && result?.message}
  129. </p>
  130. </div>
  131. </div>
  132. {state !== 'restoring' && (
  133. <button
  134. onClick={handleClose}
  135. className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
  136. >
  137. <X className="w-5 h-5" />
  138. </button>
  139. )}
  140. </div>
  141. {/* Options State */}
  142. {state === 'options' && (
  143. <>
  144. <div className="p-4 space-y-4">
  145. {/* File Selection */}
  146. <div>
  147. <input
  148. ref={fileInputRef}
  149. type="file"
  150. accept=".json,.zip"
  151. className="hidden"
  152. onChange={handleFileSelect}
  153. />
  154. <button
  155. type="button"
  156. onClick={() => fileInputRef.current?.click()}
  157. className={`w-full p-4 border-2 border-dashed rounded-lg transition-colors ${
  158. selectedFile
  159. ? 'border-bambu-green bg-bambu-green/10'
  160. : 'border-bambu-dark-tertiary hover:border-bambu-gray'
  161. }`}
  162. >
  163. {selectedFile ? (
  164. <div className="flex items-center justify-center gap-2 text-bambu-green">
  165. <CheckCircle className="w-5 h-5" />
  166. <span className="font-medium">{selectedFile.name}</span>
  167. </div>
  168. ) : (
  169. <div className="flex flex-col items-center gap-2 text-bambu-gray">
  170. <Upload className="w-8 h-8" />
  171. <span>{t('backup.selectBackupFile')}</span>
  172. </div>
  173. )}
  174. </button>
  175. </div>
  176. {/* Info Box */}
  177. <div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/30">
  178. <div className="flex items-start gap-2 text-sm">
  179. <AlertTriangle className="w-4 h-4 text-blue-500 dark:text-blue-400 mt-0.5 flex-shrink-0" />
  180. <div className="text-blue-700 dark:text-blue-200">
  181. <p className="font-medium mb-1">{t('backup.duplicateHandling')}</p>
  182. <ul className="text-blue-600 dark:text-blue-200/80 space-y-1 text-xs">
  183. <li><strong>{t('backup.matchPrinters')}</strong> - {t('backup.matchPrintersBy')}</li>
  184. <li><strong>{t('backup.matchSmartPlugs')}</strong> - {t('backup.matchSmartPlugsBy')}</li>
  185. <li><strong>{t('backup.matchNotificationProviders')}</strong> - {t('backup.matchNotificationProvidersBy')}</li>
  186. <li><strong>{t('backup.matchFilaments')}</strong> - {t('backup.matchFilamentsBy')}</li>
  187. <li><strong>{t('backup.matchArchives')}</strong> - {t('backup.matchArchivesBy')}</li>
  188. <li><strong>{t('backup.matchPendingUploads')}</strong> - {t('backup.matchPendingUploadsBy')}</li>
  189. <li><strong>{t('backup.matchSettingsTemplates')}</strong> - {t('backup.matchSettingsTemplatesBy')}</li>
  190. </ul>
  191. </div>
  192. </div>
  193. </div>
  194. {/* Overwrite Toggle */}
  195. <div className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
  196. <div className="flex items-center justify-between">
  197. <div>
  198. <p className="text-white font-medium flex items-center gap-2">
  199. {overwrite ? (
  200. <RefreshCw className="w-4 h-4 text-orange-400" />
  201. ) : (
  202. <SkipForward className="w-4 h-4 text-bambu-gray" />
  203. )}
  204. {overwrite ? t('backup.replaceExisting') : t('backup.keepExisting')}
  205. </p>
  206. <p className="text-sm text-bambu-gray mt-1">
  207. {overwrite
  208. ? t('backup.overwriteDescription')
  209. : t('backup.keepDescription')}
  210. </p>
  211. </div>
  212. <Toggle checked={overwrite} onChange={setOverwrite} />
  213. </div>
  214. </div>
  215. {overwrite && (
  216. <div className="p-3 rounded-lg bg-orange-500/10 border border-orange-500/30">
  217. <div className="flex items-start gap-2 text-sm">
  218. <AlertTriangle className="w-4 h-4 text-orange-500 dark:text-orange-400 mt-0.5 flex-shrink-0" />
  219. <div className="text-orange-700 dark:text-orange-200">
  220. <span className="font-medium">{t('backup.overwriteCaution')}</span> {t('backup.overwriteWarning')}
  221. </div>
  222. </div>
  223. </div>
  224. )}
  225. </div>
  226. {/* Footer */}
  227. <div className="flex items-center justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
  228. <Button type="button" variant="secondary" onClick={onClose}>
  229. {t('backup.cancel')}
  230. </Button>
  231. <Button
  232. type="button"
  233. onClick={handleRestore}
  234. disabled={!selectedFile}
  235. className="bg-bambu-green hover:bg-bambu-green-dark disabled:opacity-50"
  236. >
  237. <Upload className="w-4 h-4 mr-2" />
  238. {t('backup.restore')}
  239. </Button>
  240. </div>
  241. </>
  242. )}
  243. {/* Restoring State */}
  244. {state === 'restoring' && (
  245. <div className="p-8 flex flex-col items-center gap-4">
  246. <Loader2 className="w-12 h-12 text-bambu-green animate-spin" />
  247. <p className="text-bambu-gray">{t('backup.processingBackup')}</p>
  248. </div>
  249. )}
  250. {/* Result State */}
  251. {state === 'result' && result && (
  252. <>
  253. <div className="p-4 space-y-4 max-h-[400px] overflow-y-auto">
  254. {/* Summary */}
  255. <div className="grid grid-cols-2 gap-3">
  256. <div className="p-3 rounded-lg bg-bambu-green/10 border border-bambu-green/30">
  257. <div className="text-2xl font-bold text-bambu-green">{totalRestored}</div>
  258. <div className="text-sm text-bambu-gray">{t('backup.itemsRestored')}</div>
  259. </div>
  260. <div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
  261. <div className="text-2xl font-bold text-yellow-500">{totalSkipped}</div>
  262. <div className="text-sm text-bambu-gray">{t('backup.itemsSkipped')}</div>
  263. </div>
  264. </div>
  265. {/* Restored Details */}
  266. {result.restored && Object.entries(result.restored).some(([, count]) => count > 0) && (
  267. <div className="space-y-2">
  268. <h4 className="text-sm font-medium text-bambu-gray flex items-center gap-2">
  269. <CheckCircle className="w-4 h-4 text-bambu-green" />
  270. {t('backup.restored')}
  271. </h4>
  272. <div className="space-y-1">
  273. {Object.entries(result.restored)
  274. .filter(([, count]) => count > 0)
  275. .map(([key, count]) => (
  276. <div key={key} className="flex items-center justify-between text-sm p-2 rounded bg-bambu-dark">
  277. <span className="text-white">{t(`backup.categories.${key}`, key)}</span>
  278. <span className="text-bambu-green font-medium">{count}</span>
  279. </div>
  280. ))}
  281. {(result.files_restored || 0) > 0 && (
  282. <div className="flex items-center justify-between text-sm p-2 rounded bg-bambu-dark">
  283. <span className="text-white">{t('backup.filesCategory')}</span>
  284. <span className="text-bambu-green font-medium">{result.files_restored}</span>
  285. </div>
  286. )}
  287. </div>
  288. </div>
  289. )}
  290. {/* Skipped Details */}
  291. {result.skipped && Object.entries(result.skipped).some(([, count]) => count > 0) && (
  292. <div className="space-y-2">
  293. <h4 className="text-sm font-medium text-bambu-gray flex items-center gap-2">
  294. <SkipForward className="w-4 h-4 text-yellow-500" />
  295. {t('backup.skippedAlreadyExist')}
  296. </h4>
  297. <div className="space-y-1">
  298. {Object.entries(result.skipped)
  299. .filter(([, count]) => count > 0)
  300. .map(([key, count]) => {
  301. const details = result.skipped_details?.[key] || [];
  302. const isExpanded = expandedCategories.has(key);
  303. return (
  304. <div key={key}>
  305. <button
  306. onClick={() => details.length > 0 && toggleCategory(key)}
  307. className={`w-full flex items-center justify-between text-sm p-2 rounded bg-bambu-dark ${
  308. details.length > 0 ? 'hover:bg-bambu-dark-tertiary cursor-pointer' : ''
  309. }`}
  310. >
  311. <span className="text-white flex items-center gap-2">
  312. {t(`backup.categories.${key}`, key)}
  313. {details.length > 0 && (
  314. isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />
  315. )}
  316. </span>
  317. <span className="text-yellow-500 font-medium">{count}</span>
  318. </button>
  319. {isExpanded && details.length > 0 && (
  320. <div className="mt-1 ml-4 p-2 rounded bg-bambu-dark-tertiary text-xs text-bambu-gray space-y-1">
  321. {details.slice(0, 10).map((item, i) => (
  322. <div key={i}>{item}</div>
  323. ))}
  324. {details.length > 10 && (
  325. <div className="text-bambu-gray/60">{t('backup.andMore', { count: details.length - 10 })}</div>
  326. )}
  327. </div>
  328. )}
  329. </div>
  330. );
  331. })}
  332. </div>
  333. </div>
  334. )}
  335. {/* Newly Generated API Keys */}
  336. {result.new_api_keys && result.new_api_keys.length > 0 && (
  337. <div className="space-y-2">
  338. <h4 className="text-sm font-medium text-bambu-gray flex items-center gap-2">
  339. <AlertTriangle className="w-4 h-4 text-orange-500" />
  340. {t('backup.newApiKeysGenerated')}
  341. </h4>
  342. <div className="p-3 rounded bg-orange-500/10 border border-orange-500/30">
  343. <p className="text-xs text-orange-200 mb-2">
  344. {t('backup.keysShownOnce')}
  345. </p>
  346. <div className="space-y-2">
  347. {result.new_api_keys.map((apiKey: { name: string; key: string; key_prefix: string }, i: number) => (
  348. <div key={i} className="p-2 rounded bg-bambu-dark">
  349. <div className="text-sm text-white font-medium mb-1">{apiKey.name}</div>
  350. <div className="flex items-center gap-2">
  351. <code className="text-xs text-bambu-green bg-bambu-dark-tertiary px-2 py-1 rounded font-mono flex-1 break-all">
  352. {apiKey.key}
  353. </code>
  354. <button
  355. onClick={() => navigator.clipboard.writeText(apiKey.key)}
  356. className="text-xs text-bambu-gray hover:text-white px-2 py-1 rounded bg-bambu-dark-tertiary"
  357. >
  358. {t('backup.copy')}
  359. </button>
  360. </div>
  361. </div>
  362. ))}
  363. </div>
  364. </div>
  365. </div>
  366. )}
  367. {totalRestored === 0 && totalSkipped === 0 && (
  368. <div className="p-4 text-center text-bambu-gray">
  369. {t('backup.noDataFound')}
  370. </div>
  371. )}
  372. </div>
  373. {/* Footer */}
  374. <div className="flex items-center justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
  375. <Button onClick={handleClose}>
  376. {t('backup.close')}
  377. </Button>
  378. </div>
  379. </>
  380. )}
  381. </CardContent>
  382. </Card>
  383. </div>
  384. );
  385. }