feat: add identification page

This commit is contained in:
2025-12-18 06:46:27 +09:00
parent e3d3df9ecb
commit ea5a0004da
17 changed files with 696 additions and 79 deletions
+178 -58
View File
@@ -15,8 +15,9 @@ 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 LoginScreen from './components/LoginScreen';
import OverflowMarquee from './components/OverflowMarquee';
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2, Archive, Languages } from 'lucide-react';
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2, Archive, Languages, LogOut } from 'lucide-react';
import { useLanguage } from './LanguageContext';
interface Toast {
@@ -115,6 +116,12 @@ const useStripeAnimation = (syncState: SyncState) => {
const App: React.FC = () => {
const { t, language, setLanguage } = useLanguage();
// Auth (optional; controlled by backend /api/auth/config)
const [authReady, setAuthReady] = useState(false);
const [authEnabled, setAuthEnabled] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [currentUser, setCurrentUser] = useState('');
const [localPlaylists, setLocalPlaylists] = useState<Playlist[]>([]);
const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]);
const [cloudServerInfo, setCloudServerInfo] = useState<PlexServerConnection | undefined>(undefined);
@@ -171,6 +178,75 @@ const App: React.FC = () => {
retentionCount: 5
});
useEffect(() => {
let cancelled = false;
const initAuth = async () => {
try {
const cfg = await apiService.getAuthConfig();
const enabled = cfg.status === 'success' ? !!cfg.data.enabled : false;
if (cancelled) return;
setAuthEnabled(enabled);
if (!enabled) {
setIsAuthenticated(true);
setAuthReady(true);
return;
}
const savedToken = localStorage.getItem('plexsync-token');
const savedUser = localStorage.getItem('plexsync-username');
if (!savedToken || !savedUser) {
setIsAuthenticated(false);
setCurrentUser('');
setAuthReady(true);
return;
}
const me = await apiService.me();
if (me.status === 'success') {
setIsAuthenticated(true);
setCurrentUser(me.data.username || savedUser);
} else {
localStorage.removeItem('plexsync-token');
localStorage.removeItem('plexsync-username');
setIsAuthenticated(false);
setCurrentUser('');
}
} catch {
// If auth discovery fails, fall back to no-auth to keep local dev workable.
setAuthEnabled(false);
setIsAuthenticated(true);
} finally {
if (!cancelled) setAuthReady(true);
}
};
initAuth();
return () => {
cancelled = true;
};
}, []);
const handleLoginSuccess = (token: string, username: string) => {
localStorage.setItem('plexsync-token', token);
localStorage.setItem('plexsync-username', username);
setCurrentUser(username);
setIsAuthenticated(true);
};
const handleLogout = async () => {
try {
await apiService.logout();
} catch {
// ignore
}
localStorage.removeItem('plexsync-token');
localStorage.removeItem('plexsync-username');
setIsAuthenticated(false);
setCurrentUser('');
};
// Toast Notification System
const [toasts, setToasts] = useState<Toast[]>([]);
const timeoutsRef = useRef<{[key: number]: ReturnType<typeof setTimeout>}>({});
@@ -299,6 +375,7 @@ const App: React.FC = () => {
// Fetch Local Playlists
const refreshLocal = useCallback(async () => {
if (!authReady || !isAuthenticated) return;
if (localAbortRef.current) localAbortRef.current.abort();
const abortController = new AbortController();
localAbortRef.current = abortController;
@@ -310,7 +387,7 @@ const App: React.FC = () => {
}
setLoadingLocal(false);
localAbortRef.current = null;
}, [localPath]);
}, [authReady, isAuthenticated, localPath]);
const cancelLocalRefresh = () => {
if (localAbortRef.current) {
@@ -323,6 +400,7 @@ const App: React.FC = () => {
// Fetch Cloud Playlists and Info
const refreshCloud = useCallback(async () => {
if (!authReady || !isAuthenticated) return;
if (cloudAbortRef.current) cloudAbortRef.current.abort();
const abortController = new AbortController();
cloudAbortRef.current = abortController;
@@ -344,7 +422,7 @@ const App: React.FC = () => {
setLoadingCloud(false);
cloudAbortRef.current = null;
}
}, []);
}, [authReady, isAuthenticated]);
const cancelCloudRefresh = () => {
if (cloudAbortRef.current) {
@@ -357,13 +435,15 @@ const App: React.FC = () => {
// Load persisted configuration
useEffect(() => {
if (!authReady || !isAuthenticated) return;
loadSettings();
loadSchedule();
loadBackupSettings();
}, [loadSettings, loadSchedule, loadBackupSettings]);
}, [authReady, isAuthenticated, loadSettings, loadSchedule, loadBackupSettings]);
// Initial Load
useEffect(() => {
if (!authReady || !isAuthenticated) return;
refreshLocal();
refreshCloud();
return () => {
@@ -371,7 +451,7 @@ const App: React.FC = () => {
if (localAbortRef.current) localAbortRef.current.abort();
if (cloudAbortRef.current) cloudAbortRef.current.abort();
}
}, [refreshLocal, refreshCloud]);
}, [authReady, isAuthenticated, refreshLocal, refreshCloud]);
// Handle Strategy Change
const handleStrategyChange = async (strategy: SyncStrategy, label: string) => {
@@ -397,6 +477,7 @@ const App: React.FC = () => {
// Handle Sync Trigger
const handleSyncTrigger = async () => {
if (!authReady || !isAuthenticated) return;
if (syncState !== SyncState.IDLE) return;
setSyncState(SyncState.SYNCING);
@@ -423,7 +504,16 @@ const App: React.FC = () => {
// SSE for sync status
useEffect(() => {
const eventSource = new EventSource(`${import.meta.env.VITE_API_BASE_URL || ''}/api/sync/events`);
if (!authReady || !isAuthenticated) return;
const base = `${import.meta.env.VITE_API_BASE_URL || ''}/api/sync/events`;
const url = new URL(base, window.location.origin);
if (authEnabled) {
const token = localStorage.getItem('plexsync-token');
if (!token) return;
url.searchParams.set('access_token', token);
}
const eventSource = new EventSource(url.toString());
eventSource.onmessage = (event) => {
try {
@@ -490,7 +580,24 @@ const App: React.FC = () => {
return () => {
eventSource.close();
};
}, [syncState, refreshLocal, refreshCloud, addToast]);
}, [authReady, isAuthenticated, authEnabled, syncState, refreshLocal, refreshCloud, addToast]);
if (!authReady) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900 text-gray-200">
{t('common.loading')}
</div>
);
}
if (authEnabled && !isAuthenticated) {
return (
<LoginScreen
onLoginSuccess={handleLoginSuccess}
onLoginError={(msg) => addToast(msg)}
/>
);
}
const handleConnectSuccess = async (serverInfo: PlexServerConnection) => {
setCloudServerInfo(serverInfo);
@@ -671,7 +778,7 @@ const App: React.FC = () => {
<ArrowLeftRight size={24} strokeWidth={2.5} />
</div>
<h1 className="text-xl font-bold tracking-tight text-white">
Plex<span className="text-plex-orange">Sync</span>
<span className="text-plex-orange">PMS</span> Playlist Sync
</h1>
</div>
@@ -725,60 +832,73 @@ const App: React.FC = () => {
</div>
</div>
{/* Language Switcher */}
<div className="relative">
<button
onClick={() => setIsLangMenuOpen(!isLangMenuOpen)}
className="flex items-center justify-center w-9 h-9 rounded-full border border-gray-700 bg-gray-800/50 text-gray-400 hover:text-white hover:bg-gray-700 transition-all"
title={t('common.switchLanguage')}
<div className="flex items-center gap-4">
{/* Language Switcher */}
<div className="relative">
<button
onClick={() => setIsLangMenuOpen(!isLangMenuOpen)}
className="flex items-center justify-center w-9 h-9 rounded-full border border-gray-700 bg-gray-800/50 text-gray-400 hover:text-white hover:bg-gray-700 transition-all"
title={t('common.switchLanguage')}
>
<Languages size={18} />
</button>
{isLangMenuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsLangMenuOpen(false)}></div>
<div className="absolute right-0 top-full mt-2 w-32 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-50 overflow-hidden">
<button
onClick={() => { setLanguage('en'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'en' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
English
</button>
<button
onClick={() => { setLanguage('es'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'es' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
Español
</button>
<button
onClick={() => { setLanguage('chs'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'chs' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
</button>
<button
onClick={() => { setLanguage('cht'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'cht' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
</button>
</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 ? t('dashboard.connected') : t('dashboard.disconnected')}
>
<Languages size={18} />
{isConnected ? <Server size={18} /> : <ServerOff size={18} />}
</button>
{isLangMenuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsLangMenuOpen(false)}></div>
<div className="absolute right-0 top-full mt-2 w-32 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-50 overflow-hidden">
<button
onClick={() => { setLanguage('en'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'en' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
English
</button>
<button
onClick={() => { setLanguage('es'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'es' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
Español
</button>
<button
onClick={() => { setLanguage('chs'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'chs' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
</button>
<button
onClick={() => { setLanguage('cht'); setIsLangMenuOpen(false); }}
className={`w-full px-4 py-2 text-sm text-left hover:bg-gray-700 transition-colors ${language === 'cht' ? 'text-plex-orange font-bold' : 'text-gray-300'}`}
>
</button>
</div>
</>
{/* Logout (rightmost) */}
{authEnabled && isAuthenticated && (
<button
onClick={handleLogout}
className="flex items-center justify-center w-9 h-9 rounded-full border border-gray-700 bg-gray-800/50 text-gray-400 hover:text-white hover:bg-gray-700 transition-all"
title={t('auth.logout')}
>
<LogOut size={18} />
</button>
)}
</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 ? t('dashboard.connected') : t('dashboard.disconnected')}
>
{isConnected ? <Server size={18} /> : <ServerOff size={18} />}
</button>
</div>
</>
) : (