Files
gerbeur/src/App.tsx

315 lines
11 KiB
TypeScript

/**
* Renders the whole app, its providers around its routes. `main.tsx` mounts
* it. Its paths follow one rule (`shared/paths.ts`): `@name` is a user, any
* other word under the root a category or an app page, a view of a page one
* more segment, and a default view the bare path, with no redirect.
*
* - `/` is the feed of every dump on the viewer's default tab, and `/hot`,
* `/new`, `/journal` and `/follow` its tabs (`Index`), `/follow/playlists`
* the dumps of the playlists followed. `/?share_url=` opens the create form
* on a shared link.
* - `/search` and `/search/:searchTab` are the search results (`Search`).
* - `/register` and `/login` sign up and sign in (`UserRegister`,
* `UserLogin`), signed out only, `/login?next=` coming back to a page.
* - `/authorize` lets another gerbeur instance sign the user in with their
* account here (`Authorize`), and `/federation/callback` finishes a sign-in
* here with an account on another (`FederationCallback`).
* - `/reset-password` asks for a reset link by email, and sets a new password
* from it, `?token=` (`ResetPassword`), and `/unsubscribe` turns off a digest
* from its emailed link (`Unsubscribe`).
* - `/notifications` (`Notifications`) and `/settings` (`Settings`), signed in
* only, and `/manage`, the site's tools, for `category:manage` or
* `federation:manage` (`Manage`).
* - `/community` and `/community/monthly`, the site's week and month
* (`Community`), signed in only.
* - `/playlists` is every public playlist, and `/playlists/:categorySlug` a
* playlist category's (`Playlists`).
* - `/@:username/*` is a user's (`UserRoutes`): their profile and its tabs,
* their dumps at `dumps/:slug` (`Dump`) and `dumps/:slug/edit` (`DumpEdit`,
* signed in only), and their playlists at `playlists/:slug`
* (`PlaylistDetail`), with its editor open at `playlists/:slug/edit`.
* - `/:categorySlug` and `/:categorySlug/:feedTab` are a dump category's feed
* (`Index`), with its `/follow/playlists` too.
* - Any other path is `NotFound`.
*
* The pages not marked are open to anyone. Every page is loaded with its
* route. Since category slugs share the root with the app's pages and the
* feed tabs, the server reserves those words as slugs; no slug has an `@`.
*
* @module
*/
import { lazy, Suspense } from "react";
import {
BrowserRouter,
Route,
Routes,
useLocation,
useParams,
} from "react-router";
import { RestrictedGuest } from "./pages/RestrictedGuest.tsx";
import { RestrictedLoggedIn } from "./pages/RestrictedLoggedIn.tsx";
import {
FEED_TABS,
type FeedTab,
FOLLOW_VIEWS,
type FollowView,
} from "../shared/paths.ts";
import { AuthProvider } from "./contexts/auth/AuthProvider.tsx";
import { PlayerProvider } from "./contexts/player/PlayerProvider.tsx";
import { WSProvider } from "./contexts/ws/WSProvider.tsx";
import { FollowProvider } from "./contexts/social/FollowProvider.tsx";
import { CategoriesProvider } from "./contexts/category/CategoriesProvider.tsx";
import { ThemeProvider } from "./contexts/theme/ThemeProvider.tsx";
import { ChatProvider } from "./contexts/chat/ChatProvider.tsx";
import { GlobalPlayer } from "./components/player/GlobalPlayer.tsx";
import { DumpFab } from "./components/layout/DumpFab.tsx";
import { ChatFab } from "./components/layout/ChatFab.tsx";
import "./App.css";
const Index = lazy(() =>
import("./pages/Index.tsx").then((m) => ({ default: m.Index }))
);
const Dump = lazy(() =>
import("./pages/Dump.tsx").then((m) => ({ default: m.Dump }))
);
const Playlists = lazy(() =>
import("./pages/Playlists.tsx").then((m) => ({ default: m.Playlists }))
);
const DumpEdit = lazy(() =>
import("./pages/DumpEdit.tsx").then((m) => ({ default: m.DumpEdit }))
);
const UserLogin = lazy(() =>
import("./pages/UserLogin.tsx").then((m) => ({ default: m.UserLogin }))
);
const UserPublicProfile = lazy(() =>
import("./pages/UserPublicProfile.tsx").then((m) => ({
default: m.UserPublicProfile,
}))
);
const UserRegister = lazy(() =>
import("./pages/UserRegister.tsx").then((m) => ({ default: m.UserRegister }))
);
const PlaylistDetail = lazy(() =>
import("./pages/PlaylistDetail.tsx").then((m) => ({
default: m.PlaylistDetail,
}))
);
const Community = lazy(() =>
import("./pages/Community.tsx").then((m) => ({ default: m.Community }))
);
const Notifications = lazy(() =>
import("./pages/Notifications.tsx").then((m) => ({
default: m.Notifications,
}))
);
const Settings = lazy(() =>
import("./pages/Settings.tsx").then((m) => ({ default: m.Settings }))
);
const Manage = lazy(() =>
import("./pages/Manage.tsx").then((m) => ({ default: m.Manage }))
);
const Search = lazy(() =>
import("./pages/Search.tsx").then((m) => ({ default: m.Search }))
);
const ResetPassword = lazy(() =>
import("./pages/ResetPassword.tsx").then((m) => ({
default: m.ResetPassword,
}))
);
const Unsubscribe = lazy(() =>
import("./pages/Unsubscribe.tsx").then((m) => ({ default: m.Unsubscribe }))
);
const Authorize = lazy(() =>
import("./pages/Authorize.tsx").then((m) => ({ default: m.Authorize }))
);
const FederationCallback = lazy(() =>
import("./pages/FederationCallback.tsx").then((m) => ({
default: m.FederationCallback,
}))
);
const NotFound = lazy(() =>
import("./pages/NotFound.tsx").then((m) => ({ default: m.NotFound }))
);
/**
* Renders a feed, of every dump or of `categorySlug`'s, on the tab the path's
* segment after the scope names, or the viewer's default one on the bare
* path, and the `follow` tab on the view the next segment names. Every feed
* path renders it at the same place in the tree, and the key keeps `Index`
* mounted on a change of tab, which sorts on the client, and remounts it on a
* change of category, so that it refetches.
*/
function Feed({ categorySlug }: { categorySlug?: string }) {
const { pathname } = useLocation();
const segments = pathname.split("/").filter(Boolean);
const [feedTab, view, ...more] = segments.slice(categorySlug ? 1 : 0);
if (
more.length > 0 ||
(feedTab && !(FEED_TABS as readonly string[]).includes(feedTab)) ||
(view !== undefined &&
(feedTab !== "follow" || view === FOLLOW_VIEWS[0] ||
!(FOLLOW_VIEWS as readonly string[]).includes(view)))
) {
return <NotFound />;
}
return (
<Index
key={categorySlug ?? ""}
categorySlug={categorySlug}
feedTab={feedTab as FeedTab | undefined}
followView={view as FollowView | undefined}
/>
);
}
/**
* Renders the pages under user `@:handle`, which the page fetches by name: a
* former name or another case reaches them too, and the page then puts the
* current one in its URL. A tab or view the profile doesn't have is
* `NotFound`, which the profile answers itself.
*/
function UserRoutes() {
return (
<Routes>
<Route path="dumps/:slug" element={<Dump />} />
<Route
path="dumps/:slug/edit"
element={
<RestrictedLoggedIn>
<DumpEdit />
</RestrictedLoggedIn>
}
/>
<Route path="playlists/:slug" element={<PlaylistDetail />} />
<Route path="playlists/:slug/edit" element={<PlaylistDetail editing />} />
<Route path="*" element={<UserPublicProfile />} />
</Routes>
);
}
/**
* Dispatches a path's first segment: `@name` is a user's, any other word a
* category's feed, the app's own pages having matched before.
*/
function HandleRoutes() {
const { handle = "" } = useParams();
return handle.startsWith("@")
? <UserRoutes />
: <Feed categorySlug={handle} />;
}
/** Renders the route table, and the floating buttons over every page. */
function AppRoutes() {
return (
<>
<Suspense>
<Routes>
<Route path="/" element={<Feed />} />
{FEED_TABS.map((tab) => (
<Route key={tab} path={`/${tab}`} element={<Feed />} />
))}
<Route path="/search" element={<Search />} />
<Route path="/search/:searchTab" element={<Search />} />
<Route
path="/register"
element={
<RestrictedGuest>
<UserRegister />
</RestrictedGuest>
}
/>
<Route
path="/login"
element={
<RestrictedGuest>
<UserLogin />
</RestrictedGuest>
}
/>
<Route path="/authorize" element={<Authorize />} />
<Route
path="/federation/callback"
element={<FederationCallback />}
/>
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/unsubscribe" element={<Unsubscribe />} />
<Route
path="/notifications"
element={
<RestrictedLoggedIn>
<Notifications />
</RestrictedLoggedIn>
}
/>
{["/community", "/community/:view"].map((path) => (
<Route
key={path}
path={path}
element={
<RestrictedLoggedIn>
<Community />
</RestrictedLoggedIn>
}
/>
))}
{["/playlists", "/playlists/:categorySlug"].map((path) => (
<Route key={path} path={path} element={<Playlists />} />
))}
<Route
path="/settings"
element={
<RestrictedLoggedIn>
<Settings />
</RestrictedLoggedIn>
}
/>
<Route
path="/manage"
element={
<RestrictedLoggedIn>
<Manage />
</RestrictedLoggedIn>
}
/>
<Route path="/:handle/*" element={<HandleRoutes />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
<DumpFab />
<ChatFab />
</>
);
}
/**
* Renders the app. The theme, player, socket, follow and chat providers read
* the session, and `ChatProvider` reads the URL, so it sits inside the router.
* `GlobalPlayer` sits outside the routes, so playback survives navigation.
*/
export function App(): React.JSX.Element {
return (
<AuthProvider>
<ThemeProvider>
<PlayerProvider>
<WSProvider>
<FollowProvider>
<CategoriesProvider>
<BrowserRouter>
<ChatProvider>
<AppRoutes />
<GlobalPlayer />
</ChatProvider>
</BrowserRouter>
</CategoriesProvider>
</FollowProvider>
</WSProvider>
</PlayerProvider>
</ThemeProvider>
</AuthProvider>
);
}