First commit
This commit is contained in:
232
src/client/App.jsx
Normal file
232
src/client/App.jsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { request } from './api/client.js';
|
||||
import { Sidebar } from './components/Sidebar.jsx';
|
||||
import { ToastProvider, useToast } from './components/Toast.jsx';
|
||||
import { AccountView } from './routes/AccountView.jsx';
|
||||
import { AlertsView } from './routes/AlertsView.jsx';
|
||||
import { AuthPanel } from './routes/AuthPanel.jsx';
|
||||
import { NotificationMethodsView } from './routes/NotificationMethodsView.jsx';
|
||||
import { SiteSettingsPanel } from './routes/SiteSettingsPanel.jsx';
|
||||
import { SitesView } from './routes/SitesView.jsx';
|
||||
|
||||
const viewPaths = {
|
||||
sites: '/sites',
|
||||
alerts: '/alerts',
|
||||
notifications: '/notifications',
|
||||
account: '/account',
|
||||
};
|
||||
|
||||
function normalizedPath(pathname) {
|
||||
if (!pathname || pathname === '/') return '/';
|
||||
return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
}
|
||||
|
||||
function parseRoute(pathname) {
|
||||
const path = normalizedPath(pathname);
|
||||
if (path === '/') return { kind: 'root' };
|
||||
if (path === '/login') return { kind: 'auth', mode: 'login' };
|
||||
if (path === '/register') return { kind: 'auth', mode: 'register' };
|
||||
if (path === '/sites') return { kind: 'sites', activeView: 'sites', protected: true };
|
||||
if (path === '/alerts') return { kind: 'alerts', activeView: 'alerts', protected: true };
|
||||
if (path === '/notifications') {
|
||||
return { kind: 'notifications', activeView: 'notifications', protected: true };
|
||||
}
|
||||
if (path === '/account') return { kind: 'account', activeView: 'account', protected: true };
|
||||
|
||||
const siteSettingsMatch = path.match(/^\/sites\/([^/]+)\/settings$/);
|
||||
if (siteSettingsMatch) {
|
||||
return {
|
||||
kind: 'siteSettings',
|
||||
activeView: 'sites',
|
||||
protected: true,
|
||||
siteId: decodeURIComponent(siteSettingsMatch[1]),
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: 'notFound' };
|
||||
}
|
||||
|
||||
function isProtectedPath(pathname) {
|
||||
return Boolean(parseRoute(pathname).protected);
|
||||
}
|
||||
|
||||
function currentPath() {
|
||||
return normalizedPath(window.location.pathname);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [locationPath, setLocationPath] = useState(currentPath);
|
||||
const [ready, setReady] = useState(false);
|
||||
const pendingReturnPath = useRef(null);
|
||||
|
||||
const route = parseRoute(locationPath);
|
||||
|
||||
const navigate = useCallback((path, options = {}) => {
|
||||
const nextPath = normalizedPath(path);
|
||||
if (currentPath() !== nextPath) {
|
||||
const method = options.replace ? 'replaceState' : 'pushState';
|
||||
window.history[method](null, '', nextPath);
|
||||
}
|
||||
setLocationPath(nextPath);
|
||||
}, []);
|
||||
|
||||
const logout = useMemo(
|
||||
() => async () => {
|
||||
await request('/api/auth/logout', { method: 'POST' }).catch(() => {});
|
||||
pendingReturnPath.current = null;
|
||||
setUser(null);
|
||||
navigate('/login', { replace: true });
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function handlePopState() {
|
||||
setLocationPath(currentPath());
|
||||
}
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function boot() {
|
||||
const csrf = await request('/api/auth/csrf');
|
||||
localStorage.setItem('csrfToken', csrf.csrfToken);
|
||||
const me = await request('/api/auth/me').catch(() => null);
|
||||
setUser(me?.user ?? null);
|
||||
setReady(true);
|
||||
}
|
||||
boot();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
|
||||
if (user) {
|
||||
if (route.kind === 'auth' || route.kind === 'root' || route.kind === 'notFound') {
|
||||
navigate('/sites', { replace: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (route.protected) {
|
||||
pendingReturnPath.current = locationPath;
|
||||
navigate('/login', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (route.kind === 'root' || route.kind === 'notFound') {
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
}, [locationPath, navigate, ready, route.kind, route.protected, user]);
|
||||
|
||||
if (!ready) {
|
||||
return <div className="loading">CertRemind</div>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
const mode = route.kind === 'auth' ? route.mode : 'login';
|
||||
return (
|
||||
<AuthPanel
|
||||
mode={mode}
|
||||
onModeChange={(nextMode) => navigate(nextMode === 'register' ? '/register' : '/login')}
|
||||
onAuthed={(nextUser) => {
|
||||
setUser(nextUser);
|
||||
const returnPath = pendingReturnPath.current;
|
||||
pendingReturnPath.current = null;
|
||||
navigate(isProtectedPath(returnPath) ? returnPath : '/sites', { replace: true });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function withShell(content) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<div className="app-frame">
|
||||
<Sidebar
|
||||
activeView={route.activeView ?? 'sites'}
|
||||
user={user}
|
||||
onNavigate={(nextView) => navigate(viewPaths[nextView] ?? '/sites')}
|
||||
onLogout={logout}
|
||||
/>
|
||||
<div className="app-content">{content}</div>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (route.kind === 'siteSettings') {
|
||||
return withShell(
|
||||
<SiteSettingsRoute siteId={route.siteId} onBack={() => navigate('/sites')} navigate={navigate} />,
|
||||
);
|
||||
}
|
||||
|
||||
if (route.kind === 'alerts') {
|
||||
return withShell(<AlertsView onBack={() => navigate('/sites')} />);
|
||||
}
|
||||
|
||||
if (route.kind === 'notifications') {
|
||||
return withShell(<NotificationMethodsView onBack={() => navigate('/sites')} />);
|
||||
}
|
||||
|
||||
if (route.kind === 'account') {
|
||||
return withShell(
|
||||
<AccountView
|
||||
onBack={() => navigate('/sites')}
|
||||
onSignedOut={() => {
|
||||
pendingReturnPath.current = null;
|
||||
setUser(null);
|
||||
navigate('/login', { replace: true });
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
return withShell(
|
||||
<SitesView
|
||||
user={user}
|
||||
onConfigureSite={(site) =>
|
||||
navigate(`/sites/${encodeURIComponent(site.siteId)}/settings`)
|
||||
}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function SiteSettingsRoute({ siteId, onBack, navigate }) {
|
||||
const [site, setSite] = useState(null);
|
||||
const [loadingSiteId, setLoadingSiteId] = useState(siteId);
|
||||
const { showToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setSite(null);
|
||||
setLoadingSiteId(siteId);
|
||||
|
||||
async function loadSite() {
|
||||
try {
|
||||
const data = await request(`/api/sites/${siteId}`);
|
||||
if (active) {
|
||||
setSite(data.site);
|
||||
}
|
||||
} catch (err) {
|
||||
if (active) {
|
||||
showToast({ type: 'error', message: err.message });
|
||||
navigate('/sites', { replace: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadSite();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [navigate, showToast, siteId]);
|
||||
|
||||
if (!site || loadingSiteId !== siteId) {
|
||||
return <div className="loading">CertRemind</div>;
|
||||
}
|
||||
|
||||
return <SiteSettingsPanel site={site} onBack={onBack} />;
|
||||
}
|
||||
Reference in New Issue
Block a user