Files
eranos/src/AppRouter.tsx
T
Alex Gleason 39f288d819 wallet: turn 'Back up wallet' into an in-page dialog
The Back up wallet menu item on /wallet now opens the existing
WalletBackupMnemonicDialog inline instead of navigating to a dedicated
page. The dialog already existed in src/components/WalletBackupMnemonic.tsx
but wasn't wired up anywhere — this finally uses it.

- WalletPage: import WalletBackupMnemonicDialog, hold an open/closed state,
  flip the menu item from <Link to="/wallet/backup"> to a regular
  DropdownMenuItem that calls setBackupOpen(true) onSelect. Render the
  dialog alongside the existing Send / Receive / SP-scan dialogs. The
  dialog's internal gating still hides for extension/bunker signers, but
  the menu only renders in the 'available' branch of WalletPage so that
  fallback isn't user-visible in practice.
- AppRouter: drop the lazy import and route for WalletBackupPage, replace
  /wallet/backup with a redirect to /wallet so old bookmarks land somewhere
  sensible. Point /wallet/settings/backup at /wallet for the same reason.
- Delete WalletBackupPage.tsx \u2014 no longer reachable.
- Locales: remove the now-unused walletBackupPage block (six keys:
  seoTitle, seoDescription, title, subtitle, loggedOut, unsupported) from
  en + 14 other locales. The dialog reuses walletBackup.* and
  walletSettings.backup.label, which are unchanged.
2026-05-24 17:47:39 -05:00

215 lines
13 KiB
TypeScript

import { lazy, Suspense } from "react";
import { BrowserRouter, Link, Navigate, Outlet, Route, Routes } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { Toaster } from "./components/ui/toaster";
import { TopNav } from "./components/TopNav";
import { ScrollToTop } from "./components/ScrollToTop";
import { VersionCheck } from "./components/VersionCheck";
import { useCurrentUser } from "./hooks/useCurrentUser";
import { useProfileUrl } from "./hooks/useProfileUrl";
import { cn } from "@/lib/utils";
import { openUrl } from "@/lib/downloadFile";
// Critical-path pages: eagerly loaded (landing + fallback)
import Index from "./pages/Index";
import NotFound from "./pages/NotFound";
// Campaigns: home + create. (Campaign detail is dispatched from NIP19Page
// when an naddr resolves to kind 33863.)
const CampaignsPage = lazy(() => import("./pages/CampaignsPage").then(m => ({ default: m.CampaignsPage })));
const CreateCampaignPage = lazy(() => import("./pages/CreateCampaignPage").then(m => ({ default: m.CreateCampaignPage })));
const AllCampaignsPage = lazy(() => import("./pages/AllCampaignsPage").then(m => ({ default: m.AllCampaignsPage })));
// All other pages: code-split via React.lazy
const ActionsPage = lazy(() => import("./pages/ActionsPage"));
const CreateActionPage = lazy(() => import("./pages/CreateActionPage").then(m => ({ default: m.CreateActionPage })));
const AdvancedSettingsPage = lazy(() => import("./pages/AdvancedSettingsPage").then(m => ({ default: m.AdvancedSettingsPage })));
const AppearanceSettingsPage = lazy(() => import("./pages/AppearanceSettingsPage").then(m => ({ default: m.AppearanceSettingsPage })));
const ChangelogPage = lazy(() => import("./pages/ChangelogPage").then(m => ({ default: m.ChangelogPage })));
const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage").then(m => ({ default: m.CommunitiesPage })));
const CreateCommunityPage = lazy(() => import("./pages/CreateCommunityPage").then(m => ({ default: m.CreateCommunityPage })));
const CreateEventPage = lazy(() => import("./pages/CreateEventPage").then(m => ({ default: m.CreateEventPage })));
const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ default: m.CSAEPolicyPage })));
const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").then(m => ({ default: m.ExternalContentPage })));
const GeotagPage = lazy(() => import("./pages/GeotagPage").then(m => ({ default: m.GeotagPage })));
const HashtagPage = lazy(() => import("./pages/HashtagPage").then(m => ({ default: m.HashtagPage })));
const MyDashboardPage = lazy(() => import("./pages/MyDashboardPage").then(m => ({ default: m.MyDashboardPage })));
const AboutPage = lazy(() => import("./pages/AboutPage").then(m => ({ default: m.AboutPage })));
const DonorGuidePage = lazy(() => import("./pages/DonorGuidePage").then(m => ({ default: m.DonorGuidePage })));
const ActivistGuidePage = lazy(() => import("./pages/ActivistGuidePage").then(m => ({ default: m.ActivistGuidePage })));
const LanguageSettingsPage = lazy(() => import("./pages/LanguageSettingsPage").then(m => ({ default: m.LanguageSettingsPage })));
const NetworkSettingsPage = lazy(() => import("./pages/NetworkSettingsPage").then(m => ({ default: m.NetworkSettingsPage })));
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
const NotificationSettings = lazy(() => import("./pages/NotificationSettings").then(m => ({ default: m.NotificationSettings })));
const NotificationsPage = lazy(() => import("./pages/NotificationsPage").then(m => ({ default: m.NotificationsPage })));
const OrganizersPage = lazy(() => import("./pages/OrganizersPage").then(m => ({ default: m.OrganizersPage })));
const EventDashboardPage = lazy(() => import("./pages/EventDashboardPage").then(m => ({ default: m.EventDashboardPage })));
const PrivacyPolicyPage = lazy(() => import("./pages/PrivacyPolicyPage").then(m => ({ default: m.PrivacyPolicyPage })));
const ProfileSettings = lazy(() => import("./pages/ProfileSettings").then(m => ({ default: m.ProfileSettings })));
const SearchPage = lazy(() => import("./pages/SearchPage").then(m => ({ default: m.SearchPage })));
const SettingsPage = lazy(() => import("./pages/SettingsPage").then(m => ({ default: m.SettingsPage })));
const WalletPage = lazy(() => import("./pages/WalletPage").then(m => ({ default: m.WalletPage })));
const WalletMigrateV1Page = lazy(() => import("./pages/WalletMigrateV1Page").then(m => ({ default: m.WalletMigrateV1Page })));
const WalletRecoveryPage = lazy(() => import("./pages/WalletRecoveryPage").then(m => ({ default: m.WalletRecoveryPage })));
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
const LegacyWalletRecoveryPage = lazy(() => import("./pages/LegacyWalletRecoveryPage").then(m => ({ default: m.LegacyWalletRecoveryPage })));
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
/** Redirects /profile to the user's canonical profile URL (nip05 or npub). */
function ProfileRedirect() {
const { user, metadata } = useCurrentUser();
const profileUrl = useProfileUrl(user?.pubkey ?? "", metadata);
if (!user) return <Navigate to="/" replace />;
return <Navigate to={profileUrl} replace />;
}
function PageSkeleton() {
// Shown briefly while a route's lazy chunk is being fetched. A skeleton
// tuned to one page's shape (`max-w-6xl` with hero + paragraph blocks)
// ends up wrong-shaped on every other page — narrow settings pages,
// small wallet screens, etc. A neutral centered spinner is honest about
// "loading" without misleading the eye with content-shaped boxes that
// never appear.
return (
<div className="flex items-center justify-center py-24">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
function SiteFooter() {
const { t } = useTranslation();
return (
<footer className="bg-background mt-auto pt-12">
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-muted-foreground">
<button
type="button"
onClick={() => void openUrl("https://gitlab.com/soapbox-pub/agora")}
className="hover:text-foreground motion-safe:transition-colors"
>
{t('nav.sourceCode')}
</button>
<nav className="flex items-center gap-5">
<Link to="/about" className="hover:text-foreground motion-safe:transition-colors">{t('nav.about')}</Link>
<Link to="/privacy" className="hover:text-foreground motion-safe:transition-colors">{t('nav.privacy')}</Link>
<Link to="/safety" className="hover:text-foreground motion-safe:transition-colors">{t('nav.safety')}</Link>
<Link to="/changelog" className="hover:text-foreground motion-safe:transition-colors">{t('nav.changelog')}</Link>
</nav>
</div>
</footer>
);
}
/**
* Persistent app shell. GoFundMe-style top-nav-only chrome wrapping the route
* outlet. The width of the center column is decided by the layout variant
* picked in the route tree below — narrow (default, `max-w-3xl`) for
* form/prose-style pages, wide (full width) for landing / dashboard / detail
* pages that render their own internal layout.
*/
function FundraiserLayout({ narrow }: { narrow: boolean }) {
return (
<div className="min-h-dvh flex flex-col bg-background">
<TopNav />
<Suspense fallback={<PageSkeleton />}>
<div
className={cn("flex-1 min-w-0 w-full mx-auto", narrow && "max-w-3xl")}
>
<Outlet />
</div>
</Suspense>
<SiteFooter />
</div>
);
}
export function AppRouter() {
return (
<BrowserRouter>
<Toaster />
<VersionCheck />
<ScrollToTop />
<Routes>
{/* Narrow layout — `max-w-3xl` center column. The default for
form/prose-style pages. */}
<Route element={<FundraiserLayout narrow />}>
<Route path="/feed" element={<Index />} />
<Route path="/my-dashboard" element={<MyDashboardPage />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/profile" element={<ProfileRedirect />} />
<Route path="/t/:tag" element={<HashtagPage />} />
<Route path="/g/:geohash" element={<GeotagPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/appearance" element={<AppearanceSettingsPage />} />
<Route path="/settings/language" element={<LanguageSettingsPage />} />
<Route path="/settings/profile" element={<ProfileSettings />} />
<Route path="/settings/wallet" element={<WalletSettingsPage />} />
<Route path="/settings/notifications" element={<NotificationSettings />} />
<Route path="/settings/advanced" element={<AdvancedSettingsPage />} />
<Route path="/settings/network" element={<NetworkSettingsPage />} />
<Route path="/wallet" element={<WalletPage />} />
<Route path="/wallet/legacy" element={<LegacyWalletRecoveryPage />} />
{/* Old nested paths kept as redirects so any existing links / muscle
memory still land on the right page. `/wallet/settings` was an
intermediate hub that has been replaced by an overflow menu on
`/wallet`, so it redirects to the wallet home. `/wallet/backup`
is now an in-page dialog opened from that menu, so it also
redirects home. */}
<Route path="/wallet/settings" element={<Navigate to="/wallet" replace />} />
<Route path="/wallet/backup" element={<Navigate to="/wallet" replace />} />
<Route path="/wallet/settings/backup" element={<Navigate to="/wallet" replace />} />
<Route path="/wallet/settings/legacy" element={<Navigate to="/wallet/legacy" replace />} />
<Route path="/wallet/recovery" element={<WalletRecoveryPage />} />
<Route path="/wallet/migrate-v1" element={<WalletMigrateV1Page />} />
<Route path="/bitcoin" element={<Navigate to="/wallet" replace />} />
{/* Legacy /help routes redirect to /about so existing links keep
working. The About page and the two guides themselves live
under the wide layout below. */}
<Route path="/help" element={<Navigate to="/about" replace />} />
<Route path="/help/donors" element={<Navigate to="/about/donors" replace />} />
<Route path="/help/activists" element={<Navigate to="/about/activists" replace />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
<Route path="/safety" element={<CSAEPolicyPage />} />
<Route path="/changelog" element={<ChangelogPage />} />
<Route path="/organizers" element={<OrganizersPage />} />
{/* Callback target for remote signers (e.g. Amber, Primal) after NIP-46 approval */}
<Route path="/remoteloginsuccess" element={<RemoteLoginSuccessPage />} />
</Route>
{/* Wide layout — no max-width on the center column. Used by landing /
list / detail pages that render their own internal width
constraints. */}
<Route element={<FundraiserLayout narrow={false} />}>
<Route path="/" element={<CampaignsPage />} />
<Route path="/campaigns" element={<Navigate to="/" replace />} />
<Route path="/campaigns/new" element={<CreateCampaignPage />} />
<Route path="/campaigns/all" element={<AllCampaignsPage />} />
<Route path="/groups" element={<CommunitiesPage />} />
<Route path="/groups/new" element={<CreateCommunityPage />} />
<Route path="/events/new" element={<CreateEventPage />} />
<Route path="/pledges" element={<ActionsPage />} />
<Route path="/pledges/new" element={<CreateActionPage />} />
<Route path="/dashboard" element={<EventDashboardPage />} />
<Route path="/i/*" element={<ExternalContentPage />} />
{/* About page + Donor / Activist guides. Full-bleed landing-style
layouts that render their own internal max-widths. */}
<Route path="/about" element={<AboutPage />} />
<Route path="/about/donors" element={<DonorGuidePage />} />
<Route path="/about/activists" element={<ActivistGuidePage />} />
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1.
Goes through the wide layout because the dispatch may resolve to
a profile, campaign, action, or community page — all of which
render their own internal layout. PostDetailPage / ListDetailPage
also work edge-to-edge. */}
<Route path="/:nip19" element={<NIP19Page />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</BrowserRouter>
);
}
export default AppRouter;