| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422 |
- import { useState, useRef, useEffect } from 'react';
- import { Upload, X, AlertTriangle, CheckCircle, SkipForward, RefreshCw, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
- import { Card, CardContent } from './Card';
- import { Button } from './Button';
- import { Toggle } from './Toggle';
- interface RestoreResult {
- success: boolean;
- message: string;
- restored?: Record<string, number>;
- skipped?: Record<string, number>;
- skipped_details?: Record<string, string[]>;
- files_restored?: number;
- total_skipped?: number;
- new_api_keys?: Array<{ name: string; key: string; key_prefix: string }>;
- }
- interface RestoreModalProps {
- onClose: () => void;
- onRestore: (file: File, overwrite: boolean) => Promise<RestoreResult>;
- onSuccess: () => void;
- }
- type ModalState = 'options' | 'restoring' | 'result';
- const CATEGORY_LABELS: Record<string, string> = {
- settings: 'Settings',
- notification_providers: 'Notification Providers',
- notification_templates: 'Notification Templates',
- smart_plugs: 'Smart Plugs',
- printers: 'Printers',
- filaments: 'Filaments',
- maintenance_types: 'Maintenance Types',
- archives: 'Archives',
- projects: 'Projects',
- pending_uploads: 'Pending Uploads',
- external_links: 'External Links',
- api_keys: 'API Keys',
- };
- export function RestoreModal({ onClose, onRestore, onSuccess }: RestoreModalProps) {
- const [state, setState] = useState<ModalState>('options');
- const [overwrite, setOverwrite] = useState(false);
- const [selectedFile, setSelectedFile] = useState<File | null>(null);
- const [result, setResult] = useState<RestoreResult | null>(null);
- const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
- const fileInputRef = useRef<HTMLInputElement>(null);
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === 'Escape' && state !== 'restoring') {
- // Use handleClose for result state to trigger onSuccess
- if (state === 'result' && result?.success) {
- onSuccess();
- }
- onClose();
- }
- };
- window.addEventListener('keydown', handleKeyDown);
- return () => window.removeEventListener('keydown', handleKeyDown);
- }, [onClose, onSuccess, state, result]);
- const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
- const file = e.target.files?.[0];
- if (file) {
- setSelectedFile(file);
- }
- };
- const handleRestore = async () => {
- if (!selectedFile) return;
- setState('restoring');
- try {
- const restoreResult = await onRestore(selectedFile, overwrite);
- setResult(restoreResult);
- setState('result');
- // Don't call onSuccess here - wait until modal closes
- // This prevents race condition with query cache
- } catch {
- setResult({
- success: false,
- message: 'Failed to restore backup. Please check the file format.',
- });
- setState('result');
- }
- };
- const handleClose = () => {
- // If restore was successful, trigger refresh before closing
- if (result?.success) {
- onSuccess();
- }
- onClose();
- };
- const toggleCategory = (category: string) => {
- setExpandedCategories(prev => {
- const next = new Set(prev);
- if (next.has(category)) {
- next.delete(category);
- } else {
- next.add(category);
- }
- return next;
- });
- };
- const totalRestored = result?.restored
- ? Object.values(result.restored).reduce((a, b) => a + b, 0) + (result.files_restored || 0)
- : 0;
- const totalSkipped = result?.total_skipped || 0;
- return (
- <div
- className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
- onMouseDown={(e) => {
- // Only close if clicking directly on the backdrop, not on children
- if (e.target === e.currentTarget && state !== 'restoring') {
- onClose();
- }
- }}
- >
- <Card className="w-full max-w-lg">
- <CardContent className="p-0">
- {/* Header */}
- <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
- <div className="flex items-center gap-3">
- <div className={`p-2 rounded-full ${
- state === 'result' && result?.success
- ? 'bg-bambu-green/20 text-bambu-green'
- : state === 'result' && !result?.success
- ? 'bg-red-500/20 text-red-500'
- : 'bg-blue-500/20 text-blue-500'
- }`}>
- {state === 'result' && result?.success ? (
- <CheckCircle className="w-5 h-5" />
- ) : state === 'result' && !result?.success ? (
- <AlertTriangle className="w-5 h-5" />
- ) : (
- <Upload className="w-5 h-5" />
- )}
- </div>
- <div>
- <h3 className="text-lg font-semibold text-white">
- {state === 'options' && 'Restore Backup'}
- {state === 'restoring' && 'Restoring...'}
- {state === 'result' && (result?.success ? 'Restore Complete' : 'Restore Failed')}
- </h3>
- <p className="text-sm text-bambu-gray">
- {state === 'options' && 'Import settings from a backup file'}
- {state === 'restoring' && 'Please wait while your data is being restored'}
- {state === 'result' && result?.message}
- </p>
- </div>
- </div>
- {state !== 'restoring' && (
- <button
- onClick={handleClose}
- className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
- >
- <X className="w-5 h-5" />
- </button>
- )}
- </div>
- {/* Options State */}
- {state === 'options' && (
- <>
- <div className="p-4 space-y-4">
- {/* File Selection */}
- <div>
- <input
- ref={fileInputRef}
- type="file"
- accept=".json,.zip"
- className="hidden"
- onChange={handleFileSelect}
- />
- <button
- type="button"
- onClick={() => fileInputRef.current?.click()}
- className={`w-full p-4 border-2 border-dashed rounded-lg transition-colors ${
- selectedFile
- ? 'border-bambu-green bg-bambu-green/10'
- : 'border-bambu-dark-tertiary hover:border-bambu-gray'
- }`}
- >
- {selectedFile ? (
- <div className="flex items-center justify-center gap-2 text-bambu-green">
- <CheckCircle className="w-5 h-5" />
- <span className="font-medium">{selectedFile.name}</span>
- </div>
- ) : (
- <div className="flex flex-col items-center gap-2 text-bambu-gray">
- <Upload className="w-8 h-8" />
- <span>Click to select backup file (.json or .zip)</span>
- </div>
- )}
- </button>
- </div>
- {/* Info Box */}
- <div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/30">
- <div className="flex items-start gap-2 text-sm">
- <AlertTriangle className="w-4 h-4 text-blue-500 dark:text-blue-400 mt-0.5 flex-shrink-0" />
- <div className="text-blue-700 dark:text-blue-200">
- <p className="font-medium mb-1">How duplicate handling works:</p>
- <ul className="text-blue-600 dark:text-blue-200/80 space-y-1 text-xs">
- <li><strong>Printers</strong> - matched by serial number</li>
- <li><strong>Smart Plugs</strong> - matched by IP address</li>
- <li><strong>Notification Providers</strong> - matched by name</li>
- <li><strong>Filaments</strong> - matched by name + type + brand</li>
- <li><strong>Archives</strong> - matched by content hash (always skipped)</li>
- <li><strong>Pending Uploads</strong> - matched by filename</li>
- <li><strong>Settings & Templates</strong> - always overwritten</li>
- </ul>
- </div>
- </div>
- </div>
- {/* Overwrite Toggle */}
- <div className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
- <div className="flex items-center justify-between">
- <div>
- <p className="text-white font-medium flex items-center gap-2">
- {overwrite ? (
- <RefreshCw className="w-4 h-4 text-orange-400" />
- ) : (
- <SkipForward className="w-4 h-4 text-bambu-gray" />
- )}
- {overwrite ? 'Replace existing data' : 'Keep existing data'}
- </p>
- <p className="text-sm text-bambu-gray mt-1">
- {overwrite
- ? 'Overwrite items that already exist with backup data'
- : 'Only restore items that don\'t already exist'}
- </p>
- </div>
- <Toggle checked={overwrite} onChange={setOverwrite} />
- </div>
- </div>
- {overwrite && (
- <div className="p-3 rounded-lg bg-orange-500/10 border border-orange-500/30">
- <div className="flex items-start gap-2 text-sm">
- <AlertTriangle className="w-4 h-4 text-orange-500 dark:text-orange-400 mt-0.5 flex-shrink-0" />
- <div className="text-orange-700 dark:text-orange-200">
- <span className="font-medium">Caution:</span> Overwriting will replace your current configurations with data from the backup. Printer access codes are never overwritten for security.
- </div>
- </div>
- </div>
- )}
- </div>
- {/* Footer */}
- <div className="flex items-center justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
- <Button type="button" variant="secondary" onClick={onClose}>
- Cancel
- </Button>
- <Button
- type="button"
- onClick={handleRestore}
- disabled={!selectedFile}
- className="bg-bambu-green hover:bg-bambu-green-dark disabled:opacity-50"
- >
- <Upload className="w-4 h-4 mr-2" />
- Restore
- </Button>
- </div>
- </>
- )}
- {/* Restoring State */}
- {state === 'restoring' && (
- <div className="p-8 flex flex-col items-center gap-4">
- <Loader2 className="w-12 h-12 text-bambu-green animate-spin" />
- <p className="text-bambu-gray">Processing backup file...</p>
- </div>
- )}
- {/* Result State */}
- {state === 'result' && result && (
- <>
- <div className="p-4 space-y-4 max-h-[400px] overflow-y-auto">
- {/* Summary */}
- <div className="grid grid-cols-2 gap-3">
- <div className="p-3 rounded-lg bg-bambu-green/10 border border-bambu-green/30">
- <div className="text-2xl font-bold text-bambu-green">{totalRestored}</div>
- <div className="text-sm text-bambu-gray">Items Restored</div>
- </div>
- <div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
- <div className="text-2xl font-bold text-yellow-500">{totalSkipped}</div>
- <div className="text-sm text-bambu-gray">Items Skipped</div>
- </div>
- </div>
- {/* Restored Details */}
- {result.restored && Object.entries(result.restored).some(([, count]) => count > 0) && (
- <div className="space-y-2">
- <h4 className="text-sm font-medium text-bambu-gray flex items-center gap-2">
- <CheckCircle className="w-4 h-4 text-bambu-green" />
- Restored
- </h4>
- <div className="space-y-1">
- {Object.entries(result.restored)
- .filter(([, count]) => count > 0)
- .map(([key, count]) => (
- <div key={key} className="flex items-center justify-between text-sm p-2 rounded bg-bambu-dark">
- <span className="text-white">{CATEGORY_LABELS[key] || key}</span>
- <span className="text-bambu-green font-medium">{count}</span>
- </div>
- ))}
- {(result.files_restored || 0) > 0 && (
- <div className="flex items-center justify-between text-sm p-2 rounded bg-bambu-dark">
- <span className="text-white">Files (3MF, thumbnails, etc.)</span>
- <span className="text-bambu-green font-medium">{result.files_restored}</span>
- </div>
- )}
- </div>
- </div>
- )}
- {/* Skipped Details */}
- {result.skipped && Object.entries(result.skipped).some(([, count]) => count > 0) && (
- <div className="space-y-2">
- <h4 className="text-sm font-medium text-bambu-gray flex items-center gap-2">
- <SkipForward className="w-4 h-4 text-yellow-500" />
- Skipped (already exist)
- </h4>
- <div className="space-y-1">
- {Object.entries(result.skipped)
- .filter(([, count]) => count > 0)
- .map(([key, count]) => {
- const details = result.skipped_details?.[key] || [];
- const isExpanded = expandedCategories.has(key);
- return (
- <div key={key}>
- <button
- onClick={() => details.length > 0 && toggleCategory(key)}
- className={`w-full flex items-center justify-between text-sm p-2 rounded bg-bambu-dark ${
- details.length > 0 ? 'hover:bg-bambu-dark-tertiary cursor-pointer' : ''
- }`}
- >
- <span className="text-white flex items-center gap-2">
- {CATEGORY_LABELS[key] || key}
- {details.length > 0 && (
- isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />
- )}
- </span>
- <span className="text-yellow-500 font-medium">{count}</span>
- </button>
- {isExpanded && details.length > 0 && (
- <div className="mt-1 ml-4 p-2 rounded bg-bambu-dark-tertiary text-xs text-bambu-gray space-y-1">
- {details.slice(0, 10).map((item, i) => (
- <div key={i}>{item}</div>
- ))}
- {details.length > 10 && (
- <div className="text-bambu-gray/60">...and {details.length - 10} more</div>
- )}
- </div>
- )}
- </div>
- );
- })}
- </div>
- </div>
- )}
- {/* Newly Generated API Keys */}
- {result.new_api_keys && result.new_api_keys.length > 0 && (
- <div className="space-y-2">
- <h4 className="text-sm font-medium text-bambu-gray flex items-center gap-2">
- <AlertTriangle className="w-4 h-4 text-orange-500" />
- New API Keys Generated
- </h4>
- <div className="p-3 rounded bg-orange-500/10 border border-orange-500/30">
- <p className="text-xs text-orange-200 mb-2">
- These keys are only shown once. Copy them now!
- </p>
- <div className="space-y-2">
- {result.new_api_keys.map((apiKey: { name: string; key: string; key_prefix: string }, i: number) => (
- <div key={i} className="p-2 rounded bg-bambu-dark">
- <div className="text-sm text-white font-medium mb-1">{apiKey.name}</div>
- <div className="flex items-center gap-2">
- <code className="text-xs text-bambu-green bg-bambu-dark-tertiary px-2 py-1 rounded font-mono flex-1 break-all">
- {apiKey.key}
- </code>
- <button
- onClick={() => navigator.clipboard.writeText(apiKey.key)}
- className="text-xs text-bambu-gray hover:text-white px-2 py-1 rounded bg-bambu-dark-tertiary"
- >
- Copy
- </button>
- </div>
- </div>
- ))}
- </div>
- </div>
- </div>
- )}
- {totalRestored === 0 && totalSkipped === 0 && (
- <div className="p-4 text-center text-bambu-gray">
- No data was found to restore in the backup file.
- </div>
- )}
- </div>
- {/* Footer */}
- <div className="flex items-center justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
- <Button onClick={handleClose}>
- Close
- </Button>
- </div>
- </>
- )}
- </CardContent>
- </Card>
- </div>
- );
- }
|