import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Box, Button, Input, Spinner, Text, color } from 'folds'; import { Outlet, generatePath, matchPath, useLocation, useNavigate, useParams, } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { AuthFooter } from './AuthFooter'; import * as css from './styles.css'; import { clientAllowedServer, clientDefaultServer, useClientConfig, } from '../../hooks/useClientConfig'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths'; import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api'; import { SpecVersionsLoader } from '../../components/SpecVersionsLoader'; import { SpecVersionsProvider } from '../../hooks/useSpecVersions'; import { AutoDiscoveryInfoProvider } from '../../hooks/useAutoDiscoveryInfo'; import { AuthFlowsLoader } from '../../components/AuthFlowsLoader'; import { AuthFlowsProvider } from '../../hooks/useAuthFlows'; import { AuthServerProvider } from '../../hooks/useAuthServer'; import { tryDecodeURIComponent } from '../../utils/dom'; import mascotPoster from '../../../../public/res/img/mascot.png'; import mascotWebm from '../../../../public/res/img/mascot.webm'; const currentAuthPath = (pathname: string): string => { if (matchPath(LOGIN_PATH, pathname)) return LOGIN_PATH; if (matchPath(RESET_PASSWORD_PATH, pathname)) return RESET_PASSWORD_PATH; if (matchPath(REGISTER_PATH, pathname)) return REGISTER_PATH; return LOGIN_PATH; }; function AuthLayoutLoading({ message }: { message: string }) { return ( {message} ); } function AuthLayoutError({ message }: { message: string }) { return ( {message} ); } /* ── Server edit dialog ── */ function ServerEditDialog({ currentServer, onConfirm, onCancel, }: { currentServer: string; onConfirm: (server: string) => void; onCancel: () => void; }) { const { t } = useTranslation(); const [value, setValue] = useState(currentServer); const inputRef = useRef(null); useEffect(() => { inputRef.current?.focus(); inputRef.current?.select(); }, []); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const trimmed = value.trim(); if (trimmed) onConfirm(trimmed); }; return (
{ if (e.key === 'Escape') onCancel(); }} > ); } /* ── Layout helpers (from element-web) ── */ function readPx(s: CSSStyleDeclaration, name: string, fallback = 0): number { const v = Number.parseFloat(s.getPropertyValue(name)); return Number.isFinite(v) ? v : fallback; } function readNum(s: CSSStyleDeclaration, name: string, fallback: number): number { const v = Number.parseFloat(s.getPropertyValue(name)); return Number.isFinite(v) ? v : fallback; } function calculateModalLayout(input: { pageHeight: number; mascotTopOffset: number; mascotHeight: number; modalHeight: number; footerHeight: number; anchorRatio: number; minTop: number; bottomGap: number; }) { const desiredTop = input.mascotTopOffset + input.mascotHeight * input.anchorRatio; const maxTop = input.pageHeight - input.footerHeight - input.bottomGap - input.modalHeight; const top = Math.max(input.minTop, Math.min(desiredTop, maxTop)); const maxHeight = Math.max(0, input.pageHeight - input.footerHeight - input.bottomGap - top); return { top: Math.round(top), maxHeight: Math.floor(maxHeight), constrained: input.modalHeight > maxHeight, }; } const rootVars: React.CSSProperties = { '--vojo-mascot-size': 'clamp(28rem, 57dvh, 48rem)', '--vojo-mascot-top': 'clamp(1.5rem, 4dvh, 3rem)', '--vojo-stack-pad': '1.5rem', '--vojo-anchor-ratio': '0.71', '--vojo-modal-min-top': 'clamp(12rem, 26dvh, 18rem)', '--vojo-modal-gap': 'clamp(0.75rem, 2dvh, 1.5rem)', '--vojo-footer-space': 'clamp(4.5rem, 9dvh, 6.5rem)', } as React.CSSProperties; export function AuthLayout() { const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); const { server: urlEncodedServer } = useParams(); const clientConfig = useClientConfig(); const defaultServer = clientDefaultServer(clientConfig); let server: string = urlEncodedServer ? tryDecodeURIComponent(urlEncodedServer) : defaultServer; if (!clientAllowedServer(clientConfig, server)) { server = defaultServer; } const [showServerDialog, setShowServerDialog] = useState(false); const [discoveryState, discoverServer] = useAsyncCallback( useCallback(async (serverName: string) => { const response = await autoDiscovery(fetch, serverName); return { serverName, response }; }, []) ); useEffect(() => { if (server) discoverServer(server); }, [discoverServer, server]); useEffect(() => { if (!urlEncodedServer || tryDecodeURIComponent(urlEncodedServer) !== server) { navigate( generatePath(currentAuthPath(location.pathname), { server: encodeURIComponent(server), }), { replace: true } ); } }, [urlEncodedServer, navigate, location, server]); const selectServer = useCallback( (newServer: string) => { if (newServer === server) { if (discoveryState.status === AsyncStatus.Loading) return; discoverServer(server); return; } navigate( generatePath(currentAuthPath(location.pathname), { server: encodeURIComponent(newServer), }) ); }, [navigate, location, discoveryState, server, discoverServer] ); const [autoDiscoveryError, autoDiscoveryInfo] = discoveryState.status === AsyncStatus.Success ? discoveryState.data.response : []; /* ── Page title based on route ── */ const getPageTitle = () => { if (matchPath(LOGIN_PATH, location.pathname)) return t('Auth.title_login'); if (matchPath(REGISTER_PATH, location.pathname)) return t('Auth.title_register'); return t('Auth.title_reset_password'); }; const pageTitle = getPageTitle(); /* ── Refs for JS-driven layout ── */ const pageRef = useRef(null); const mascotRef = useRef(null); const modalRef = useRef(null); const footerRef = useRef(null); const cardContentRef = useRef(null); useLayoutEffect(() => { const page = pageRef.current; const mascot = mascotRef.current; const modal = modalRef.current; const footer = footerRef.current; if (!page || !mascot || !modal || !footer) return undefined; let frameId = 0; const update = (): void => { cancelAnimationFrame(frameId); frameId = requestAnimationFrame(() => { const s = getComputedStyle(page); const layout = calculateModalLayout({ pageHeight: page.clientHeight, mascotTopOffset: readPx(s, '--vojo-mascot-top'), mascotHeight: mascot.offsetHeight, modalHeight: modal.offsetHeight, footerHeight: footer.offsetHeight || readPx(s, '--vojo-footer-space', footer.offsetHeight), anchorRatio: readNum(s, '--vojo-anchor-ratio', 0.58), minTop: readPx(s, '--vojo-modal-min-top'), bottomGap: readPx(s, '--vojo-modal-gap'), }); page.style.setProperty('--vojo-modal-top', `${layout.top}px`); page.style.setProperty('--vojo-modal-max-h', `${layout.maxHeight}px`); if (cardContentRef.current) { cardContentRef.current.classList.toggle(css.AuthCardContentConstrained, layout.constrained); } }); }; update(); const ro = new ResizeObserver(update); ro.observe(page); ro.observe(mascot); ro.observe(modal); ro.observe(footer); window.addEventListener('resize', update); return () => { cancelAnimationFrame(frameId); ro.disconnect(); window.removeEventListener('resize', update); }; }, []); return (
{/* Title */} {pageTitle} {/* Server info row with separator */}
{t('Auth.homeserver')} {server} {clientConfig.allowCustomHomeservers && ( )}
{/* Loading / error / form */} {discoveryState.status === AsyncStatus.Loading && ( )} {discoveryState.status === AsyncStatus.Error && ( )} {autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && ( )} {autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && ( )} {discoveryState.status === AsyncStatus.Success && autoDiscoveryInfo && ( ( )} error={() => ( )} > {(specVersions) => ( ( )} error={() => ( )} > {(authFlows) => ( )} )} )}
{/* Server edit dialog */} {showServerDialog && ( { setShowServerDialog(false); selectServer(newServer); }} onCancel={() => setShowServerDialog(false)} /> )}
); }