import {useEffect, useState} from 'react' import './App.css' import {connect} from 'socket.io-client' import {isJSONClean} from './utils/utils.ts' import {NavLink, Outlet, useNavigate} from "react-router-dom"; import {useStore} from "./store/store.ts"; import {LoadingScreen} from "./utils/LoadingScreen.tsx"; import {Trans, useTranslation} from "react-i18next"; import {Cable, Construction, Crown, NotepadText, Wrench, PhoneCall, LucideMenu, Bell, Users} from "lucide-react"; import {UpdateBanner} from "./components/UpdateBanner"; const WS_URL = import.meta.env.DEV ? 'http://localhost:9001' : '' export const App = () => { const setSettings = useStore(state => state.setSettings); const erasureEnabled = useStore(state => state.gdprAuthorErasureEnabled) const {t} = useTranslation() const navigate = useNavigate() const [sidebarOpen, setSidebarOpen] = useState(true) const updateStatus = useStore(state => state.updateStatus) const version = updateStatus?.currentVersion ?? null useEffect(() => { fetch('/admin-auth/', {method: 'POST'}).then((value) => { if (!value.ok) navigate('/login') }).catch(() => navigate('/login')) }, []); useEffect(() => { document.title = t('admin.page-title') useStore.getState().setShowLoading(true); const settingSocket = connect(`${WS_URL}/settings`, {transports: ['websocket']}); const pluginsSocket = connect(`${WS_URL}/pluginfw/installer`, {transports: ['websocket']}) // When the server explicitly rejects us for not being admin, we must // NOT reconnect on the subsequent `disconnect` event — otherwise the // socket cycles forever (connect → admin_auth_error → server // disconnect → SPA auto-reconnect → …). The flag is reset on each // successful connect. let authErrored = false; pluginsSocket.on('connect', () => { useStore.getState().setPluginsSocket(pluginsSocket); }); settingSocket.on('connect', () => { authErrored = false; useStore.getState().setSettingsSocket(settingSocket); useStore.getState().setShowLoading(false) settingSocket.emit('load'); console.log('connected'); }); settingSocket.on('disconnect', (reason) => { useStore.getState().setShowLoading(true) if (reason === 'io server disconnect' && !authErrored) settingSocket.connect(); }); settingSocket.on('settings', (settings: any) => { if (settings && typeof settings.flags === 'object' && settings.flags) { useStore.getState().setGdprAuthorErasureEnabled( !!settings.flags.gdprAuthorErasure); } if (settings.results === 'NOT_ALLOWED') { console.log('Not allowed to view settings.json') useStore.getState().setResolved(null); return; } if (isJSONClean(settings.results)) setSettings(settings.results); else alert(t('admin_settings.invalid_json')); // The resolved field is optional — old servers won't send it, // and the SPA degrades to today's behaviour when it's null. useStore.getState().setResolved(settings.resolved ?? null); useStore.getState().setShowLoading(false); }); settingSocket.on('saveprogress', (status: string, payload?: {message?: string}) => { const {setToastState} = useStore.getState(); if (status === 'saved') { setToastState({open: true, title: t('admin_settings.toast.saved'), success: true}); } else { const detail = payload?.message ?? ''; setToastState({open: true, title: t('admin_settings.toast.save_failed') + (detail ? ` (${detail})` : ''), success: false}); } }); // Backend emits this when the connecting socket does not have an // admin session. Previously the server just dropped silently, so the // SPA would sit on a loading screen with no clue. Surface it AND // suppress the automatic reconnect — without this flag the SPA would // immediately reconnect to a socket that will reject it again. settingSocket.on('admin_auth_error', (payload?: {message?: string}) => { authErrored = true; const {setToastState} = useStore.getState(); setToastState({ open: true, title: payload?.message || t('admin_settings.toast.auth_error'), success: false, }); useStore.getState().setShowLoading(false); }) return () => { settingSocket.disconnect(); pluginsSocket.disconnect() } }, []); const closeOnMobile = () => { if (window.innerWidth < 768) setSidebarOpen(false) } return (
{sidebarOpen && (
Etherpad
)}
{sidebarOpen && (
{version ? `v${version}` : 'Etherpad'}
)}
) } export default App