Implement backup functionality with UI and backend support
Co-authored-by: Koha9 <36852125+Koha9@users.noreply.github.com>
This commit is contained in:
+29
-2
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, PathMappingConfig, PathMappingMode, PlexConnectionSettings, SyncState, ScheduleSettings, ScheduleMode } from './types';
|
||||
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, PathMappingConfig, PathMappingMode, PlexConnectionSettings, SyncState, ScheduleSettings, ScheduleMode, BackupSettings } from './types';
|
||||
import { apiService } from './services/api';
|
||||
import {
|
||||
STRIPE_BASE_SPEED,
|
||||
@@ -161,6 +161,12 @@ const App: React.FC = () => {
|
||||
});
|
||||
const [nextRunTime, setNextRunTime] = useState<string | undefined>(undefined);
|
||||
|
||||
// Backup State
|
||||
const [backupSettings, setBackupSettings] = useState<BackupSettings>({
|
||||
enabled: false,
|
||||
retentionCount: 5
|
||||
});
|
||||
|
||||
// Toast Notification System
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const timeoutsRef = useRef<{[key: number]: ReturnType<typeof setTimeout>}>({});
|
||||
@@ -246,6 +252,13 @@ const App: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadBackupSettings = useCallback(async () => {
|
||||
const result = await apiService.getBackupSettings();
|
||||
if (result.status === 'success') {
|
||||
setBackupSettings(result.data);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle Schedule Save
|
||||
const handleSaveSchedule = async (settings: ScheduleSettings): Promise<boolean> => {
|
||||
const result = await apiService.saveScheduleSettings(settings);
|
||||
@@ -267,6 +280,17 @@ const App: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Backup Settings Save
|
||||
const handleSaveBackupSettings = async (settings: BackupSettings) => {
|
||||
const result = await apiService.saveBackupSettings(settings);
|
||||
if (result.status === 'success') {
|
||||
setBackupSettings(settings);
|
||||
addToast('Backup settings have been saved.');
|
||||
} else {
|
||||
addToast(result.message || 'Failed to save backup settings.');
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch Local Playlists
|
||||
const refreshLocal = useCallback(async () => {
|
||||
if (localAbortRef.current) localAbortRef.current.abort();
|
||||
@@ -329,7 +353,8 @@ const App: React.FC = () => {
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
loadSchedule();
|
||||
}, [loadSettings, loadSchedule]);
|
||||
loadBackupSettings();
|
||||
}, [loadSettings, loadSchedule, loadBackupSettings]);
|
||||
|
||||
// Initial Load
|
||||
useEffect(() => {
|
||||
@@ -749,6 +774,8 @@ const App: React.FC = () => {
|
||||
onSelect={handleStrategyChange}
|
||||
savedPathMapping={pathMappingConfig}
|
||||
onSavePathMapping={handleSavePathMapping}
|
||||
savedBackup={backupSettings}
|
||||
onSaveBackup={handleSaveBackupSettings}
|
||||
savedSchedule={scheduleSettings}
|
||||
onSaveSchedule={handleSaveSchedule}
|
||||
syncState={syncState}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { SyncStrategy, ReplacementRule, PathMappingConfig, PathMappingRules, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode } from '../types';
|
||||
import { SyncStrategy, ReplacementRule, PathMappingConfig, PathMappingRules, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode, BackupSettings } from '../types';
|
||||
import {
|
||||
ArrowRightCircle,
|
||||
ArrowLeftCircle,
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
Type,
|
||||
Code2,
|
||||
Link,
|
||||
Archive,
|
||||
History,
|
||||
Eye
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -222,6 +224,8 @@ interface StrategySelectorProps {
|
||||
onSelect: (strategy: SyncStrategy, label: string) => void;
|
||||
savedPathMapping: PathMappingConfig;
|
||||
onSavePathMapping: (config: PathMappingConfig) => void;
|
||||
savedBackup: BackupSettings;
|
||||
onSaveBackup: (settings: BackupSettings) => void;
|
||||
savedSchedule: ScheduleSettings;
|
||||
onSaveSchedule: (settings: ScheduleSettings) => Promise<boolean>;
|
||||
syncState: SyncState;
|
||||
@@ -233,6 +237,8 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
onSelect,
|
||||
savedPathMapping,
|
||||
onSavePathMapping,
|
||||
savedBackup,
|
||||
onSaveBackup,
|
||||
savedSchedule,
|
||||
onSaveSchedule,
|
||||
syncState,
|
||||
@@ -245,6 +251,10 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
const [localPathMapping, setLocalPathMapping] = useState<PathMappingConfig>(savedPathMapping);
|
||||
const [isMappingDirty, setIsMappingDirty] = useState(false);
|
||||
|
||||
// Local state for Backup Settings
|
||||
const [localBackup, setLocalBackup] = useState<BackupSettings>(savedBackup);
|
||||
const [isBackupDirty, setIsBackupDirty] = useState(false);
|
||||
|
||||
// Local state for Schedule editing
|
||||
const [localSchedule, setLocalSchedule] = useState<ScheduleSettings>(savedSchedule);
|
||||
const [isScheduleDirty, setIsScheduleDirty] = useState(false);
|
||||
@@ -263,6 +273,11 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
setIsMappingDirty(false);
|
||||
}, [savedPathMapping]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalBackup(JSON.parse(JSON.stringify(savedBackup)));
|
||||
setIsBackupDirty(false);
|
||||
}, [savedBackup]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule)));
|
||||
if (savedSchedule.mode !== ScheduleMode.DISABLED) {
|
||||
@@ -277,6 +292,12 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
setIsMappingDirty(isDifferent);
|
||||
}, [localPathMapping, savedPathMapping]);
|
||||
|
||||
// Check dirty state for backup
|
||||
useEffect(() => {
|
||||
const isDifferent = JSON.stringify(localBackup) !== JSON.stringify(savedBackup);
|
||||
setIsBackupDirty(isDifferent);
|
||||
}, [localBackup, savedBackup]);
|
||||
|
||||
// Check dirty state for Schedule (including Active Tab changes)
|
||||
useEffect(() => {
|
||||
const effectiveLocal = deriveEffectiveSchedule(localSchedule, activeScheduleTab);
|
||||
@@ -360,6 +381,22 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
const regexRules = localPathMapping.regex;
|
||||
const simpleRules = localPathMapping.simple;
|
||||
|
||||
// --- Backup Handlers ---
|
||||
const handleUpdateBackup = (field: keyof BackupSettings, value: any) => {
|
||||
if (isLocked) return;
|
||||
setLocalBackup(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleResetBackup = () => {
|
||||
if (isLocked) return;
|
||||
setLocalBackup(JSON.parse(JSON.stringify(savedBackup)));
|
||||
};
|
||||
|
||||
const handleSaveBackupClick = () => {
|
||||
if (isLocked) return;
|
||||
onSaveBackup(localBackup);
|
||||
};
|
||||
|
||||
// --- Schedule Handlers ---
|
||||
const handleUpdateSchedule = (field: keyof ScheduleSettings, value: any) => {
|
||||
if (isLocked) return;
|
||||
@@ -478,6 +515,83 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 1.5: Backup Retention */}
|
||||
<div className="px-4 py-3 bg-gray-900/40 border-b border-white/5 flex-none">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Backup Retention</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 border border-indigo-500/20 text-indigo-400">
|
||||
<Archive size={16} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-200">Enable Backups</span>
|
||||
<span className="text-[10px] text-gray-500">Create a copy before changes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleUpdateBackup('enabled', !localBackup.enabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-plex-orange focus:ring-offset-2 focus:ring-offset-gray-900 ${localBackup.enabled ? 'bg-plex-orange' : 'bg-gray-700'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${localBackup.enabled ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded Config */}
|
||||
<div className={`overflow-hidden transition-all duration-300 ${localBackup.enabled ? 'max-h-20 opacity-100' : 'max-h-0 opacity-50'}`}>
|
||||
<div className="flex items-center justify-between p-2.5 rounded-lg bg-black/20 border border-white/5">
|
||||
<div className="flex items-center space-x-2">
|
||||
<History size={14} className="text-gray-500" />
|
||||
<span className="text-xs text-gray-400">Max versions to keep:</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={localBackup.retentionCount}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
handleUpdateBackup('retentionCount', isNaN(value) ? 0 : Math.max(0, value));
|
||||
}}
|
||||
className="w-16 bg-gray-800 border border-gray-700 text-center text-sm rounded py-1 text-white focus:border-plex-orange focus:outline-none"
|
||||
/>
|
||||
<span className="text-[10px] text-gray-600 italic">{localBackup.retentionCount === 0 ? 'No auto-delete' : 'Oldest deleted automatically'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end items-center gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleResetBackup}
|
||||
disabled={!isBackupDirty}
|
||||
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-medium border transition-all
|
||||
${isBackupDirty
|
||||
? 'bg-gray-800 border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||
: 'bg-transparent border-transparent text-gray-700 cursor-not-allowed'}`}
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
<span>Revert</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveBackupClick}
|
||||
disabled={!isBackupDirty}
|
||||
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-bold border transition-all
|
||||
${isBackupDirty
|
||||
? 'bg-plex-orange border-plex-orange text-gray-900 hover:bg-yellow-500 shadow-lg shadow-plex-orange/10'
|
||||
: 'bg-gray-800/30 border-gray-800/50 text-gray-600 cursor-not-allowed'}`}
|
||||
>
|
||||
<Save size={12} />
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Path Mapping (Tabs + Grid) */}
|
||||
<div className="p-4 bg-gray-900/40 border-b border-white/5 flex-none">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -791,7 +905,7 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
className={`w-full py-3 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all shadow-lg
|
||||
${isLocked
|
||||
? 'bg-gray-700/30 text-gray-500 cursor-not-allowed border border-gray-700/50'
|
||||
: isMappingDirty
|
||||
: isMappingDirty || isBackupDirty
|
||||
? 'bg-gray-800 text-gray-500 cursor-not-allowed border border-gray-700'
|
||||
: 'bg-green-600 hover:bg-green-500 text-white border border-green-500 shadow-green-900/20 active:scale-[0.98]'
|
||||
}`}
|
||||
@@ -808,9 +922,9 @@ const StrategySelector: React.FC<StrategySelectorProps> = ({
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{(isMappingDirty) && (
|
||||
{(isMappingDirty || isBackupDirty) && (
|
||||
<p className="text-[10px] text-plex-orange text-center mt-2">
|
||||
Please save path mapping changes before syncing.
|
||||
Please save pending changes (Backups/Path Mapping) before syncing.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, ReplacementRule, PathMappingConfig, PathMappingMode, PathMappingRules, SyncStrategy, ScheduleSettings } from '../types';
|
||||
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, ReplacementRule, PathMappingConfig, PathMappingMode, PathMappingRules, SyncStrategy, ScheduleSettings, BackupSettings } from '../types';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
|
||||
|
||||
@@ -234,4 +234,31 @@ export const apiService = {
|
||||
const response = await fetch(`${API_BASE}/api/sync/status`);
|
||||
return handleResponse(response);
|
||||
},
|
||||
|
||||
async getBackupSettings(): Promise<ApiResponse<BackupSettings>> {
|
||||
const response = await fetch(`${API_BASE}/api/backup/settings`);
|
||||
const result = await handleResponse<any>(response);
|
||||
if (result.status === 'success') {
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
enabled: result.data.enabled ?? false,
|
||||
retentionCount: result.data.retention_count ?? 5,
|
||||
},
|
||||
};
|
||||
}
|
||||
return result as ApiResponse<BackupSettings>;
|
||||
},
|
||||
|
||||
async saveBackupSettings(settings: BackupSettings): Promise<ApiResponse<null>> {
|
||||
const response = await fetch(`${API_BASE}/api/backup/settings`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enabled: settings.enabled,
|
||||
retention_count: settings.retentionCount,
|
||||
}),
|
||||
});
|
||||
return handleResponse(response);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user