Implement backup functionality with UI and backend support

Co-authored-by: Koha9 <36852125+Koha9@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-12-04 23:21:26 +00:00
parent 7b14445387
commit b483edae74
7 changed files with 489 additions and 7 deletions
+29 -2
View File
@@ -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}
+118 -4
View File
@@ -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>
+28 -1
View File
@@ -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);
},
};