diff --git a/.gitea/workflows/docs.yaml b/.gitea/workflows/docs.yaml index 4c28ea5e..592b93aa 100644 --- a/.gitea/workflows/docs.yaml +++ b/.gitea/workflows/docs.yaml @@ -1,4 +1,4 @@ -name: Build and deploy docs +name: Build and Deploy Static Documentation Site on: push: @@ -6,6 +6,7 @@ on: paths: - "api/**" - "shared/**" + - "src/**" - "mod.ts" - "deno.json" - "deno.lock" diff --git a/README.md b/README.md index b0506095..2fa32093 100644 --- a/README.md +++ b/README.md @@ -158,22 +158,34 @@ place that fetches from other sites. Tests mirror the source one file per module, so the tests of `services/chat-service.ts` are in `test/services/chat-service.test.ts`. -The backend and `shared/` are documented for `deno doc`, the same way in every -file. Each module opens with a `/** … @module */` block saying what it is for. -Every export has a JSDoc comment, and so does every field of an exported -interface; a comment that describes a declaration is always `/** */`, and `//` -is only for notes on the code inside. Functions open with a verb ("Returns…", -"Checks…"), everything else with a noun phrase. Parameters are named in the -prose, in backticks. `{@link}` points at symbols the file already has in scope. -A function that can throw to its caller says so with `@throws`: for an -`APIException`, the statuses and when. A router's JSDoc lists its endpoints, one -line each: method and path, who may call it, what it does. `deno task doc` -builds the HTML reference into `docs/` from every module of `api/` and `shared/` -but the tests. Its index page is the module doc of `mod.ts` at the root, which -introduces the repository's three parts and links every documented module -(`mod.test.ts` checks it), and the `docs` workflow publishes it on every push to -`main` that touches them. `deno task doc:lint` checks the rules the linter can -see. +All three parts — `api/`, `shared/` and `src/` — are documented for `deno doc`, +the same way in every file. Each module opens with a `/** … @module */` block +saying what it is for. Every export has a JSDoc comment, and so does every field +of an exported interface; a comment that describes a declaration is always `/** +*/`, and `//` is only for notes on the code inside. Functions open with a verb +("Returns…", "Checks…", "Renders…" for a component), everything else with a noun +phrase. Parameters are named in the prose, in backticks. `{@link}` points at +symbols the file already has in scope. A function that can throw to its caller +says so with `@throws`: for an `APIException`, the statuses and when. A router's +JSDoc lists its endpoints, one line each: method and path, who may call it, what +it does; a page's names its route and who may see it. + +In the frontend, every exported function states its return type, so the +reference can show it: a component returns `React.JSX.Element` (or `| null`), a +hook returning several things returns a documented `UseResult` interface. +A component that takes props takes an exported `Props` interface, each +prop documented. React's types are used through the global `React` namespace +rather than a default import of `react`, which `deno doc` can't follow. + +`deno task doc` builds the HTML reference into `docs/` from every module of +`api/`, `shared/` and `src/` but the tests (and the generated `src/locales/`). +Its index page is the module doc of `mod.ts` at the root, which introduces the +repository's three parts and links every documented module (`mod.test.ts` checks +it), and the `docs` workflow publishes it on every push to `main` that touches +them. `deno task doc:lint` checks the rules the linter can see, through +`doc-lint.ts`: `deno doc` doesn't document npm packages, so it takes a type +imported from one (react-hook-form's `FieldValues`, say) for a private one, and +those references alone are let through. Each of the frontend's `components/`, `contexts/`, `hooks/` and `utils/` is split into the same domain folders — `auth`, `category`, `chat`, `comment`, diff --git a/deno.json b/deno.json index 263e77b5..9653d986 100644 --- a/deno.json +++ b/deno.json @@ -12,8 +12,8 @@ "test:coverage": "deno test -A --coverage=coverage/api --clean api/ shared/ mod.test.ts", "test:web": "deno run -A npm:vitest run", "test:web:coverage": "deno run -A npm:vitest run --coverage", - "doc": "deno doc --html --name=gerbeur --output=docs mod.ts $(find api shared -name '*.ts' -not -path '*/test/*')", - "doc:lint": "deno doc --lint mod.ts $(find api shared -name '*.ts' -not -path '*/test/*')" + "doc": "deno doc --html --name=gerbeur --output=docs mod.ts $(find api shared src \\( -name '*.ts' -o -name '*.tsx' \\) -not -path '*/test/*' -not -path 'src/locales/*')", + "doc:lint": "deno run --allow-run --allow-read --allow-env doc-lint.ts mod.ts $(find api shared src \\( -name '*.ts' -o -name '*.tsx' \\) -not -path '*/test/*' -not -path 'src/locales/*')" }, "exports": "./mod.ts", "nodeModulesDir": "auto", diff --git a/doc-lint.ts b/doc-lint.ts new file mode 100644 index 00000000..88316d28 --- /dev/null +++ b/doc-lint.ts @@ -0,0 +1,77 @@ +/** + * `deno task doc:lint`: runs `deno doc --lint` over the modules given as + * arguments and fails on what it reports, with one exception. `deno doc` + * doesn't document npm packages, so it calls every type imported from one + * private — react-hook-form's `FieldValues` in a form field's props, say — + * and no change to our own documentation could satisfy it. A `private-type-ref` + * whose type the module imports from a package is let through, and counted; + * every other diagnostic fails the run. + * + * @module + */ + +/** One diagnostic of `deno doc --lint`. */ +interface Diagnostic { + /** The rule, e.g. `missing-jsdoc`. */ + rule: string; + /** The rule's message. */ + message: string; + /** The module it was reported in. */ + file: string; + /** The diagnostic as printed, location and excerpt included. */ + text: string; +} + +/** Returns the diagnostics of `deno doc --lint` over `modules`. */ +async function lint(modules: string[]): Promise { + const { stderr } = await new Deno.Command(Deno.execPath(), { + args: ["doc", "--lint", ...modules], + env: { NO_COLOR: "1" }, + stdout: "null", + stderr: "piped", + }).output(); + const output = new TextDecoder().decode(stderr); + return output.split(/\n(?=error\[)/).flatMap((text) => { + const head = /^error\[([\w-]+)\]: (.*)$/m.exec(text); + const at = /--> (.+?):\d+:\d+/.exec(text); + if (!head || !at) return []; + return [{ rule: head[1], message: head[2], file: at[1], text }]; + }); +} + +/** Checks whether module `file` imports `name` from a package. */ +function importedFromPackage(file: string, name: string): boolean { + const source = Deno.readTextFileSync(file); + const imports = source.matchAll( + /import\s+(?:type\s+)?([^;]*?)\s+from\s+"([^"]+)"/g, + ); + for (const [, clause, specifier] of imports) { + if (specifier.startsWith(".")) continue; + const names = clause.replace(/[{}]/g, " ").split(",").map((part) => + part.trim().replace(/^type\s+/, "").split(/\s+as\s+/).pop()!.trim() + ); + if (names.includes(name)) return true; + } + return false; +} + +/** Checks whether `d` is a private type from a package, which is let through. */ +function isPackageTypeRef(d: Diagnostic): boolean { + const type = /references private type '([\w$]+)/.exec(d.message)?.[1]; + return d.rule === "private-type-ref" && !!type && + importedFromPackage(d.file, type); +} + +if (import.meta.main) { + const diagnostics = await lint(Deno.args); + const failures = diagnostics.filter((d) => !isPackageTypeRef(d)); + for (const d of failures) console.error(d.text.trimEnd() + "\n"); + const allowed = diagnostics.length - failures.length; + console.error( + `${failures.length} documentation lint errors` + + (allowed + ? `, ${allowed} references to npm packages' types let through` + : ""), + ); + if (failures.length) Deno.exit(1); +} diff --git a/mod.test.ts b/mod.test.ts index 790f9b44..e8c238d6 100644 --- a/mod.test.ts +++ b/mod.test.ts @@ -3,18 +3,20 @@ import { assertEquals } from "@std/assert"; Deno.test("mod.ts, the docs' index page, links every documented module", async () => { const doc = await Deno.readTextFile("mod.ts"); const linked = new Set( - [...doc.matchAll(/\]\(\.\/(\S+?\.ts)\/index\.html\)/g)].map((m) => m[1]), + [...doc.matchAll(/\]\(\.\/(\S+?\.tsx?)\/index\.html\)/g)].map((m) => m[1]), ); const modules: string[] = []; async function walk(dir: string) { for await (const entry of Deno.readDir(dir)) { const path = `${dir}/${entry.name}`; - if (entry.isDirectory && entry.name !== "test") await walk(path); - else if (entry.name.endsWith(".ts")) modules.push(path); + if (entry.isDirectory) { + if (entry.name !== "test" && path !== "src/locales") await walk(path); + } else if (/\.tsx?$/.test(entry.name)) modules.push(path); } } await walk("api"); await walk("shared"); + await walk("src"); assertEquals(modules.filter((m) => !linked.has(m)), []); assertEquals([...linked].filter((m) => !modules.includes(m)), []); }); diff --git a/mod.ts b/mod.ts index 8c30d2db..001e4603 100644 --- a/mod.ts +++ b/mod.ts @@ -4,7 +4,7 @@ * A small invite-only social platform for sharing links and files. The * repository holds three parts: the backend in `api/`, the frontend in `src/`, * and in `shared/` whatever crosses the wire between them. This reference - * documents the backend and the shared modules; the frontend isn't in it yet. + * documents all three. * * ## Backend — `api/` * @@ -155,7 +155,202 @@ * * ## Frontend — `src/` * - * The React app, built with Vite. Not in this reference yet. + * The React app, built with Vite and translated with Lingui. Each layer holds + * one kind of module, and every layer but pages/ is split into the same domains + * (auth, category, chat, comment, dump, editor, feed, form, layout, player, + * playlist, profile, social, theme, ws, and shared for what belongs to no one + * domain). A page reads the API and the socket through contexts and hooks, and + * draws with components; utils hold what isn't React at all. + * + * - **Entry point and app** — mounting, the providers and the route table, the + * language: [App](./src/App.tsx/index.html), + * [i18n](./src/i18n.ts/index.html), [main](./src/main.tsx/index.html). + * - **Pages** — one component per route, each naming it, and the two guards + * that wrap the routes for the signed in and the signed out: + * [Dump](./src/pages/Dump.tsx/index.html), + * [DumpEdit](./src/pages/DumpEdit.tsx/index.html), + * [Index](./src/pages/Index.tsx/index.html), + * [NotFound](./src/pages/NotFound.tsx/index.html), + * [Notifications](./src/pages/Notifications.tsx/index.html), + * [PlaylistDetail](./src/pages/PlaylistDetail.tsx/index.html), + * [ResetPassword](./src/pages/ResetPassword.tsx/index.html), + * [RestrictedGuest](./src/pages/RestrictedGuest.tsx/index.html), + * [RestrictedLoggedIn](./src/pages/RestrictedLoggedIn.tsx/index.html), + * [Search](./src/pages/Search.tsx/index.html), + * [UserDumps](./src/pages/UserDumps.tsx/index.html), + * [UserLogin](./src/pages/UserLogin.tsx/index.html), + * [UserPlaylists](./src/pages/UserPlaylists.tsx/index.html), + * [UserPublicProfile](./src/pages/UserPublicProfile.tsx/index.html), + * [UserRegister](./src/pages/UserRegister.tsx/index.html), + * [UserUpvoted](./src/pages/UserUpvoted.tsx/index.html). + * - **Config** — settings read at startup: + * [api](./src/config/api.ts/index.html), + * [feedTabs](./src/config/feedTabs.ts/index.html), + * [playerMode](./src/config/playerMode.ts/index.html). + * - **Contexts** — a FooContext holding the state and its FooProvider, by + * domain: + * - auth: [AuthContext](./src/contexts/auth/AuthContext.ts/index.html), + * [AuthProvider](./src/contexts/auth/AuthProvider.tsx/index.html). + * - category: + * [CategoriesContext](./src/contexts/category/CategoriesContext.ts/index.html), + * [CategoriesProvider](./src/contexts/category/CategoriesProvider.tsx/index.html). + * - chat: [ChatContext](./src/contexts/chat/ChatContext.ts/index.html), + * [ChatProvider](./src/contexts/chat/ChatProvider.tsx/index.html). + * - player: + * [PlayerContext](./src/contexts/player/PlayerContext.ts/index.html), + * [PlayerProvider](./src/contexts/player/PlayerProvider.tsx/index.html). + * - social: + * [FollowContext](./src/contexts/social/FollowContext.ts/index.html), + * [FollowProvider](./src/contexts/social/FollowProvider.tsx/index.html). + * - theme: [ThemeContext](./src/contexts/theme/ThemeContext.ts/index.html), + * [ThemeProvider](./src/contexts/theme/ThemeProvider.tsx/index.html). + * - ws: + * [AvatarOverrideContext](./src/contexts/ws/AvatarOverrideContext.ts/index.html), + * [WSContext](./src/contexts/ws/WSContext.ts/index.html), + * [WSProvider](./src/contexts/ws/WSProvider.tsx/index.html). + * - **Hooks** — every hook, by domain: + * - auth: [useAuth](./src/hooks/auth/useAuth.ts/index.html). + * - category: + * [useCategories](./src/hooks/category/useCategories.ts/index.html). + * - chat: [useChat](./src/hooks/chat/useChat.ts/index.html). + * - dump: [useDumpCardLink](./src/hooks/dump/useDumpCardLink.ts/index.html), + * [useDumpListSync](./src/hooks/dump/useDumpListSync.ts/index.html), + * [useUserDumpFeed](./src/hooks/dump/useUserDumpFeed.ts/index.html). + * - editor: + * [useEmojiTrigger](./src/hooks/editor/useEmojiTrigger.ts/index.html), + * [useMentionAutocomplete](./src/hooks/editor/useMentionAutocomplete.ts/index.html). + * - feed: + * [useDefaultFeedTab](./src/hooks/feed/useDefaultFeedTab.ts/index.html), + * [useFading](./src/hooks/feed/useFading.ts/index.html), + * [useFeedCache](./src/hooks/feed/useFeedCache.ts/index.html), + * [useInfiniteScroll](./src/hooks/feed/useInfiniteScroll.ts/index.html), + * [usePositionAwareSync](./src/hooks/feed/usePositionAwareSync.ts/index.html), + * [useScrollSave](./src/hooks/feed/useScrollSave.ts/index.html). + * - form: [useApiForm](./src/hooks/form/useApiForm.ts/index.html). + * - player: + * [usePlayRichContent](./src/hooks/player/usePlayRichContent.ts/index.html), + * [useYouTubePlaylist](./src/hooks/player/useYouTubePlaylist.ts/index.html). + * - playlist: + * [usePlaylistListSync](./src/hooks/playlist/usePlaylistListSync.ts/index.html). + * - shared: + * [useAnchoredFloat](./src/hooks/shared/useAnchoredFloat.ts/index.html), + * [useDocumentTitle](./src/hooks/shared/useDocumentTitle.ts/index.html), + * [useHeightTransition](./src/hooks/shared/useHeightTransition.ts/index.html), + * [useRoomBeside](./src/hooks/shared/useRoomBeside.ts/index.html), + * [useScrollLock](./src/hooks/shared/useScrollLock.ts/index.html), + * [useSoundsEnabled](./src/hooks/shared/useSoundsEnabled.ts/index.html), + * [useTabParam](./src/hooks/shared/useTabParam.ts/index.html). + * - social: [useFollows](./src/hooks/social/useFollows.ts/index.html). + * - theme: [useTheme](./src/hooks/theme/useTheme.ts/index.html). + * - ws: [useWS](./src/hooks/ws/useWS.ts/index.html). + * - **Components** — only components, by domain: + * - category: + * [CategoryManager](./src/components/category/CategoryManager.tsx/index.html), + * [CategorySelect](./src/components/category/CategorySelect.tsx/index.html), + * [CategorySwitcher](./src/components/category/CategorySwitcher.tsx/index.html). + * - chat: [ChatEditBox](./src/components/chat/ChatEditBox.tsx/index.html), + * [ChatMessageItem](./src/components/chat/ChatMessageItem.tsx/index.html), + * [ChatModal](./src/components/chat/ChatModal.tsx/index.html), + * [PendingItem](./src/components/chat/PendingItem.tsx/index.html), + * [ReplyRef](./src/components/chat/ReplyRef.tsx/index.html). + * - comment: + * [CommentForm](./src/components/comment/CommentForm.tsx/index.html), + * [CommentNode](./src/components/comment/CommentNode.tsx/index.html), + * [CommentThread](./src/components/comment/CommentThread.tsx/index.html). + * - dump: [DumpCard](./src/components/dump/DumpCard.tsx/index.html), + * [FilePreview](./src/components/dump/FilePreview.tsx/index.html), + * [JournalCard](./src/components/dump/JournalCard.tsx/index.html), + * [RichContentCard](./src/components/dump/RichContentCard.tsx/index.html), + * [DumpCreateModal](./src/components/dump/create/DumpCreateModal.tsx/index.html), + * [DuplicateNotice](./src/components/dump/create/DuplicateNotice.tsx/index.html), + * [LocalFilePreview](./src/components/dump/create/LocalFilePreview.tsx/index.html), + * [StepIndicator](./src/components/dump/create/StepIndicator.tsx/index.html), + * [TitleField](./src/components/dump/create/TitleField.tsx/index.html). + * - editor: + * [EmojiPickerPopup](./src/components/editor/EmojiPickerPopup.tsx/index.html), + * [Markdown](./src/components/editor/Markdown.tsx/index.html), + * [MentionDropdown](./src/components/editor/MentionDropdown.tsx/index.html), + * [TextEditor](./src/components/editor/TextEditor.tsx/index.html). + * - feed: [DumpFeed](./src/components/feed/DumpFeed.tsx/index.html), + * [FeedTabBar](./src/components/feed/FeedTabBar.tsx/index.html), + * [FollowedFeed](./src/components/feed/FollowedFeed.tsx/index.html), + * [JournalFeed](./src/components/feed/JournalFeed.tsx/index.html), + * [PresenceRow](./src/components/feed/PresenceRow.tsx/index.html). + * - form: [FileDropZone](./src/components/form/FileDropZone.tsx/index.html), + * [FileField](./src/components/form/FileField.tsx/index.html), + * [FormActions](./src/components/form/FormActions.tsx/index.html), + * [FormError](./src/components/form/FormError.tsx/index.html), + * [RichTextField](./src/components/form/RichTextField.tsx/index.html), + * [SegmentedField](./src/components/form/SegmentedField.tsx/index.html), + * [SubmitButton](./src/components/form/SubmitButton.tsx/index.html), + * [TextField](./src/components/form/TextField.tsx/index.html). + * - layout: [AppHeader](./src/components/layout/AppHeader.tsx/index.html), + * [ChatButton](./src/components/layout/ChatButton.tsx/index.html), + * [ChatFab](./src/components/layout/ChatFab.tsx/index.html), + * [DumpFab](./src/components/layout/DumpFab.tsx/index.html), + * [NotificationBell](./src/components/layout/NotificationBell.tsx/index.html), + * [PageError](./src/components/layout/PageError.tsx/index.html), + * [PageShell](./src/components/layout/PageShell.tsx/index.html), + * [SearchBar](./src/components/layout/SearchBar.tsx/index.html), + * [UserMenu](./src/components/layout/UserMenu.tsx/index.html). + * - player: + * [GlobalPlayer](./src/components/player/GlobalPlayer.tsx/index.html), + * [MediaPlayer](./src/components/player/MediaPlayer.tsx/index.html), + * [PlayerIcons](./src/components/player/PlayerIcons.tsx/index.html), + * [SeekBar](./src/components/player/SeekBar.tsx/index.html), + * [Waveform](./src/components/player/Waveform.tsx/index.html). + * - playlist: + * [AddToPlaylistModal](./src/components/playlist/AddToPlaylistModal.tsx/index.html), + * [NewPlaylistButton](./src/components/playlist/NewPlaylistButton.tsx/index.html), + * [PlaylistCard](./src/components/playlist/PlaylistCard.tsx/index.html), + * [PlaylistCreateForm](./src/components/playlist/PlaylistCreateForm.tsx/index.html), + * [PlaylistMembershipPanel](./src/components/playlist/PlaylistMembershipPanel.tsx/index.html). + * - profile: + * [ChangePasswordModal](./src/components/profile/ChangePasswordModal.tsx/index.html), + * [ProfileSubpageHeader](./src/components/profile/ProfileSubpageHeader.tsx/index.html). + * - shared: [Avatar](./src/components/shared/Avatar.tsx/index.html), + * [ConfirmModal](./src/components/shared/ConfirmModal.tsx/index.html), + * [CountedInput](./src/components/shared/CountedInput.tsx/index.html), + * [ErrorCard](./src/components/shared/ErrorCard.tsx/index.html), + * [ImagePicker](./src/components/shared/ImagePicker.tsx/index.html), + * [Modal](./src/components/shared/Modal.tsx/index.html), + * [TabBar](./src/components/shared/TabBar.tsx/index.html), + * [Thumbnail](./src/components/shared/Thumbnail.tsx/index.html), + * [ThumbnailPlaceholder](./src/components/shared/ThumbnailPlaceholder.tsx/index.html), + * [Tooltip](./src/components/shared/Tooltip.tsx/index.html). + * - social: + * [FollowButton](./src/components/social/FollowButton.tsx/index.html), + * [LikeButton](./src/components/social/LikeButton.tsx/index.html), + * [UserListPopover](./src/components/social/UserListPopover.tsx/index.html), + * [VoteButton](./src/components/social/VoteButton.tsx/index.html). + * - **Utils** — every module that isn't React, by domain: + * - auth: [permissions](./src/utils/auth/permissions.ts/index.html), + * [storage](./src/utils/auth/storage.ts/index.html). + * - chat: [draft](./src/utils/chat/draft.ts/index.html), + * [message](./src/utils/chat/message.ts/index.html), + * [text](./src/utils/chat/text.ts/index.html), + * [time](./src/utils/chat/time.ts/index.html). + * - dump: [draft](./src/utils/dump/draft.ts/index.html). + * - editor: [skinTone](./src/utils/editor/skinTone.ts/index.html), + * [text](./src/utils/editor/text.ts/index.html). + * - feed: [hotScore](./src/utils/feed/hotScore.ts/index.html), + * [journalLayout](./src/utils/feed/journalLayout.ts/index.html). + * - player: [bandcamp](./src/utils/player/bandcamp.ts/index.html), + * [duration](./src/utils/player/duration.ts/index.html), + * [streamSources](./src/utils/player/streamSources.ts/index.html), + * [volume](./src/utils/player/volume.ts/index.html), + * [waveform](./src/utils/player/waveform.ts/index.html), + * [youtube](./src/utils/player/youtube.ts/index.html). + * - shared: [api](./src/utils/shared/api.ts/index.html), + * [charCount](./src/utils/shared/charCount.ts/index.html), + * [deserialize](./src/utils/shared/deserialize.ts/index.html), + * [format](./src/utils/shared/format.ts/index.html), + * [proxyImage](./src/utils/shared/proxyImage.ts/index.html), + * [relativeTime](./src/utils/shared/relativeTime.ts/index.html), + * [sound](./src/utils/shared/sound.ts/index.html), + * [thumbnailTint](./src/utils/shared/thumbnailTint.ts/index.html), + * [urls](./src/utils/shared/urls.ts/index.html), + * [visited](./src/utils/shared/visited.ts/index.html). * * @module */ diff --git a/src/App.tsx b/src/App.tsx index c7df7e08..54bb6448 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,3 +1,29 @@ +/** + * The app: its providers and its routes. + * + * - `/` — redirects to the default feed tab. + * - `/~/:feedTab` — the feed of every dump ({@link Index}). + * - `/:categorySlug/:feedTab` — a category's feed; `/:categorySlug` redirects + * to + * its default tab. + * - `/dumps/:selectedDump` — a dump ({@link Dump}); `/edit` — its editor + * ({@link DumpEdit}), signed in. + * - `/login`, `/register` — sign-in and sign-up ({@link UserLogin}, + * {@link UserRegister}), signed out. + * - `/reset-password` — a new password from a reset link ({@link + * ResetPassword}). + * - `/users/:username` and `/users/:username/~/:profileTab` — a profile + * ({@link UserPublicProfile}); `/dumps`, `/upvoted`, `/playlists` — its full + * lists ({@link UserDumps}, {@link UserUpvoted}, {@link UserPlaylists}). + * - `/playlists/:playlistId` — a playlist ({@link PlaylistDetail}). + * - `/search` and `/search/~/:searchTab` — search results ({@link Search}). + * - `/notifications` — the user's notifications ({@link Notifications}), signed + * in. + * - anything else — {@link NotFound}. + * + * @module + */ + import { lazy, Suspense } from "react"; import { BrowserRouter, @@ -27,6 +53,7 @@ import { ChatFab } from "./components/layout/ChatFab.tsx"; import "./App.css"; +/** The feed page, loaded with its route; so is every page below. */ const Index = lazy(() => import("./pages/Index.tsx").then((m) => ({ default: m.Index })) ); @@ -80,22 +107,27 @@ const NotFound = lazy(() => import("./pages/NotFound.tsx").then((m) => ({ default: m.NotFound })) ); -// Default feed tab for the current viewer ("followed" needs a session). +/** + * Returns the default feed tab for the current viewer ("followed" needs a + * session). + */ function useResolvedDefaultTab() { const { user } = useAuth(); const [preferredTab] = useDefaultFeedTab(); return preferredTab === "followed" && !user ? DEFAULT_FEED_TAB : preferredTab; } -// The query string has to survive the hop: the Web Share Target posts to "/", -// so an Android share arrives here as `/?share_url=…` and the feed below is the -// only thing that can act on it. +/** + * Redirects `/` to the default feed tab. The query string has to survive the + * hop: the Web Share Target posts to "/", so an Android share arrives here as + * `/?share_url=…` and the feed below is the only thing that can act on it. + */ function IndexRedirect() { const { search } = useLocation(); return ; } -// Bare `/` lands on that category's default feed tab. +/** Redirects a bare `/` to that category's default feed tab. */ function CategoryRedirect() { const { categorySlug } = useParams(); const { search } = useLocation(); @@ -107,14 +139,17 @@ function CategoryRedirect() { ); } -// Both `/~/:feedTab` (all) and `/:categorySlug/:feedTab` render the same feed. -// Keying by scope remounts the feed when switching categories so it refetches, -// while switching tabs within a scope keeps it mounted (client-side re-sort). +/** + * Renders the feed of `/~/:feedTab` (all) and `/:categorySlug/:feedTab` alike. + * Keying by scope remounts the feed when switching categories so it refetches, + * while switching tabs within a scope keeps it mounted (client-side re-sort). + */ function ScopedIndex() { const { categorySlug } = useParams(); return ; } +/** Renders the route table, and the floating buttons over every page. */ function AppRoutes() { return ( <> @@ -181,7 +216,11 @@ function AppRoutes() { ); } -export function App() { +/** + * Renders the app: the theme, session, player, socket, follow and category + * providers around the router, the chat, the routes and the global player. + */ +export function App(): React.JSX.Element { return ( diff --git a/src/components/category/CategoryManager.tsx b/src/components/category/CategoryManager.tsx index 9bf3d6e7..cea72c86 100644 Binary files a/src/components/category/CategoryManager.tsx and b/src/components/category/CategoryManager.tsx differ diff --git a/src/components/category/CategorySelect.tsx b/src/components/category/CategorySelect.tsx index e7e01d17..41c22644 100644 --- a/src/components/category/CategorySelect.tsx +++ b/src/components/category/CategorySelect.tsx @@ -1,18 +1,30 @@ +/** + * A dump form's category picker. + * + * @module + */ + import { Trans } from "@lingui/react/macro"; import { useCategories } from "../../hooks/category/useCategories.ts"; +/** The props of {@link CategorySelect}. */ +export interface CategorySelectProps { + /** The ids of the categories chosen. */ + selected: Set; + /** Called with the id of a category toggled. */ + onToggle: (id: string) => void; + /** Whether the chips are disabled. */ + disabled?: boolean; +} + /** - * Multi-select of categories rendered as toggle chips. Renders nothing when no - * categories are configured, so dump forms stay unchanged until an admin adds - * the first category. + * Renders a multi-select of categories as toggle chips. Renders nothing when + * no categories are configured, so dump forms stay unchanged until an admin + * adds the first category. */ export function CategorySelect( - { selected, onToggle, disabled }: { - selected: Set; - onToggle: (id: string) => void; - disabled?: boolean; - }, -) { + { selected, onToggle, disabled }: CategorySelectProps, +): React.JSX.Element | null { const { categories } = useCategories(); if (categories.length === 0) return null; diff --git a/src/components/category/CategorySwitcher.tsx b/src/components/category/CategorySwitcher.tsx index 4f053dc1..a2b438b3 100644 --- a/src/components/category/CategorySwitcher.tsx +++ b/src/components/category/CategorySwitcher.tsx @@ -1,3 +1,9 @@ +/** + * The feed's category switcher. + * + * @module + */ + import { type CSSProperties, useEffect, @@ -12,15 +18,16 @@ import { useCategories } from "../../hooks/category/useCategories.ts"; import { DEFAULT_FEED_TAB } from "../../config/feedTabs.ts"; /** - * Header dropdown that switches the feed scope between "All" and a category, - * preserving the active feed tab (e.g. /music/new → /cinema/new). Rendered as - * the leading item of the feed-sort tab row so it reads as part of the bar. + * Renders the header dropdown that switches the feed scope between "All" and a + * category, preserving the active feed tab (e.g. /music/new → /cinema/new). + * Rendered as the leading item of the feed-sort tab row so it reads as part of + * the bar. * * A custom popover (not a native