315 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|