From 06e49be1f9c587f66cca97de97cf449b33b04a4b Mon Sep 17 00:00:00 2001 From: Koha9 Date: Sat, 29 Nov 2025 08:23:31 +0900 Subject: [PATCH] Squashed 'sample-front-end/' changes from 552f9c4..99ea3a6 99ea3a6 feat: Display next sync schedule information fb8d17a feat: Implement schedule settings and basic UI git-subtree-dir: sample-front-end git-subtree-split: 99ea3a68de98503b706d3ee5782baf4a66dc7134 --- App.tsx | 163 +++++++++++-- components/StrategySelector.tsx | 407 ++++++++++++++++++++++++++------ index.html | 13 +- services/api.ts | 30 ++- types.ts | 19 +- 5 files changed, 534 insertions(+), 98 deletions(-) diff --git a/App.tsx b/App.tsx index cdf6911..acd91d7 100644 --- a/App.tsx +++ b/App.tsx @@ -1,5 +1,7 @@ + + import React, { useEffect, useState, useCallback, useRef } from 'react'; -import { Playlist, ServerType, SyncStrategy, PlexServerConnection, RegexReplacement, SyncState } from './types'; +import { Playlist, ServerType, SyncStrategy, PlexServerConnection, RegexReplacement, SyncState, ScheduleSettings, ScheduleMode } from './types'; import { apiService } from './services/api'; import { STRIPE_BASE_SPEED, @@ -15,7 +17,7 @@ import { SYNC_BANNER_PADDING_X, SYNC_BANNER_PADDING_Y, SYNC_BANNER_MIN_WIDTH } f import ServerPanel from './components/ServerPanel'; import StrategySelector from './components/StrategySelector'; import ConnectionModal from './components/ConnectionModal'; -import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff } from 'lucide-react'; +import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock } from 'lucide-react'; interface Toast { id: number; @@ -31,7 +33,7 @@ const useStripeAnimation = (syncState: SyncState) => { const rightYellowRef = useRef(null); const rightGreenRef = useRef(null); - const requestRef = useRef(); + const requestRef = useRef(undefined); const lastTimeRef = useRef(0); const offsetRef = useRef(0); @@ -138,6 +140,16 @@ const App: React.FC = () => { // Regex State const [regexReplacements, setRegexReplacements] = useState([]); + // Schedule State + const [scheduleSettings, setScheduleSettings] = useState({ + mode: ScheduleMode.DISABLED, + cronExpression: '', + dailyTime: '02:00', + weeklyDays: [0], // Sunday + weeklyTime: '03:00', + autoWatch: false + }); + // Toast Notification System const [toasts, setToasts] = useState([]); const timeoutsRef = useRef<{[key: number]: ReturnType}>({}); @@ -286,6 +298,29 @@ const App: React.FC = () => { addToast('Regex preprocessing rules have been saved.'); }; + // Handle Schedule Save + const handleSaveSchedule = async (settings: ScheduleSettings): Promise => { + // Call API (validation happens in Mock) + const result = await apiService.saveScheduleSettings(settings); + + if (result.status === 'success') { + // Only update local state if successful + setScheduleSettings(settings); + + if (settings.mode === ScheduleMode.DISABLED) { + addToast("Scheduled tasks disabled."); + } else if (settings.mode === ScheduleMode.CRON && settings.cronExpression.trim() === '') { + addToast("Scheduled tasks disabled (Empty Cron)."); + } else { + addToast("Scheduled task started successfully."); + } + return true; + } else { + addToast(result.message || "Failed to update schedule."); + return false; + } + }; + // Handle Sync Trigger const handleSyncTrigger = async () => { if (syncState !== SyncState.IDLE) return; @@ -346,6 +381,84 @@ const App: React.FC = () => { const isConnected = cloudServerInfo?.isConnected; + // Helper: Calculate Next Run Info + const getScheduleDisplayInfo = (settings: ScheduleSettings) => { + if (settings.mode === ScheduleMode.DISABLED) { + return { label: 'Auto-Sync', value: 'Disabled', active: false }; + } + + if (settings.mode === ScheduleMode.CRON) { + return { label: 'Cron Schedule', value: settings.cronExpression || 'Pending...', active: true }; + } + + const now = new Date(); + const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + let nextRun: Date | null = null; + let timeStr = ''; + + if (settings.mode === ScheduleMode.DAILY) { + const [h, m] = settings.dailyTime.split(':').map(Number); + const target = new Date(); + target.setHours(h, m, 0, 0); + timeStr = settings.dailyTime; + + if (now < target) { + nextRun = target; + } else { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(h, m, 0, 0); + nextRun = tomorrow; + } + } else if (settings.mode === ScheduleMode.WEEKLY) { + timeStr = settings.weeklyTime; + const [h, m] = settings.weeklyTime.split(':').map(Number); + const activeDays = [...settings.weeklyDays].sort(); + + if (activeDays.length === 0) return { label: 'Weekly Schedule', value: 'No days selected', active: false }; + + // Check rest of today + if (activeDays.includes(now.getDay())) { + const todayTarget = new Date(); + todayTarget.setHours(h, m, 0, 0); + if (todayTarget > now) { + nextRun = todayTarget; + } + } + + // Check future days + if (!nextRun) { + for (let i = 1; i <= 7; i++) { + const nextDayIndex = (now.getDay() + i) % 7; + if (activeDays.includes(nextDayIndex)) { + const d = new Date(); + d.setDate(now.getDate() + i); + d.setHours(h, m, 0, 0); + nextRun = d; + break; + } + } + } + } + + if (nextRun) { + // Format logic + const isToday = nextRun.getDate() === now.getDate() && nextRun.getMonth() === now.getMonth(); + const isTomorrow = new Date(now.getTime() + 86400000).getDate() === nextRun.getDate(); + + let dateStr = ''; + if (isToday) dateStr = 'Today'; + else if (isTomorrow) dateStr = 'Tomorrow'; + else dateStr = days[nextRun.getDay()]; + + return { label: `${settings.mode === ScheduleMode.DAILY ? 'Daily' : 'Weekly'} Schedule`, value: `${dateStr} at ${timeStr}`, active: true }; + } + + return { label: 'Schedule', value: 'Not configured', active: false }; + }; + + const scheduleInfo = getScheduleDisplayInfo(scheduleSettings); + return (
@@ -411,7 +524,7 @@ const App: React.FC = () => { {syncState === SyncState.IDLE ? ( <> - {/* Normal Toolbar */} + {/* Normal Toolbar Left */}
@@ -421,18 +534,32 @@ const App: React.FC = () => {
- {/* Connection Status Button */} - + {/* Normal Toolbar Right */} +
+ {/* Schedule Info */} +
+ + {scheduleInfo.label} + +
+ {scheduleInfo.active && } + {scheduleInfo.value} +
+
+ + {/* Connection Status Button */} + +
) : ( /* Syncing / Success Text Banner */ @@ -504,6 +631,8 @@ const App: React.FC = () => { onSelect={handleStrategyChange} savedRegexReplacements={regexReplacements} onSaveRegex={handleSaveRegex} + savedSchedule={scheduleSettings} + onSaveSchedule={handleSaveSchedule} syncState={syncState} onSync={handleSyncTrigger} /> @@ -540,4 +669,4 @@ const App: React.FC = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/components/StrategySelector.tsx b/components/StrategySelector.tsx index 3de2ae4..6ede15e 100644 --- a/components/StrategySelector.tsx +++ b/components/StrategySelector.tsx @@ -1,6 +1,5 @@ - import React, { useState, useRef, useEffect } from 'react'; -import { SyncStrategy, RegexReplacement, SyncState } from '../types'; +import { SyncStrategy, RegexReplacement, SyncState, ScheduleSettings, ScheduleMode } from '../types'; import { ArrowRightCircle, ArrowLeftCircle, @@ -13,7 +12,12 @@ import { Save, RotateCcw, Zap, - Loader2 + Loader2, + Calendar, + Clock, + Repeat, + CheckSquare, + Square } from 'lucide-react'; interface StrategyOption { @@ -55,11 +59,15 @@ const STRATEGIES: StrategyOption[] = [ } ]; +const WEEK_DAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; + interface StrategySelectorProps { currentStrategy: SyncStrategy; onSelect: (strategy: SyncStrategy, label: string) => void; savedRegexReplacements: RegexReplacement[]; onSaveRegex: (replacements: RegexReplacement[]) => void; + savedSchedule: ScheduleSettings; + onSaveSchedule: (settings: ScheduleSettings) => Promise; syncState: SyncState; onSync: () => void; } @@ -69,6 +77,8 @@ const StrategySelector: React.FC = ({ onSelect, savedRegexReplacements, onSaveRegex, + savedSchedule, + onSaveSchedule, syncState, onSync }) => { @@ -77,23 +87,47 @@ const StrategySelector: React.FC = ({ // Local state for regex editing const [localReplacements, setLocalReplacements] = useState([]); - const [isDirty, setIsDirty] = useState(false); + const [isRegexDirty, setIsRegexDirty] = useState(false); + + // Local state for Schedule editing + const [localSchedule, setLocalSchedule] = useState(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( + 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 (only if not dirty, or initially) + // Initialize local state when prop updates useEffect(() => { setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements))); - setIsDirty(false); + 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); - setIsDirty(isDifferent); + setIsRegexDirty(isDifferent); }, [localReplacements, savedRegexReplacements]); + useEffect(() => { + const isDifferent = JSON.stringify(localSchedule) !== JSON.stringify(savedSchedule); + setIsScheduleDirty(isDifferent); + }, [localSchedule, savedSchedule]); + const selectedOption = STRATEGIES.find(s => s.value === currentStrategy) || STRATEGIES[0]; useEffect(() => { @@ -111,7 +145,7 @@ const StrategySelector: React.FC = ({ onSelect(strategy.value, strategy.label); }; - // Regex Handlers + // --- Regex Handlers --- const handleAddRegex = () => { if (isLocked) return; const newId = Date.now().toString(); @@ -130,29 +164,93 @@ const StrategySelector: React.FC = ({ )); }; - const handleReset = () => { + const handleResetRegex = () => { if (isLocked) return; setLocalReplacements(JSON.parse(JSON.stringify(savedRegexReplacements))); }; - const handleSave = () => { + 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); + } + }; + + const handleSaveScheduleClick = async () => { + if (isLocked) return; + + let settingsToSave = { ...localSchedule }; + + // Logic to determine mode based on active Tab and checkbox state + if (activeTab === ScheduleMode.CRON) { + if (settingsToSave.cronExpression.trim() !== '') { + settingsToSave.mode = ScheduleMode.CRON; + } else { + // Empty cron -> disabled + settingsToSave.mode = ScheduleMode.DISABLED; + } + } + + // For Daily/Weekly, enforce: Save commits what is seen in the active tab. + if (activeTab !== ScheduleMode.CRON) { + // If the mode matches the active tab, it's enabled. Otherwise disabled. + if (localSchedule.mode !== activeTab) { + settingsToSave.mode = ScheduleMode.DISABLED; + } + } + + // Call API + const success = await onSaveSchedule(settingsToSave); + if (success) { + setLocalSchedule(settingsToSave); + setIsScheduleDirty(false); + } + }; + 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 (
- {/* Trigger Button - Added Ring to create visual 'cutout' over panels */} + {/* Trigger Button */}
- {/* Dropdown Menu - Persistent Mount for State Preservation */} + {/* Dropdown Menu */}
= ({ /* Desktop: Center alignment */ md:left-1/2 md:right-auto md:origin-top md:-translate-x-1/2 - w-80 md:w-[30rem] bg-gray-800/95 border border-white/10 rounded-xl shadow-2xl z-50 overflow-hidden backdrop-blur-xl + 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'}`} > -
+
+ {/* Section 1: Sync Strategy */} -
+

Sync Strategy

{STRATEGIES.map((strategy) => ( @@ -217,23 +316,23 @@ const StrategySelector: React.FC = ({
{/* Section 2: Regex Preprocessing */} -
+
-

Regex Rules

- {localReplacements.length === 0 && ( - - )} +

Regex Rules

+ {localReplacements.length === 0 && ( + + )}
-
+
{localReplacements.length === 0 ? ( -
+
No regex replacements configured.
) : ( @@ -242,11 +341,11 @@ const StrategySelector: React.FC = ({
handleUpdateRegex(regex.id, 'pattern', e.target.value)} - className={`w-full bg-gray-900/80 border rounded-md px-2.5 py-1.5 text-xs text-gray-200 focus:outline-none focus:ring-1 transition-all placeholder-gray-600 - ${!regex.pattern && isDirty ? 'border-red-500/30 focus:border-red-500' : 'border-gray-700 focus:border-plex-orange'}`} + 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'}`} />
@@ -258,7 +357,7 @@ const StrategySelector: React.FC = ({ 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.5 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" + 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" />
- {/* Actions */} -
- {localReplacements.length > 0 && ( -
- + +
+ +
- )} +
+
-
- + ))} +
+ + {/* Tab Content */} +
+ {activeTab === ScheduleMode.CRON && ( +
+
+ Cron: + 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" + /> +
+

+ Unix-cron format. Leave empty to disable schedule. +

+
+ )} + + {activeTab === ScheduleMode.DAILY && ( +
+ {/* Top Row: Checkbox + Label */} +
+ + +
+ + {/* Bottom Row: Centered Native Time Input */} +
+ 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' : ''}`} + /> +
+
+ )} + + {activeTab === ScheduleMode.WEEKLY && ( +
+ {/* Top Row: Checkbox + Label */} +
+ + +
+ + {/* Middle Row: Full Width Capsules */} +
+ {WEEK_DAYS.map((day, index) => { + const isSelected = localSchedule.weeklyDays.includes(index); + return ( + + ) + })} +
+ + {/* Bottom Row: Centered Native Time Input */} +
+ 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' : ''}`} + /> +
+
+ )} +
+ + {/* Auto Watch Checkbox */} +
+ - -
-
+
+ + {/* Action Buttons (Mirrored from Regex) */} +
+ + +
- {/* Section 3: Sync Now Button */} -
+ {/* Section 4: Sync Now Button */} +
- {isDirty && ( + {(isRegexDirty) && (

- Please save or revert regex rules changes before syncing. + Please save regex changes before syncing.

)}
@@ -351,4 +614,4 @@ const StrategySelector: React.FC = ({ ); }; -export default StrategySelector; +export default StrategySelector; \ No newline at end of file diff --git a/index.html b/index.html index f33b125..6bcccb0 100644 --- a/index.html +++ b/index.html @@ -41,13 +41,13 @@ background: #6b7280; } + /* Force native date/time pickers to use dark mode scheme */ + input[type="time"] { + color-scheme: dark; + } + /* Symmetrical Diagonal Scroll Animations - Pattern width: 40px (20px color + 20px transparent). - Diagonal length: 40 * sqrt(2) ≈ 56.57px. - - Left Side: Anchored to Right (Center). Moves Left (increases right offset). - Right Side: Anchored to Left (Center). Moves Right (increases left offset). */ @keyframes scroll-out-left { 0% { background-position: right 0 top 0; } @@ -64,7 +64,8 @@ "lucide-react": "https://aistudiocdn.com/lucide-react@^0.555.0", "react/": "https://aistudiocdn.com/react@^19.2.0/", "react": "https://aistudiocdn.com/react@^19.2.0", - "react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/" + "react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/", + "react-dom": "https://aistudiocdn.com/react-dom@^19.2.0" } } diff --git a/services/api.ts b/services/api.ts index aa7785d..347a1f8 100644 --- a/services/api.ts +++ b/services/api.ts @@ -1,5 +1,6 @@ -import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, SyncStrategy, RegexReplacement } from '../types'; + +import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, SyncStrategy, RegexReplacement, ScheduleSettings, ScheduleMode } from '../types'; import { MOCK_LOCAL_PLAYLISTS, MOCK_CLOUD_PLAYLISTS } from './mockData'; const SIMULATE_DELAY_MS = 800; @@ -135,6 +136,14 @@ const triggerSync = async (strategy: SyncStrategy, regexRules: RegexReplacement[ }); }; +// Basic Cron validation helper +const validateCron = (expression: string): boolean => { + const parts = expression.trim().split(/\s+/); + if (parts.length !== 5) return false; + // A very naive check, real validation is more complex but this fits the mock requirement + return true; +}; + export const apiService = { getPlaylists: async (serverType: ServerType, signal?: AbortSignal): Promise> => { try { @@ -191,5 +200,22 @@ export const apiService = { } catch (error) { return { data: null, status: 'error', message: 'Sync failed' }; } + }, + + saveScheduleSettings: async (settings: ScheduleSettings): Promise> => { + // Simulate API call + return new Promise((resolve) => { + setTimeout(() => { + // Validation only applies if the mode is CRON and user provided input + if (settings.mode === ScheduleMode.CRON && settings.cronExpression.trim() !== '') { + if (!validateCron(settings.cronExpression)) { + resolve({ data: null, status: 'error', message: 'Invalid Cron expression format' }); + return; + } + } + + resolve({ data: null, status: 'success', message: 'Schedule updated successfully' }); + }, 500); + }); } -}; +}; \ No newline at end of file diff --git a/types.ts b/types.ts index e910c70..10c2b51 100644 --- a/types.ts +++ b/types.ts @@ -1,4 +1,5 @@ + export interface Track { id: string; title: string; @@ -40,6 +41,22 @@ export interface RegexReplacement { replacement: string; } +export enum ScheduleMode { + DISABLED = 'DISABLED', + CRON = 'CRON', + DAILY = 'DAILY', + WEEKLY = 'WEEKLY' +} + +export interface ScheduleSettings { + mode: ScheduleMode; + cronExpression: string; + dailyTime: string; + weeklyDays: number[]; // 0 = Sunday, 1 = Monday, etc. + weeklyTime: string; + autoWatch: boolean; +} + export interface PlexLibrary { id: string; title: string; @@ -69,4 +86,4 @@ export interface ApiResponse { data: T; status: 'success' | 'error'; message?: string; -} +} \ No newline at end of file