Squashed 'sample-front-end/' changes from 32f6ed7..e855771
e855771 feat: Implement user authentication and login screen git-subtree-dir: sample-front-end git-subtree-split: e8557717c9397bb10286a61f1d29a9976c10ecba
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
|
||||
|
||||
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { Playlist, ServerType, SyncStrategy, PlexServerConnection, PathMappingConfig, PathMappingMode, SyncState, ScheduleSettings, ScheduleMode, BackupSettings } from './types';
|
||||
import { apiService } from './services/api';
|
||||
@@ -16,7 +18,8 @@ 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, Clock, Eye, EyeOff, Type, Code2, Archive, Languages } from 'lucide-react';
|
||||
import LoginScreen from './components/LoginScreen';
|
||||
import { ArrowLeftRight, ShieldCheck, X, Server, ServerOff, Clock, Eye, EyeOff, Type, Code2, Archive, Languages, LogOut, User } from 'lucide-react';
|
||||
import { useLanguage } from './LanguageContext';
|
||||
|
||||
interface Toast {
|
||||
@@ -115,6 +118,12 @@ const useStripeAnimation = (syncState: SyncState) => {
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { t, language, setLanguage } = useLanguage();
|
||||
|
||||
// Auth State
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState('');
|
||||
|
||||
// App Data State
|
||||
const [localPlaylists, setLocalPlaylists] = useState<Playlist[]>([]);
|
||||
const [cloudPlaylists, setCloudPlaylists] = useState<Playlist[]>([]);
|
||||
const [cloudServerInfo, setCloudServerInfo] = useState<PlexServerConnection | undefined>(undefined);
|
||||
@@ -198,6 +207,16 @@ const App: React.FC = () => {
|
||||
timeoutsRef.current[id] = dismissTimer;
|
||||
};
|
||||
|
||||
// Check auth on mount
|
||||
useEffect(() => {
|
||||
const savedToken = localStorage.getItem('plexsync-token');
|
||||
const savedUser = localStorage.getItem('plexsync-username');
|
||||
if (savedToken && savedUser) {
|
||||
setIsAuthenticated(true);
|
||||
setCurrentUser(savedUser);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Effect to trigger the "slide down" animation
|
||||
useEffect(() => {
|
||||
const enteringIds = toasts.filter(t => t.entering).map(t => t.id);
|
||||
@@ -236,6 +255,7 @@ const App: React.FC = () => {
|
||||
|
||||
// Fetch Local Playlists
|
||||
const refreshLocal = useCallback(async () => {
|
||||
if (!isAuthenticated) return;
|
||||
if (localAbortRef.current) localAbortRef.current.abort();
|
||||
const abortController = new AbortController();
|
||||
localAbortRef.current = abortController;
|
||||
@@ -247,7 +267,7 @@ const App: React.FC = () => {
|
||||
}
|
||||
setLoadingLocal(false);
|
||||
localAbortRef.current = null;
|
||||
}, []);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const cancelLocalRefresh = () => {
|
||||
if (localAbortRef.current) {
|
||||
@@ -260,6 +280,7 @@ const App: React.FC = () => {
|
||||
|
||||
// Fetch Cloud Playlists and Info
|
||||
const refreshCloud = useCallback(async () => {
|
||||
if (!isAuthenticated) return;
|
||||
if (cloudAbortRef.current) cloudAbortRef.current.abort();
|
||||
const abortController = new AbortController();
|
||||
cloudAbortRef.current = abortController;
|
||||
@@ -281,7 +302,7 @@ const App: React.FC = () => {
|
||||
setLoadingCloud(false);
|
||||
cloudAbortRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const cancelCloudRefresh = () => {
|
||||
if (cloudAbortRef.current) {
|
||||
@@ -292,16 +313,18 @@ const App: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Initial Load
|
||||
// Initial Load (Only if Authenticated)
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
refreshLocal();
|
||||
refreshCloud();
|
||||
}
|
||||
return () => {
|
||||
// Cleanup on unmount
|
||||
if (localAbortRef.current) localAbortRef.current.abort();
|
||||
if (cloudAbortRef.current) cloudAbortRef.current.abort();
|
||||
}
|
||||
}, [refreshLocal, refreshCloud]);
|
||||
}, [isAuthenticated, refreshLocal, refreshCloud]);
|
||||
|
||||
// Handle Strategy Change
|
||||
const handleStrategyChange = (strategy: SyncStrategy, label: string) => {
|
||||
@@ -364,13 +387,6 @@ const App: React.FC = () => {
|
||||
|
||||
// Timing Breakdown:
|
||||
// T+0.0s: State is SUCCESS.
|
||||
// - JS Animation loop detects change and begins decelerating speed from 56 -> 0 over 0.5s.
|
||||
// - CSS opacity transitions Yellow -> Green over 0.3s.
|
||||
|
||||
// T+0.5s: Deceleration complete. Speed is 0. Background is static.
|
||||
// We hold this static state for another 0.5s.
|
||||
|
||||
// T+1.0s: Total success duration complete. Disappear.
|
||||
setTimeout(() => {
|
||||
setSyncState(SyncState.IDLE);
|
||||
refreshLocal();
|
||||
@@ -390,6 +406,27 @@ const App: React.FC = () => {
|
||||
refreshCloud();
|
||||
};
|
||||
|
||||
const handleLoginSuccess = (token: string, username: string) => {
|
||||
localStorage.setItem('plexsync-token', token);
|
||||
localStorage.setItem('plexsync-username', username);
|
||||
setIsAuthenticated(true);
|
||||
setCurrentUser(username);
|
||||
addToast(t('auth.welcome', { user: username }));
|
||||
};
|
||||
|
||||
const handleLoginError = (msg: string) => {
|
||||
// Toast handles error display, or LoginScreen internal state handles UI
|
||||
addToast(msg);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('plexsync-token');
|
||||
localStorage.removeItem('plexsync-username');
|
||||
setIsAuthenticated(false);
|
||||
setCurrentUser('');
|
||||
addToast(t('toasts.loggedOut'));
|
||||
};
|
||||
|
||||
const getToastStyles = (toast: Toast): React.CSSProperties => {
|
||||
if (toast.exiting || toast.entering) {
|
||||
return {
|
||||
@@ -563,6 +600,34 @@ const App: React.FC = () => {
|
||||
|
||||
const backupInfo = getBackupDisplayInfo(backupSettings);
|
||||
|
||||
// If not authenticated, show Login Screen
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<>
|
||||
{/* Render toasts over login screen if needed (e.g. login errors pushed to global toast) */}
|
||||
<div className="fixed top-20 left-0 right-0 flex justify-center h-0 overflow-visible z-[100] pointer-events-none">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={getToastClasses()}
|
||||
style={getToastStyles(toast)}
|
||||
>
|
||||
<ShieldCheck size={16} />
|
||||
<span>{toast.message}</span>
|
||||
<button
|
||||
onClick={() => setToasts(prev => prev.map(t => t.id === toast.id ? { ...t, exiting: true } : t))}
|
||||
className="ml-2 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<LoginScreen onLoginSuccess={handleLoginSuccess} onLoginError={handleLoginError} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
|
||||
@@ -724,6 +789,15 @@ const App: React.FC = () => {
|
||||
>
|
||||
{isConnected ? <Server size={18} /> : <ServerOff size={18} />}
|
||||
</button>
|
||||
|
||||
{/* Logout Button */}
|
||||
<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-red-400 hover:border-red-500/30 hover:bg-red-500/10 transition-all"
|
||||
title={t('auth.logout') + ` (${currentUser})`}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useLanguage } from '../LanguageContext';
|
||||
import { apiService } from '../services/api';
|
||||
import { Lock, User, Loader2, Languages, ArrowRight, ArrowLeftRight } from 'lucide-react';
|
||||
|
||||
interface LoginScreenProps {
|
||||
onLoginSuccess: (token: string, username: string) => void;
|
||||
onLoginError: (msg: string) => void;
|
||||
}
|
||||
|
||||
const LoginScreen: React.FC<LoginScreenProps> = ({ onLoginSuccess, onLoginError }) => {
|
||||
const { t, language, setLanguage } = useLanguage();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setLocalError(null);
|
||||
|
||||
try {
|
||||
// Mock credentials: admin / password
|
||||
const response = await apiService.login({ username, password });
|
||||
|
||||
if (response.status === 'success') {
|
||||
onLoginSuccess(response.data.token, response.data.username);
|
||||
} else {
|
||||
const errorMsg = response.message || t('auth.invalidCredentials');
|
||||
setLocalError(errorMsg);
|
||||
onLoginError(errorMsg);
|
||||
}
|
||||
} catch (err) {
|
||||
setLocalError(t('auth.invalidCredentials'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-gray-800 via-gray-900 to-black p-4">
|
||||
|
||||
{/* Background decoration */}
|
||||
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
||||
<div className="absolute top-0 left-1/4 w-96 h-96 bg-plex-orange/10 rounded-full blur-[100px] opacity-20"></div>
|
||||
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-blue-500/10 rounded-full blur-[100px] opacity-20"></div>
|
||||
</div>
|
||||
|
||||
{/* Language Switcher (Top Right) */}
|
||||
<div className="absolute top-6 right-6 z-20">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsLangMenuOpen(!isLangMenuOpen)}
|
||||
className="flex items-center space-x-2 px-3 py-2 rounded-lg bg-gray-800/50 hover:bg-gray-700/50 border border-gray-700 hover:border-gray-600 text-gray-300 transition-all backdrop-blur-sm"
|
||||
>
|
||||
<Languages size={16} />
|
||||
<span className="text-sm font-medium">{language === 'en' ? 'English' : 'Español'}</span>
|
||||
</button>
|
||||
|
||||
{isLangMenuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" 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-20 overflow-hidden animate-in fade-in slide-in-from-top-2">
|
||||
<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>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Card */}
|
||||
<div className="w-full max-w-md bg-gray-900/60 backdrop-blur-xl border border-gray-700/50 rounded-2xl shadow-2xl p-8 z-10 animate-in zoom-in-95 duration-300">
|
||||
|
||||
<div className="text-center mb-8 flex flex-col items-center">
|
||||
<div className="inline-flex items-center justify-center p-3 rounded-xl bg-gradient-to-br from-plex-orange to-yellow-600 shadow-lg shadow-plex-orange/20 mb-4">
|
||||
<ArrowLeftRight size={32} strokeWidth={2.5} className="text-gray-900" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-white">
|
||||
<span className="text-plex-orange">PMS</span> Playlist Sync
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
|
||||
{localError && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-xs flex items-center justify-center">
|
||||
{localError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wider ml-1">{t('auth.username')}</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User size={16} className="text-gray-500 group-focus-within:text-plex-orange transition-colors" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full h-11 pl-10 pr-4 bg-gray-800/50 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:border-plex-orange focus:ring-1 focus:ring-plex-orange focus:outline-none transition-all"
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wider ml-1">{t('auth.password')}</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock size={16} className="text-gray-500 group-focus-within:text-plex-orange transition-colors" />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full h-11 pl-10 pr-4 bg-gray-800/50 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:border-plex-orange focus:ring-1 focus:ring-plex-orange focus:outline-none transition-all"
|
||||
placeholder="password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !username || !password}
|
||||
className={`w-full h-12 mt-6 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all shadow-lg
|
||||
${isLoading
|
||||
? 'bg-gray-700 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-plex-orange text-gray-900 hover:bg-yellow-500 hover:shadow-plex-orange/30 active:scale-[0.98]'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
<span>{t('auth.loggingIn')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{t('auth.loginBtn')}</span>
|
||||
<ArrowRight size={18} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-gray-700/50 text-center">
|
||||
<p className="text-[10px] text-gray-600">
|
||||
© PMS Playlist Sync
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginScreen;
|
||||
@@ -1,4 +1,6 @@
|
||||
|
||||
|
||||
|
||||
export const en = {
|
||||
app: {
|
||||
// title and manager are no longer used for branding
|
||||
@@ -6,6 +8,17 @@ export const en = {
|
||||
manager: 'Manager',
|
||||
footer: '© {year} PMS Playlist Sync. Connected to Docker backend.',
|
||||
},
|
||||
auth: {
|
||||
title: 'Login',
|
||||
subtitle: 'Sign in to manage your playlist syncs',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
loginBtn: 'Sign In',
|
||||
logout: 'Logout',
|
||||
loggingIn: 'Verifying...',
|
||||
invalidCredentials: 'Invalid username or password',
|
||||
welcome: 'Welcome, {user}',
|
||||
},
|
||||
common: {
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
@@ -143,5 +156,7 @@ export const en = {
|
||||
librarySwitched: 'Library switched to {library}',
|
||||
connectedTo: 'Successfully connected to {name}',
|
||||
connectionCancelled: 'Connection cancelled by user.',
|
||||
loginFailed: 'Login failed. Please check credentials.',
|
||||
loggedOut: 'Successfully logged out.',
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
|
||||
|
||||
|
||||
export const es = {
|
||||
app: {
|
||||
// title and manager are no longer used for branding
|
||||
@@ -6,6 +8,17 @@ export const es = {
|
||||
manager: 'Gestor',
|
||||
footer: '© {year} PMS Playlist Sync. Conectado al backend Docker.',
|
||||
},
|
||||
auth: {
|
||||
title: 'Iniciar Sesión',
|
||||
subtitle: 'Ingrese para gestionar sus sincronizaciones',
|
||||
username: 'Usuario',
|
||||
password: 'Password',
|
||||
loginBtn: 'Entrar',
|
||||
logout: 'Salir',
|
||||
loggingIn: 'Verificando...',
|
||||
invalidCredentials: 'Usuario o contraseña incorrectos',
|
||||
welcome: 'Bienvenido, {user}',
|
||||
},
|
||||
common: {
|
||||
save: 'Guardar',
|
||||
cancel: 'Cancelar',
|
||||
@@ -143,5 +156,7 @@ export const es = {
|
||||
librarySwitched: 'Librería cambiada a {library}',
|
||||
connectedTo: 'Conectado exitosamente a {name}',
|
||||
connectionCancelled: 'Conexión cancelada por usuario.',
|
||||
loginFailed: 'Fallo de inicio de sesión. Verifique credenciales.',
|
||||
loggedOut: 'Sesión cerrada exitosamente.',
|
||||
}
|
||||
};
|
||||
+25
-1
@@ -3,7 +3,9 @@
|
||||
|
||||
|
||||
|
||||
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, SyncStrategy, PathMappingConfig, ScheduleSettings, ScheduleMode, BackupSettings } from '../types';
|
||||
|
||||
|
||||
import { Playlist, ServerType, ApiResponse, PlexServerConnection, PlexConnectionSettings, PlexLibrary, SyncStrategy, PathMappingConfig, ScheduleSettings, ScheduleMode, BackupSettings, LoginCredentials, AuthResponse } from '../types';
|
||||
import { MOCK_LOCAL_PLAYLISTS, MOCK_CLOUD_PLAYLISTS } from './mockData';
|
||||
|
||||
const SIMULATE_DELAY_MS = 800;
|
||||
@@ -229,5 +231,27 @@ export const apiService = {
|
||||
resolve({ data: null, status: 'success', message: 'Backup settings saved' });
|
||||
}, 500);
|
||||
});
|
||||
},
|
||||
|
||||
// Mock Login - In a real app this would POST to a backend
|
||||
login: async (creds: LoginCredentials): Promise<ApiResponse<AuthResponse>> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
// Hardcoded mock credentials for demonstration
|
||||
if (creds.username === 'admin' && creds.password === 'password') {
|
||||
resolve({
|
||||
data: { token: 'mock-jwt-token-123', username: 'admin' },
|
||||
status: 'success',
|
||||
message: 'Login successful'
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
data: { token: '', username: '' },
|
||||
status: 'error',
|
||||
message: 'Invalid credentials'
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user