Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 13 additions & 225 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,20 @@
import { useEffect, useLayoutEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
import { diagnosticsEnabled } from "./live/diagnostics";
import { Suspense, useEffect, useLayoutEffect, useState, type ReactNode } from "react";
import { Link, Redirect, Route, Switch, useLocation, useSearch } from "wouter";
import { LoginPage } from "./auth/Login";
import { SignupPage, VerifyEmailPage } from "./auth/Signup";
import { ForgotPasswordPage, ResetPasswordPage } from "./auth/ForgotPassword";
import { InvitePage, OnboardPage } from "./auth/InviteOnboard";
import { PrivacyPage, TermsPage } from "./legal/LegalPages";
import { useApp } from "./state";
import { Header, MoreSheet, PhoneTabs, Sidebar } from "./shell/Shell";
import { HomeEmptyScreen, HomeScreen, PlacesScreen } from "./screens/Home";
import { HubEmptyScreen, HubScreen } from "./screens/Hub";
import {
ExpensesEmptyScreen,
ExpensesScreen,
FundsScreen,
RecurringScreen,
SettleEvenScreen,
SettleScreen,
} from "./screens/Money";
import {
ActivityScreen,
MembersScreen,
NotesScreen,
TemplatesScreen,
} from "./screens/House";
import { OperationsScreen } from "./screens/Operations";
import { CommuneSettingsScreen, SettingsScreen } from "./screens/Settings";
import { Windows } from "./windows/Windows";
import { cx } from "./ui/cx";
import { PAGE_TITLES, type ScreenId } from "./data/household";
import { COMMUNE_SCREENS, forgetReturn, keepSearch, readAddress, readReturn, rememberReturn } from "./nav";
import { forgetReturn, keepSearch, readAddress, readReturn, rememberReturn } from "./nav";
import { AuthLayout } from "./auth/AuthLayout";
import { PendingShell } from "./shell/PendingShell";
import { AllActivityScreen, SearchScreen } from "./screens/Search";
import { ConnectionRecovery } from "./shell/ConnectionRecovery";
import { RecoveryBoundary, ScreenRecovery } from "./shell/Recovery";
import { isConnectionFailure } from "./live/connection";
import { lazyComponent } from "./shell/lazyComponent";
import { usePageTitle } from "./shell/pageTitle";

// The signed-in app, so that the sign-in pages do not download every screen.
const ProductShell = lazyComponent(() => import("./shell/ProductShell").then((m) => m.ProductShell));

export function App() {
return (
Expand Down Expand Up @@ -98,12 +77,6 @@ export function App() {
);
}

// "Expenses · Flat 4 · Commune": short, most specific first.
function usePageTitle(...parts: Array<string | undefined>) {
const title = [...parts.filter(Boolean), "Commune"].join(" · ");
useEffect(() => { document.title = title; }, [title]);
}

function PageTitle({ title }: { title: string }) {
usePageTitle(title);
return null;
Expand All @@ -127,9 +100,13 @@ function SignedIn({ hasHubs }: { hasHubs: boolean }) {

function ProtectedApp() {
const [path] = useLocation();
const known = Boolean(readAddress(path).screen);
// Fetched while the session is checked; until it arrives, the same
// loading page stays up.
useEffect(() => { if (known) ProductShell.preload().catch(() => {}); }, [known]);
// An address Commune does not have needs no sign-in to say so.
if (!readAddress(path).screen) return <><PageTitle title="Page not found" /><NotFoundPage /></>;
return <AuthenticatedOnly><ProductShell /></AuthenticatedOnly>;
if (!known) return <><PageTitle title="Page not found" /><NotFoundPage /></>;
return <AuthenticatedOnly><Suspense fallback={<AppLoading />}><ProductShell /></Suspense></AuthenticatedOnly>;
}

function AuthenticatedOnly({ children }: { children: ReactNode }) {
Expand Down Expand Up @@ -169,192 +146,3 @@ function AppUnavailable() {
const { refresh, signOut } = useApp();
return <ConnectionRecovery retry={refresh} signOut={signOut} />;
}

function ProductShell() {
const { screen, navMode, moreOpen, native, dark, window: win, notes, noteKey, apiError, clearApiError, contentError, hub, refresh, searchTarget, unknownCommune } = useApp();
const noteReader = screen === "notes" && win === "note" && Boolean(notes[noteKey]);
const modalOpen = Boolean(win) && !noteReader;
usePageFocus(screen, hub.id, Boolean(win) || Boolean(searchTarget));
usePageTitle(unknownCommune ? "Commune not found" : (win && PAGE_TITLES[win]) || PAGE_TITLES[screen],
COMMUNE_SCREENS.has(screen) && !unknownCommune ? hub.name : undefined);
return (
<div
className={cx(
"a-body",
(screen === "hub" || screen === "hub-empty") && "hub-editorial",
navMode === "advanced" ? "nav-advanced" : "nav-basic",
moreOpen && "more-open",
native && "is-native",
dark && "is-dark",
modalOpen && "has-window",
noteReader && "has-note-reader",
)}
>
<div className="a-app" inert={modalOpen}>
<a className="a-skip-link" href="#main-content" onClick={skipToContent}>Skip to content</a>
<Sidebar screen={screen} />
<div className="a-main">
<Header screen={screen} />
{diagnosticsEnabled() ? <ConnectionCheck /> : null}
{apiError ? <div className="a-api-error" role="alert">{isConnectionFailure(apiError) ? <>Connection interrupted. Your last loaded view is still available.<button type="button" onClick={() => void refresh()}>Try again</button></> : apiError}<button type="button" onClick={clearApiError} aria-label="Dismiss error">×</button></div> : null}
{contentError && hub.activityState === "ready" ? <div className="a-api-error" role="alert">Could not refresh this commune. Showing its last loaded activity.<button type="button" onClick={() => void refresh()}>Try again</button></div> : null}
<main className="a-content" id="main-content">
<RecoveryBoundary resetKey={`${screen} ${hub.id}`} fallback={(retry) => <ScreenRecovery retry={retry} />}>
<Screen screen={screen} />
</RecoveryBoundary>
</main>
</div>
</div>
<div inert={modalOpen} style={{ display: "contents" }}>
<PhoneTabs screen={screen} />
<MoreSheet />
</div>
<Windows key={hub.id} />
</div>
);
}

// Focus moves to an element that only holds it until the next step away.
function focusOnce(element: HTMLElement) {
if (!element.hasAttribute("tabindex")) {
element.tabIndex = -1;
element.addEventListener("blur", () => element.removeAttribute("tabindex"), { once: true });
}
element.focus({ preventScroll: true });
}

function skipToContent(event: MouseEvent<HTMLAnchorElement>) {
const main = document.getElementById("main-content");
if (!main) return;
event.preventDefault();
focusOnce(main);
}

// After moving to another page or commune, start at the new page's heading
// rather than on a control that has gone. A window, the note reader or a
// search result places focus itself; if one was opening, wait until it closes
// and only step in when focus would otherwise be lost.
function usePageFocus(screen: ScreenId, hubId: string, held: boolean) {
const shown = useRef({ screen, hubId });
const waiting = useRef(false);
useEffect(() => {
const previous = shown.current;
shown.current = { screen, hubId };
const moved = previous.screen !== screen || (Boolean(previous.hubId) && previous.hubId !== hubId);
if (moved) waiting.current = true;
if (!waiting.current || held) return;
waiting.current = false;
const active = document.activeElement;
if (!moved && active && active !== document.body) return;
const heading = document.querySelector<HTMLElement>(".a-header-title h1");
if (heading) focusOnce(heading);
}, [screen, hubId, held]);
}

function ConnectionCheck() {
const { hubId } = useApp();
const [running, setRunning] = useState(false);
const [result, setResult] = useState("");
async function check() {
setRunning(true);
setResult("");
try {
const { inspectMembershipRead } = await import("./live/neon");
const rows = [];
for (let round = 1; round <= 5; round++) {
rows.push({ round, reads: await inspectMembershipRead(hubId) });
}
setResult(JSON.stringify(rows, null, 2));
} catch {
setResult("Connection test could not complete.");
} finally { setRunning(false); }
}
return <details style={{ margin: "0 28px", fontSize: 12 }}>
<summary>Read-only connection diagnostics</summary>
<button type="button" disabled={!hubId || running} onClick={() => void check()}>{running ? "Checking…" : "Compare membership reads"}</button>
<pre style={{ maxHeight: 240, overflow: "auto" }}>{result}</pre>
</details>;
}

function Screen({ screen }: { screen: ScreenId }) {
return (
<section className="screen" id={screen}>
<ScreenBody screen={screen} />
</section>
);
}

function ScreenBody({ screen }: { screen: ScreenId }) {
const { session, emptyHub, hub, contentLoading, contentError, refresh, unknownCommune, go } = useApp();
const hasHubs = Boolean(session?.hasHubs);
const hasSettlement = hub.moves.some((move) => !move.even);

// Never another commune's records in its place.
if (unknownCommune) {
return <div className="a-empty"><h2>This commune isn’t available to you.</h2><p>You may have left it, or the link is for a different account.</p><button className="a-btn" type="button" onClick={() => go("home")}>Go to Home</button></div>;
}

if (contentLoading && screen !== "settings" && screen !== "search") {
if (contentError) return <div className="a-content-loading" role="alert"><h2>Could not load {hub.name}</h2><p>{contentError}</p><button className="a-btn" type="button" onClick={() => void refresh()}>Try again</button></div>;
return <div className="c-content-skeleton" role="status" aria-label="Loading shared records" aria-busy="true"><span className="c-visually-hidden">Loading shared records</span>{screen === "hub" && <div className="c-skeleton-identity" style={hub.photo ? { backgroundImage: `linear-gradient(0deg, rgba(26,30,43,.8), rgba(26,30,43,.1)), url("${hub.photo}")` } : undefined}><h2>{hub.name}</h2><p>{hub.switchMeta}</p></div>}<div className="c-skeleton-grid" aria-hidden="true"><div /><div /><div /><div /></div></div>;
}

switch (screen) {
case "home":
return hasHubs ? <HomeScreen /> : <HomeEmptyScreen />;
case "home-empty":
return hasHubs ? <HomeScreen /> : <HomeEmptyScreen />;
case "home-inbox":
return hasHubs ? <HomeScreen /> : <HomeEmptyScreen />;
case "communes":
return <PlacesScreen />;
case "hub":
return !hasHubs ? <HomeEmptyScreen /> : emptyHub ? <HubEmptyScreen /> : <HubScreen />;
case "hub-empty":
return emptyHub || !hasHubs ? <HubEmptyScreen /> : <HubScreen />;
case "commune-settings":
return <CommuneSettingsScreen key={hub.id} />;
case "expenses":
return emptyHub ? <ExpensesEmptyScreen /> : <ExpensesScreen key={hub.id} />;
case "expenses-empty":
return emptyHub || !hasHubs ? (
<ExpensesEmptyScreen />
) : (
<ExpensesScreen key={hub.id} />
);
case "settle":
return !hasSettlement || !hasHubs ? (
<SettleEvenScreen key={hub.id} />
) : (
<SettleScreen key={hub.id} />
);
case "settle-even":
return !hasSettlement || !hasHubs ? (
<SettleEvenScreen key={hub.id} />
) : (
<SettleScreen key={hub.id} />
);
case "recurring":
return <RecurringScreen key={hub.id} />;
case "funds":
return <FundsScreen key={hub.id} />;
case "templates":
return <TemplatesScreen key={hub.id} />;
case "members":
return <MembersScreen key={hub.id} />;
case "operations":
return <OperationsScreen key={hub.id} />;
case "notes":
return <NotesScreen key={hub.id} />;
case "activity":
return <ActivityScreen />;
case "home-activity":
return <AllActivityScreen />;
case "search":
return <SearchScreen />;
case "settings":
return <SettingsScreen />;
default:
return hasHubs ? <HomeScreen /> : <HomeEmptyScreen />;
}
}
12 changes: 3 additions & 9 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
// First, so the cascade layers' order is declared before any stylesheet that
// a component imports uses one (see styles/index.css).
import "./styles/index.css";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { AppProvider } from "./state";
import { AppRecovery, RecoveryBoundary } from "./shell/Recovery";
import { watchErrors, watchSpeed } from "./live/reports";
// Before the app's own stylesheets, as its separate link was, so their rules still come after it.
import "./styles/calendar-date.css";
import "./styles/shared.css";
import "./styles/app.css";
import "./styles/auth.css";
import "./styles/legal.css";
import "./styles/overrides.css";
import "./styles/design-system.css";
import "./styles/fonts.css";

const root = document.getElementById("root");
if (!root) throw new Error("Missing #root");
Expand Down
5 changes: 0 additions & 5 deletions apps/web/src/screens/House.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import { useApp } from "../state";
import { ActivityLog } from "../ui/ActivityLog";
export { NotesScreen } from "./Notes";

export { MembersScreen } from "./Members";

export function ActivityScreen() {
const { hub, go } = useApp();
Expand All @@ -15,5 +12,3 @@ export function ActivityScreen() {
}))} />
);
}

export { TemplatesScreen } from "./Templates";
Loading
Loading