feat: add identification page
This commit is contained in:
+178
-58
@@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user