BackupModal.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. import { useEffect, useState } from 'react';
  2. import { Download, X, Settings, Bell, FileText, Plug, Printer, Palette, Wrench, Archive, Loader2, Key, AlertTriangle, Link, FolderKanban, Upload, Camera } from 'lucide-react';
  3. import { useTranslation } from 'react-i18next';
  4. import { Card, CardContent } from './Card';
  5. import { Button } from './Button';
  6. import { Toggle } from './Toggle';
  7. interface BackupCategory {
  8. id: string;
  9. labelKey: string;
  10. defaultLabel: string;
  11. icon: React.ReactNode;
  12. default: boolean;
  13. description: string;
  14. requiresPrinters?: boolean;
  15. }
  16. const BACKUP_CATEGORIES: BackupCategory[] = [
  17. {
  18. id: 'settings',
  19. labelKey: 'backup.categories.settings',
  20. defaultLabel: 'App Settings',
  21. icon: <Settings className="w-4 h-4" />,
  22. default: true,
  23. description: 'Language, theme, update preferences',
  24. },
  25. {
  26. id: 'notifications',
  27. labelKey: 'backup.categories.notifications',
  28. defaultLabel: 'Notification Providers',
  29. icon: <Bell className="w-4 h-4" />,
  30. default: true,
  31. description: 'ntfy, Pushover, Discord, etc.',
  32. },
  33. {
  34. id: 'templates',
  35. labelKey: 'backup.categories.templates',
  36. defaultLabel: 'Notification Templates',
  37. icon: <FileText className="w-4 h-4" />,
  38. default: true,
  39. description: 'Custom message templates',
  40. },
  41. {
  42. id: 'smart_plugs',
  43. labelKey: 'backup.categories.smartPlugs',
  44. defaultLabel: 'Smart Plugs',
  45. icon: <Plug className="w-4 h-4" />,
  46. default: true,
  47. description: 'Tasmota plug configurations',
  48. },
  49. {
  50. id: 'external_links',
  51. labelKey: 'backup.categories.externalLinks',
  52. defaultLabel: 'External Links',
  53. icon: <Link className="w-4 h-4" />,
  54. default: true,
  55. description: 'Sidebar links to external services',
  56. },
  57. {
  58. id: 'printers',
  59. labelKey: 'backup.categories.printers',
  60. defaultLabel: 'Printers',
  61. icon: <Printer className="w-4 h-4" />,
  62. default: false,
  63. description: 'Printer info (access codes excluded)',
  64. },
  65. {
  66. id: 'plate_calibration',
  67. labelKey: 'backup.categories.plateCalibration',
  68. defaultLabel: 'Plate Detection',
  69. icon: <Camera className="w-4 h-4" />,
  70. default: false,
  71. description: 'Empty plate reference images',
  72. requiresPrinters: true,
  73. },
  74. {
  75. id: 'filaments',
  76. labelKey: 'backup.categories.filaments',
  77. defaultLabel: 'Filament Inventory',
  78. icon: <Palette className="w-4 h-4" />,
  79. default: false,
  80. description: 'Filament types and costs',
  81. },
  82. {
  83. id: 'maintenance',
  84. labelKey: 'backup.categories.maintenance',
  85. defaultLabel: 'Maintenance Types',
  86. icon: <Wrench className="w-4 h-4" />,
  87. default: false,
  88. description: 'Custom maintenance schedules',
  89. },
  90. {
  91. id: 'archives',
  92. labelKey: 'backup.categories.archives',
  93. defaultLabel: 'Print Archives',
  94. icon: <Archive className="w-4 h-4" />,
  95. default: false,
  96. description: 'All print data + files (3MF, thumbnails, photos)',
  97. },
  98. {
  99. id: 'projects',
  100. labelKey: 'backup.categories.projects',
  101. defaultLabel: 'Projects',
  102. icon: <FolderKanban className="w-4 h-4" />,
  103. default: false,
  104. description: 'Projects, BOM items, and attachments',
  105. },
  106. {
  107. id: 'pending_uploads',
  108. labelKey: 'backup.categories.pendingUploads',
  109. defaultLabel: 'Pending Uploads',
  110. icon: <Upload className="w-4 h-4" />,
  111. default: false,
  112. description: 'Virtual printer uploads awaiting review',
  113. },
  114. {
  115. id: 'api_keys',
  116. labelKey: 'backup.categories.apiKeys',
  117. defaultLabel: 'API Keys',
  118. icon: <Key className="w-4 h-4" />,
  119. default: false,
  120. description: 'Webhook API keys (new keys generated on import)',
  121. },
  122. ];
  123. interface BackupModalProps {
  124. onClose: () => void;
  125. onExport: (categories: Record<string, boolean>) => Promise<void>;
  126. }
  127. export function BackupModal({ onClose, onExport }: BackupModalProps) {
  128. const { t } = useTranslation();
  129. const [selected, setSelected] = useState<Record<string, boolean>>(() => {
  130. const initial: Record<string, boolean> = {};
  131. BACKUP_CATEGORIES.forEach((cat) => {
  132. initial[cat.id] = cat.default;
  133. });
  134. return initial;
  135. });
  136. const [includeAccessCodes, setIncludeAccessCodes] = useState(false);
  137. const [isExporting, setIsExporting] = useState(false);
  138. // Close on Escape key
  139. useEffect(() => {
  140. const handleKeyDown = (e: KeyboardEvent) => {
  141. if (e.key === 'Escape') onClose();
  142. };
  143. window.addEventListener('keydown', handleKeyDown);
  144. return () => window.removeEventListener('keydown', handleKeyDown);
  145. }, [onClose]);
  146. const toggleCategory = (id: string) => {
  147. setSelected((prev) => ({ ...prev, [id]: !prev[id] }));
  148. };
  149. const selectAll = () => {
  150. const all: Record<string, boolean> = {};
  151. BACKUP_CATEGORIES.forEach((cat) => {
  152. all[cat.id] = true;
  153. });
  154. setSelected(all);
  155. };
  156. const selectNone = () => {
  157. const none: Record<string, boolean> = {};
  158. BACKUP_CATEGORIES.forEach((cat) => {
  159. none[cat.id] = false;
  160. });
  161. setSelected(none);
  162. };
  163. const selectedCount = Object.values(selected).filter(Boolean).length;
  164. const handleExport = async () => {
  165. setIsExporting(true);
  166. try {
  167. await onExport({ ...selected, access_codes: includeAccessCodes && selected.printers });
  168. } finally {
  169. setIsExporting(false);
  170. }
  171. };
  172. return (
  173. <div
  174. className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
  175. onClick={isExporting ? undefined : onClose}
  176. >
  177. <Card className="w-full max-w-lg" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
  178. <CardContent className="p-0">
  179. {/* Header */}
  180. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  181. <div className="flex items-center gap-3">
  182. <div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
  183. <Download className="w-5 h-5" />
  184. </div>
  185. <div>
  186. <h3 className="text-lg font-semibold text-white">
  187. {t('backup.exportTitle', { defaultValue: 'Export Backup' })}
  188. </h3>
  189. <p className="text-sm text-bambu-gray">
  190. {t('backup.selectCategories', { defaultValue: 'Select data to include' })}
  191. </p>
  192. </div>
  193. </div>
  194. <button
  195. onClick={onClose}
  196. className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
  197. >
  198. <X className="w-5 h-5" />
  199. </button>
  200. </div>
  201. {/* Quick actions */}
  202. <div className="flex gap-2 px-4 pt-4">
  203. <button
  204. onClick={selectAll}
  205. disabled={isExporting}
  206. className="text-sm text-bambu-green hover:text-bambu-green/80 disabled:opacity-50 disabled:cursor-not-allowed"
  207. >
  208. {t('common.selectAll', { defaultValue: 'Select All' })}
  209. </button>
  210. <span className="text-bambu-gray">|</span>
  211. <button
  212. onClick={selectNone}
  213. disabled={isExporting}
  214. className="text-sm text-bambu-gray hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
  215. >
  216. {t('common.selectNone', { defaultValue: 'Select None' })}
  217. </button>
  218. </div>
  219. {/* Categories */}
  220. <div className={`p-4 space-y-2 max-h-[400px] overflow-y-auto ${isExporting ? 'opacity-50 pointer-events-none' : ''}`}>
  221. {BACKUP_CATEGORIES.map((category) => {
  222. const isDisabled = isExporting || (category.requiresPrinters && !selected.printers);
  223. return (
  224. <label
  225. key={category.id}
  226. className={`flex items-center gap-3 p-3 rounded-lg transition-colors ${
  227. isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'
  228. } ${
  229. selected[category.id] && !isDisabled
  230. ? 'bg-bambu-green/10 border border-bambu-green/30'
  231. : 'bg-bambu-dark hover:bg-bambu-dark-tertiary border border-transparent'
  232. }`}
  233. >
  234. <input
  235. type="checkbox"
  236. checked={selected[category.id] && !isDisabled}
  237. onChange={() => toggleCategory(category.id)}
  238. disabled={isDisabled}
  239. className="w-4 h-4 rounded border-bambu-gray bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0"
  240. />
  241. <div className={`${selected[category.id] && !isDisabled ? 'text-bambu-green' : 'text-bambu-gray'}`}>
  242. {category.icon}
  243. </div>
  244. <div className="flex-1">
  245. <div className="text-white text-sm font-medium">
  246. {t(category.labelKey, { defaultValue: category.defaultLabel })}
  247. </div>
  248. <div className="text-xs text-bambu-gray">
  249. {category.requiresPrinters && !selected.printers
  250. ? 'Requires Printers to be selected'
  251. : category.description}
  252. </div>
  253. </div>
  254. </label>
  255. );
  256. })}
  257. </div>
  258. {/* Archive warning */}
  259. {selected.archives && (
  260. <div className="mx-4 mb-2 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
  261. <div className="flex items-start gap-2 text-sm">
  262. <Archive className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
  263. <div className="text-yellow-200 dark:text-yellow-200 text-yellow-700">
  264. <span className="font-medium">ZIP file will be created.</span>
  265. <span className="text-yellow-600 dark:text-yellow-200/70"> Includes all 3MF files, thumbnails, timelapses, and photos. This may take a while and result in a large file.</span>
  266. </div>
  267. </div>
  268. </div>
  269. )}
  270. {/* Access codes option - only shown when printers are selected */}
  271. {selected.printers && (
  272. <div className="mx-4 mb-2 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
  273. <div className="flex items-center justify-between">
  274. <div className="flex items-start gap-2">
  275. <Key className="w-4 h-4 text-orange-500 dark:text-orange-400 mt-0.5 flex-shrink-0" />
  276. <div>
  277. <p className="text-sm font-medium text-white">Include Access Codes</p>
  278. <p className="text-xs text-bambu-gray">For transferring to another machine</p>
  279. </div>
  280. </div>
  281. <Toggle checked={includeAccessCodes} onChange={setIncludeAccessCodes} />
  282. </div>
  283. {includeAccessCodes && (
  284. <div className="mt-2 p-2 rounded bg-orange-500/10 border border-orange-500/30">
  285. <div className="flex items-start gap-2 text-xs">
  286. <AlertTriangle className="w-3 h-3 text-orange-500 dark:text-orange-400 mt-0.5 flex-shrink-0" />
  287. <span className="text-orange-700 dark:text-orange-200">
  288. Access codes will be included in plain text. Keep this backup file secure!
  289. </span>
  290. </div>
  291. </div>
  292. )}
  293. </div>
  294. )}
  295. {/* Footer */}
  296. <div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary">
  297. <span className="text-sm text-bambu-gray">
  298. {t('backup.selectedCount', {
  299. count: selectedCount,
  300. defaultValue: `${selectedCount} categories selected`,
  301. })}
  302. </span>
  303. <div className="flex gap-3">
  304. <Button variant="secondary" onClick={onClose} disabled={isExporting}>
  305. {t('common.cancel', { defaultValue: 'Cancel' })}
  306. </Button>
  307. <Button
  308. onClick={handleExport}
  309. disabled={selectedCount === 0 || isExporting}
  310. className="bg-bambu-green hover:bg-bambu-green-dark disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]"
  311. >
  312. {isExporting ? (
  313. <>
  314. <Loader2 className="w-4 h-4 mr-2 animate-spin" />
  315. {t('backup.exporting', { defaultValue: 'Exporting...' })}
  316. </>
  317. ) : (
  318. <>
  319. <Download className="w-4 h-4 mr-2" />
  320. {t('backup.export', { defaultValue: 'Export' })}
  321. </>
  322. )}
  323. </Button>
  324. </div>
  325. </div>
  326. </CardContent>
  327. </Card>
  328. </div>
  329. );
  330. }