6f234ebc48
feat(ui): Improve schedule dirty state detection Detects changes in schedule settings more accurately, considering the active tab and deriving the effective schedule state before comparison. This prevents unintended saving of disabled schedules when switching tabs. Merge commit '305743d752e1a1ecaefba79419929524ad060663'
632 lines
29 KiB
TypeScript
632 lines
29 KiB
TypeScript
|
|
import React, { useState, useRef, useEffect } from 'react';
|
|
import { SyncStrategy, RegexReplacement, SyncState, ScheduleSettings, ScheduleMode } from '../types';
|
|
import {
|
|
ArrowRightCircle,
|
|
ArrowLeftCircle,
|
|
GitMerge,
|
|
ChevronDown,
|
|
Check,
|
|
HelpCircle,
|
|
Plus,
|
|
Trash2,
|
|
Save,
|
|
RotateCcw,
|
|
Zap,
|
|
Loader2,
|
|
Calendar,
|
|
Clock,
|
|
Repeat,
|
|
CheckSquare,
|
|
Square
|
|
} from 'lucide-react';
|
|
|
|
interface StrategyOption {
|
|
value: SyncStrategy;
|
|
label: string;
|
|
description: string;
|
|
icon: React.ElementType;
|
|
color: string;
|
|
}
|
|
|
|
const STRATEGIES: StrategyOption[] = [
|
|
{
|
|
value: SyncStrategy.LOCAL_OVERWRITE,
|
|
label: 'Local Overwrite',
|
|
description: 'Local playlist completely overwrites Cloud. (No Diff)',
|
|
icon: ArrowRightCircle,
|
|
color: 'text-blue-400'
|
|
},
|
|
{
|
|
value: SyncStrategy.CLOUD_OVERWRITE,
|
|
label: 'Cloud Overwrite',
|
|
description: 'Cloud playlist completely overwrites Local. (No Diff)',
|
|
icon: ArrowLeftCircle,
|
|
color: 'text-green-400'
|
|
},
|
|
{
|
|
value: SyncStrategy.MERGE_LOCAL,
|
|
label: 'Two-way Merge (Local Priority)',
|
|
description: 'Merge both. Conflicts resolve to Local version.',
|
|
icon: GitMerge,
|
|
color: 'text-blue-300'
|
|
},
|
|
{
|
|
value: SyncStrategy.MERGE_CLOUD,
|
|
label: 'Two-way Merge (Cloud Priority)',
|
|
description: 'Merge both. Conflicts resolve to Cloud version.',
|
|
icon: GitMerge,
|
|
color: 'text-green-300'
|
|
}
|
|
];
|
|
|
|
const WEEK_DAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
|
|
|
// Helper to determine the actual mode and settings that would be saved based on the current UI state
|
|
const deriveEffectiveSchedule = (schedule: ScheduleSettings, tab: ScheduleMode): ScheduleSettings => {
|
|
const derived = { ...schedule };
|
|
|
|
if (tab === ScheduleMode.CRON) {
|
|
derived.mode = derived.cronExpression.trim() !== '' ? ScheduleMode.CRON : ScheduleMode.DISABLED;
|
|
} else {
|
|
// For Daily/Weekly
|
|
// If the mode matches the tab, we keep it (Enabled).
|
|
// If the mode doesn't match (e.g. it was CRON or DISABLED), then in the context of this tab, it is effectively Disabled until the user checks the box.
|
|
if (derived.mode === tab) {
|
|
derived.mode = tab;
|
|
} else {
|
|
derived.mode = ScheduleMode.DISABLED;
|
|
}
|
|
}
|
|
return derived;
|
|
};
|
|
|
|
interface StrategySelectorProps {
|
|
currentStrategy: SyncStrategy;
|
|
onSelect: (strategy: SyncStrategy, label: string) => void;
|
|
savedRegexReplacements: RegexReplacement[];
|
|
onSaveRegex: (replacements: RegexReplacement[]) => void;
|
|
savedSchedule: ScheduleSettings;
|
|
onSaveSchedule: (settings: ScheduleSettings) => Promise<boolean>;
|
|
syncState: SyncState;
|
|
onSync: () => void;
|
|
}
|
|
|
|
const StrategySelector: React.FC<StrategySelectorProps> = ({
|
|
currentStrategy,
|
|
onSelect,
|
|
savedRegexReplacements,
|
|
onSaveRegex,
|
|
savedSchedule,
|
|
onSaveSchedule,
|
|
syncState,
|
|
onSync
|
|
}) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Local state for regex editing
|
|
const [localReplacements, setLocalReplacements] = useState<RegexReplacement[]>([]);
|
|
const [isRegexDirty, setIsRegexDirty] = useState(false);
|
|
|
|
// Local state for Schedule editing
|
|
const [localSchedule, setLocalSchedule] = useState<ScheduleSettings>(savedSchedule);
|
|
const [isScheduleDirty, setIsScheduleDirty] = useState(false);
|
|
|
|
// UI State for Schedule Tabs
|
|
// We initialize active tab based on the saved mode. If DISABLED, default to CRON.
|
|
const [activeTab, setActiveTab] = useState<ScheduleMode>(
|
|
savedSchedule.mode === ScheduleMode.DISABLED ? ScheduleMode.CRON : savedSchedule.mode
|
|
);
|
|
|
|
const isSyncing = syncState === SyncState.SYNCING;
|
|
const isLocked = isSyncing || syncState === SyncState.SUCCESS;
|
|
|
|
// Initialize local state when prop updates
|
|
useEffect(() => {
|
|
setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements)));
|
|
setIsRegexDirty(false);
|
|
}, [savedRegexReplacements]);
|
|
|
|
useEffect(() => {
|
|
setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule)));
|
|
// If the saved mode is not disabled, ensure we show that tab.
|
|
if (savedSchedule.mode !== ScheduleMode.DISABLED) {
|
|
setActiveTab(savedSchedule.mode);
|
|
}
|
|
setIsScheduleDirty(false);
|
|
}, [savedSchedule]);
|
|
|
|
// Check dirty state whenever local changes
|
|
useEffect(() => {
|
|
const isDifferent = JSON.stringify(localReplacements) !== JSON.stringify(savedRegexReplacements);
|
|
setIsRegexDirty(isDifferent);
|
|
}, [localReplacements, savedRegexReplacements]);
|
|
|
|
// Check dirty state for Schedule (including Active Tab changes)
|
|
useEffect(() => {
|
|
// We calculate what the "effective" schedule would be if we saved right now.
|
|
const effectiveLocal = deriveEffectiveSchedule(localSchedule, activeTab);
|
|
const isDifferent = JSON.stringify(effectiveLocal) !== JSON.stringify(savedSchedule);
|
|
setIsScheduleDirty(isDifferent);
|
|
}, [localSchedule, savedSchedule, activeTab]);
|
|
|
|
const selectedOption = STRATEGIES.find(s => s.value === currentStrategy) || STRATEGIES[0];
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
// Determine if tabs have changed from the saved state
|
|
const initialTab = savedSchedule.mode === ScheduleMode.DISABLED ? ScheduleMode.CRON : savedSchedule.mode;
|
|
const hasTabChanged = activeTab !== initialTab;
|
|
const isScheduleActionable = isScheduleDirty || hasTabChanged;
|
|
|
|
const handleSelect = (strategy: StrategyOption) => {
|
|
if (isLocked) return;
|
|
onSelect(strategy.value, strategy.label);
|
|
};
|
|
|
|
// --- Regex Handlers ---
|
|
const handleAddRegex = () => {
|
|
if (isLocked) return;
|
|
const newId = Date.now().toString();
|
|
setLocalReplacements(prev => [...prev, { id: newId, pattern: '', replacement: '' }]);
|
|
};
|
|
|
|
const handleDeleteRegex = (id: string) => {
|
|
if (isLocked) return;
|
|
setLocalReplacements(prev => prev.filter(r => r.id !== id));
|
|
};
|
|
|
|
const handleUpdateRegex = (id: string, field: 'pattern' | 'replacement', value: string) => {
|
|
if (isLocked) return;
|
|
setLocalReplacements(prev => prev.map(r =>
|
|
r.id === id ? { ...r, [field]: value } : r
|
|
));
|
|
};
|
|
|
|
const handleResetRegex = () => {
|
|
if (isLocked) return;
|
|
setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements)));
|
|
};
|
|
|
|
const handleSaveRegex = () => {
|
|
if (isLocked) return;
|
|
const validReplacements = localReplacements.filter(r => r.pattern.trim() !== '');
|
|
setLocalReplacements(validReplacements);
|
|
onSaveRegex(validReplacements);
|
|
};
|
|
|
|
// --- Schedule Handlers ---
|
|
const handleUpdateSchedule = (field: keyof ScheduleSettings, value: any) => {
|
|
if (isLocked) return;
|
|
setLocalSchedule(prev => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
const toggleWeekDay = (dayIndex: number) => {
|
|
if (isLocked) return;
|
|
const currentDays = localSchedule.weeklyDays;
|
|
const newDays = currentDays.includes(dayIndex)
|
|
? currentDays.filter(d => d !== dayIndex)
|
|
: [...currentDays, dayIndex].sort();
|
|
handleUpdateSchedule('weeklyDays', newDays);
|
|
};
|
|
|
|
const handleResetSchedule = () => {
|
|
if (isLocked) return;
|
|
setLocalSchedule(JSON.parse(JSON.stringify(savedSchedule)));
|
|
if (savedSchedule.mode !== ScheduleMode.DISABLED) {
|
|
setActiveTab(savedSchedule.mode);
|
|
} else {
|
|
setActiveTab(ScheduleMode.CRON);
|
|
}
|
|
};
|
|
|
|
const handleSaveScheduleClick = async () => {
|
|
if (isLocked) return;
|
|
|
|
// Determine the effective settings based on the current view (tab) and inputs
|
|
const settingsToSave = deriveEffectiveSchedule(localSchedule, activeTab);
|
|
|
|
// Call API
|
|
const success = await onSaveSchedule(settingsToSave);
|
|
if (success) {
|
|
setLocalSchedule(settingsToSave);
|
|
// Dirty state is cleared by the useEffect prop update, or we can clear it optimistically here if needed,
|
|
// but useEffect [savedSchedule] handles it correctly.
|
|
}
|
|
};
|
|
|
|
const handleSyncClick = () => {
|
|
if (isLocked) return;
|
|
onSync();
|
|
};
|
|
|
|
// Helper to toggle enable/disable for current active tab (Daily/Weekly)
|
|
const toggleScheduleEnable = (targetMode: ScheduleMode) => {
|
|
if (isLocked) return;
|
|
if (localSchedule.mode === targetMode) {
|
|
handleUpdateSchedule('mode', ScheduleMode.DISABLED);
|
|
} else {
|
|
handleUpdateSchedule('mode', targetMode);
|
|
}
|
|
};
|
|
|
|
// If syncing or locked, apply grayscale filter to content sections
|
|
const contentClass = isLocked ? "opacity-50 pointer-events-none grayscale transition-all" : "transition-all";
|
|
|
|
return (
|
|
<div className="relative group" ref={dropdownRef}>
|
|
{/* Trigger Button */}
|
|
<button
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className="flex items-center justify-center w-12 h-12 rounded-full bg-gray-800/90 border border-gray-600 hover:border-plex-orange text-gray-300 hover:text-white hover:bg-gray-700/80 transition-all shadow-2xl hover:shadow-plex-orange/30 ring-[6px] md:ring-8 ring-gray-900 backdrop-blur-sm active:scale-95"
|
|
title={`Current Strategy: ${selectedOption.label}`}
|
|
>
|
|
<selectedOption.icon size={22} className={selectedOption.color} strokeWidth={2.5} />
|
|
<div className="absolute -bottom-1 -right-1 bg-gray-900 rounded-full border border-gray-600 p-[2px] shadow-sm">
|
|
<ChevronDown size={10} className="text-gray-400" />
|
|
</div>
|
|
</button>
|
|
|
|
{/* Dropdown Menu */}
|
|
<div
|
|
className={`absolute
|
|
top-14
|
|
/* Mobile: Open to left */
|
|
right-0 origin-top-right
|
|
/* Desktop: Center alignment */
|
|
md:left-1/2 md:right-auto md:origin-top md:-translate-x-1/2
|
|
|
|
w-80 md:w-[32rem] bg-gray-800/95 border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden backdrop-blur-xl
|
|
transition-all duration-200 ease-out
|
|
${isOpen ? 'opacity-100 scale-100 visible translate-y-0' : 'opacity-0 scale-95 invisible pointer-events-none -translate-y-2'}`}
|
|
>
|
|
<div className={`flex flex-col max-h-[75vh] md:max-h-[85vh] overflow-y-auto custom-scrollbar ${contentClass}`}>
|
|
|
|
{/* Section 1: Sync Strategy */}
|
|
<div className="px-4 py-3 bg-black/20 border-b border-white/5 flex-none">
|
|
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2">Sync Strategy</h3>
|
|
<div className="space-y-1">
|
|
{STRATEGIES.map((strategy) => (
|
|
<div
|
|
key={strategy.value}
|
|
onClick={() => handleSelect(strategy)}
|
|
className={`group flex items-center justify-between p-2 rounded-lg cursor-pointer transition-all border ${
|
|
currentStrategy === strategy.value
|
|
? 'bg-white/10 border-white/10 shadow-sm'
|
|
: 'hover:bg-white/5 border-transparent'
|
|
}`}
|
|
>
|
|
<div className="flex items-center space-x-3 overflow-hidden">
|
|
<strategy.icon size={18} className={strategy.color} />
|
|
<span className={`text-sm font-medium truncate ${currentStrategy === strategy.value ? 'text-white' : 'text-gray-300 group-hover:text-white'}`}>
|
|
{strategy.label}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<div className="relative group/tooltip">
|
|
<HelpCircle size={14} className="text-gray-600 hover:text-gray-400 transition-colors" />
|
|
<div className="absolute right-0 bottom-full mb-2 w-48 p-2.5 bg-gray-900 text-xs text-gray-300 rounded-lg shadow-xl border border-gray-700 pointer-events-none opacity-0 group-hover/tooltip:opacity-100 transition-opacity z-50">
|
|
{strategy.description}
|
|
</div>
|
|
</div>
|
|
|
|
{currentStrategy === strategy.value && (
|
|
<Check size={14} className="text-plex-orange" strokeWidth={3} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Section 2: Regex Preprocessing */}
|
|
<div className="p-4 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">Regex Rules</h3>
|
|
{localReplacements.length === 0 && (
|
|
<button
|
|
onClick={handleAddRegex}
|
|
className="p-1 rounded bg-gray-700/50 hover:bg-gray-600 text-gray-400 hover:text-white transition-colors"
|
|
title="Add Rule"
|
|
>
|
|
<Plus size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2 mb-4 max-h-40 overflow-y-auto pr-1 custom-scrollbar">
|
|
{localReplacements.length === 0 ? (
|
|
<div className="text-xs text-gray-600 italic text-center py-2 border border-dashed border-gray-700/50 rounded-lg">
|
|
No regex replacements configured.
|
|
</div>
|
|
) : (
|
|
localReplacements.map((regex) => (
|
|
<div key={regex.id} className="flex items-center space-x-2 animate-in slide-in-from-left-2 duration-200">
|
|
<div className="flex-1 min-w-0">
|
|
<input
|
|
type="text"
|
|
placeholder="Pattern"
|
|
value={regex.pattern}
|
|
onChange={(e) => handleUpdateRegex(regex.id, 'pattern', e.target.value)}
|
|
className={`w-full bg-gray-900/80 border rounded-md px-2 py-1.5 text-xs text-gray-200 focus:outline-none focus:ring-1 transition-all placeholder-gray-600
|
|
${!regex.pattern && isRegexDirty ? 'border-red-500/30 focus:border-red-500' : 'border-gray-700 focus:border-plex-orange'}`}
|
|
/>
|
|
</div>
|
|
<div className="flex-none text-gray-600">
|
|
<ArrowRightCircle size={12} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<input
|
|
type="text"
|
|
placeholder="Replacement"
|
|
value={regex.replacement}
|
|
onChange={(e) => handleUpdateRegex(regex.id, 'replacement', e.target.value)}
|
|
className="w-full bg-gray-900/80 border border-gray-700 rounded-md px-2 py-1.5 text-xs text-gray-200 focus:outline-none focus:border-plex-orange focus:ring-1 focus:ring-plex-orange transition-all placeholder-gray-600"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={() => handleDeleteRegex(regex.id)}
|
|
className="text-gray-600 hover:text-red-400 p-1.5 hover:bg-red-500/10 rounded transition-colors"
|
|
title="Delete Rule"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex justify-between items-center gap-2">
|
|
<button
|
|
onClick={handleAddRegex}
|
|
className={`flex items-center space-x-1 px-2 py-1 rounded text-[10px] font-bold uppercase tracking-wide transition-colors ${localReplacements.length > 0 ? 'text-plex-orange hover:bg-plex-orange/10' : 'hidden'}`}
|
|
>
|
|
<Plus size={10} />
|
|
<span>Add</span>
|
|
</button>
|
|
|
|
<div className="flex items-center gap-2 ml-auto">
|
|
<button
|
|
onClick={handleResetRegex}
|
|
disabled={!isRegexDirty}
|
|
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-medium border transition-all
|
|
${isRegexDirty
|
|
? '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={handleSaveRegex}
|
|
disabled={!isRegexDirty}
|
|
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-bold border transition-all
|
|
${isRegexDirty
|
|
? '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 3: Scheduled Tasks */}
|
|
<div className="p-4 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">Scheduled Tasks</h3>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex space-x-1 bg-black/30 p-1 rounded-lg mb-4">
|
|
{[
|
|
{ id: ScheduleMode.CRON, label: 'Cron', icon: Repeat },
|
|
{ id: ScheduleMode.DAILY, label: 'Daily', icon: Clock },
|
|
{ id: ScheduleMode.WEEKLY, label: 'Weekly', icon: Calendar },
|
|
].map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`flex-1 flex items-center justify-center space-x-2 py-1.5 rounded-md text-xs font-medium transition-all
|
|
${activeTab === tab.id
|
|
? 'bg-gray-700 text-plex-orange shadow-sm'
|
|
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
|
|
}`}
|
|
>
|
|
<tab.icon size={12} />
|
|
<span>{tab.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Tab Content */}
|
|
<div className="mb-4 min-h-[50px]">
|
|
{activeTab === ScheduleMode.CRON && (
|
|
<div className="space-y-2 animate-in fade-in duration-200">
|
|
<div className="flex items-center space-x-2">
|
|
<span className="text-gray-500 font-mono text-xs">Cron:</span>
|
|
<input
|
|
type="text"
|
|
value={localSchedule.cronExpression}
|
|
onChange={(e) => handleUpdateSchedule('cronExpression', e.target.value)}
|
|
placeholder="0 0 * * *"
|
|
className="flex-1 bg-gray-800 border border-gray-700 rounded-md px-2.5 py-1.5 text-xs text-gray-200 font-mono focus:border-plex-orange focus:outline-none focus:ring-1 focus:ring-plex-orange placeholder-gray-600"
|
|
/>
|
|
</div>
|
|
<p className="text-[10px] text-gray-500">
|
|
Unix-cron format. Leave empty to disable schedule.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === ScheduleMode.DAILY && (
|
|
<div className="flex flex-col animate-in fade-in duration-200">
|
|
{/* Top Row: Checkbox + Label */}
|
|
<div className="flex items-center justify-start space-x-2 mb-2">
|
|
<button
|
|
onClick={() => toggleScheduleEnable(ScheduleMode.DAILY)}
|
|
className={`transition-colors flex-none ${localSchedule.mode === ScheduleMode.DAILY ? 'text-plex-orange' : 'text-gray-500 hover:text-gray-400'}`}
|
|
title={localSchedule.mode === ScheduleMode.DAILY ? "Schedule Enabled" : "Schedule Disabled"}
|
|
>
|
|
{localSchedule.mode === ScheduleMode.DAILY ? <CheckSquare size={16} /> : <Square size={16} />}
|
|
</button>
|
|
<label className="text-xs text-gray-400 font-medium">Run daily at:</label>
|
|
</div>
|
|
|
|
{/* Bottom Row: Centered Native Time Input */}
|
|
<div className="flex justify-center mt-2">
|
|
<input
|
|
type="time"
|
|
value={localSchedule.dailyTime}
|
|
onChange={(e) => handleUpdateSchedule('dailyTime', e.target.value)}
|
|
disabled={localSchedule.mode !== ScheduleMode.DAILY}
|
|
className={`bg-gray-800 border border-gray-700 text-white rounded-md px-4 py-2 text-xl font-mono focus:border-plex-orange focus:ring-1 focus:ring-plex-orange focus:outline-none transition-all ${localSchedule.mode !== ScheduleMode.DAILY ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === ScheduleMode.WEEKLY && (
|
|
<div className="flex flex-col animate-in fade-in duration-200">
|
|
{/* Top Row: Checkbox + Label */}
|
|
<div className="flex items-center justify-start space-x-2 mb-2">
|
|
<button
|
|
onClick={() => toggleScheduleEnable(ScheduleMode.WEEKLY)}
|
|
className={`transition-colors flex-none ${localSchedule.mode === ScheduleMode.WEEKLY ? 'text-plex-orange' : 'text-gray-500 hover:text-gray-400'}`}
|
|
title={localSchedule.mode === ScheduleMode.WEEKLY ? "Schedule Enabled" : "Schedule Disabled"}
|
|
>
|
|
{localSchedule.mode === ScheduleMode.WEEKLY ? <CheckSquare size={16} /> : <Square size={16} />}
|
|
</button>
|
|
<label className="text-xs text-gray-400 font-medium">Run on days:</label>
|
|
</div>
|
|
|
|
{/* Middle Row: Full Width Capsules */}
|
|
<div className={`grid grid-cols-7 w-full transition-opacity duration-200 mb-3 ${localSchedule.mode !== ScheduleMode.WEEKLY ? 'opacity-50 pointer-events-none' : ''}`}>
|
|
{WEEK_DAYS.map((day, index) => {
|
|
const isSelected = localSchedule.weeklyDays.includes(index);
|
|
return (
|
|
<button
|
|
key={index}
|
|
onClick={() => toggleWeekDay(index)}
|
|
className={`h-9 text-xs font-bold border-y border-l last:border-r border-gray-600/50 transition-colors
|
|
first:rounded-l-lg last:rounded-r-lg
|
|
${isSelected
|
|
? 'bg-plex-orange text-gray-900 border-plex-orange z-10'
|
|
: 'bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-gray-200'
|
|
}
|
|
`}
|
|
>
|
|
{day}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Bottom Row: Centered Native Time Input */}
|
|
<div className="flex justify-center mt-1">
|
|
<input
|
|
type="time"
|
|
value={localSchedule.weeklyTime}
|
|
onChange={(e) => handleUpdateSchedule('weeklyTime', e.target.value)}
|
|
disabled={localSchedule.mode !== ScheduleMode.WEEKLY}
|
|
className={`bg-gray-800 border border-gray-700 text-white rounded-md px-4 py-2 text-xl font-mono focus:border-plex-orange focus:ring-1 focus:ring-plex-orange focus:outline-none transition-all ${localSchedule.mode !== ScheduleMode.WEEKLY ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Auto Watch Checkbox */}
|
|
<div className="flex items-center mb-4 px-1">
|
|
<button
|
|
onClick={() => handleUpdateSchedule('autoWatch', !localSchedule.autoWatch)}
|
|
className="flex items-center space-x-2 group"
|
|
>
|
|
{localSchedule.autoWatch ? (
|
|
<CheckSquare size={16} className="text-plex-orange" />
|
|
) : (
|
|
<Square size={16} className="text-gray-600 group-hover:text-gray-400" />
|
|
)}
|
|
<span className={`text-xs ${localSchedule.autoWatch ? 'text-gray-200' : 'text-gray-500 group-hover:text-gray-400'}`}>
|
|
Watch for local playlist changes
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Action Buttons (Mirrored from Regex) */}
|
|
<div className="flex items-center gap-2 justify-end pt-3 border-t border-white/5">
|
|
<button
|
|
onClick={handleResetSchedule}
|
|
disabled={!isScheduleActionable}
|
|
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-medium border transition-all
|
|
${isScheduleActionable
|
|
? '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={handleSaveScheduleClick}
|
|
disabled={!isScheduleActionable}
|
|
className={`flex items-center justify-center space-x-1.5 px-3 py-1.5 rounded-md text-xs font-bold border transition-all
|
|
${isScheduleActionable
|
|
? '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 4: Sync Now Button */}
|
|
<div className="p-4 bg-gray-950/50 border-t border-white/5 flex-none">
|
|
<button
|
|
onClick={handleSyncClick}
|
|
disabled={isLocked}
|
|
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'
|
|
: isRegexDirty
|
|
? '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]'
|
|
}`}
|
|
>
|
|
{isSyncing ? (
|
|
<>
|
|
<Loader2 size={16} className="animate-spin" />
|
|
<span>Sync in Progress...</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Zap size={16} fill="currentColor" />
|
|
<span>Sync Now</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
{(isRegexDirty) && (
|
|
<p className="text-[10px] text-plex-orange text-center mt-2">
|
|
Please save regex changes before syncing.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default StrategySelector;
|