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
This commit is contained in:
2025-11-29 08:23:31 +09:00
parent 74b37a062c
commit 06e49be1f9
5 changed files with 534 additions and 98 deletions
+146 -17
View File
@@ -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<HTMLDivElement>(null);
const rightGreenRef = useRef<HTMLDivElement>(null);
const requestRef = useRef<number>();
const requestRef = useRef<number | undefined>(undefined);
const lastTimeRef = useRef<number>(0);
const offsetRef = useRef<number>(0);
@@ -138,6 +140,16 @@ const App: React.FC = () => {
// Regex State
const [regexReplacements, setRegexReplacements] = useState<RegexReplacement[]>([]);
// Schedule State
const [scheduleSettings, setScheduleSettings] = useState<ScheduleSettings>({
mode: ScheduleMode.DISABLED,
cronExpression: '',
dailyTime: '02:00',
weeklyDays: [0], // Sunday
weeklyTime: '03:00',
autoWatch: false
});
// Toast Notification System
const [toasts, setToasts] = useState<Toast[]>([]);
const timeoutsRef = useRef<{[key: number]: ReturnType<typeof setTimeout>}>({});
@@ -286,6 +298,29 @@ const App: React.FC = () => {
addToast('Regex preprocessing rules have been saved.');
};
// Handle Schedule Save
const handleSaveSchedule = async (settings: ScheduleSettings): Promise<boolean> => {
// 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 (
<div className="min-h-screen flex flex-col bg-gray-900 text-gray-100 font-sans overflow-hidden bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-gray-800 via-gray-900 to-black">
@@ -411,7 +524,7 @@ const App: React.FC = () => {
{syncState === SyncState.IDLE ? (
<>
{/* Normal Toolbar */}
{/* Normal Toolbar Left */}
<div className="flex items-center space-x-3">
<div className="p-1.5 rounded-lg shadow-lg bg-gradient-to-br from-plex-orange to-yellow-600 text-gray-900 shadow-plex-orange/20">
<ArrowLeftRight size={24} strokeWidth={2.5} />
@@ -421,18 +534,32 @@ const App: React.FC = () => {
</h1>
</div>
{/* Connection Status Button */}
<button
onClick={() => setIsConnectionModalOpen(true)}
className={`flex items-center justify-center w-9 h-9 rounded-full border transition-all duration-300 hover:scale-105 active:scale-95 shadow-md
${isConnected
? "bg-green-500/10 border-green-500/50 text-green-400 hover:bg-green-500/20 hover:shadow-green-500/20"
: "bg-red-500/10 border-red-500/50 text-red-400 hover:bg-red-500/20 hover:shadow-red-500/20"
}`}
title={isConnected ? "Connected to Plex" : "Disconnected"}
>
{isConnected ? <Server size={18} /> : <ServerOff size={18} />}
</button>
{/* Normal Toolbar Right */}
<div className="flex items-center gap-4">
{/* Schedule Info */}
<div className="flex flex-col items-end mr-2 md:mr-0 hidden md:flex">
<span className="text-[10px] uppercase font-bold text-gray-500 tracking-wider">
{scheduleInfo.label}
</span>
<div className={`text-xs font-mono flex items-center gap-1.5 ${scheduleInfo.active ? 'text-plex-orange' : 'text-gray-600'}`}>
{scheduleInfo.active && <Clock size={12} />}
<span>{scheduleInfo.value}</span>
</div>
</div>
{/* Connection Status Button */}
<button
onClick={() => setIsConnectionModalOpen(true)}
className={`flex items-center justify-center w-9 h-9 rounded-full border transition-all duration-300 hover:scale-105 active:scale-95 shadow-md
${isConnected
? "bg-green-500/10 border-green-500/50 text-green-400 hover:bg-green-500/20 hover:shadow-green-500/20"
: "bg-red-500/10 border-red-500/50 text-red-400 hover:bg-red-500/20 hover:shadow-red-500/20"
}`}
title={isConnected ? "Connected to Plex" : "Disconnected"}
>
{isConnected ? <Server size={18} /> : <ServerOff size={18} />}
</button>
</div>
</>
) : (
/* 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;
export default App;