First commit

This commit is contained in:
CyberRex
2026-05-23 17:03:05 +09:00
commit 40e7953ee5
52 changed files with 13004 additions and 0 deletions

232
src/client/App.jsx Normal file
View 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} />;
}