From 563daa720cd5f31d91c3037e297c464978139ffe Mon Sep 17 00:00:00 2001 From: khannurien Date: Mon, 28 Sep 2026 07:23:03 +0000 Subject: [PATCH] =?UTF-8?q?v3:=20src/=20is=20documented=20like=20api/=20an?= =?UTF-8?q?d=20shared/,=20and=20the=20reference=20covers=20the=20whole=20r?= =?UTF-8?q?epository=20=E2=80=94=20the=20frontend=20had=20no=20module=20do?= =?UTF-8?q?cs,=20JSDoc=20on=20some=20components=20and=20hooks=20and=20not?= =?UTF-8?q?=20on=20others,=20props=20typed=20inline=20in=20half=20the=20co?= =?UTF-8?q?mponents=20and=20in=20private=20interfaces=20in=20the=20rest,?= =?UTF-8?q?=20`//`=20comments=20on=20declarations,=20and=20no=20return=20t?= =?UTF-8?q?ypes=20on=20147=20exported=20functions,=20so=20`deno=20doc=20--?= =?UTF-8?q?lint`=20over=20src/=20found=20637=20errors.=20The=20rule=20api/?= =?UTF-8?q?=20follows=20is=20applied=20to=20every=20non-test=20module=20of?= =?UTF-8?q?=20src/:=20a=20`/**=20=E2=80=A6=20@module=20*/`=20block,=20JSDo?= =?UTF-8?q?c=20on=20every=20export=20and=20every=20field=20of=20an=20expor?= =?UTF-8?q?ted=20interface,=20`/**=20*/`=20for=20declarations=20and=20`//`?= =?UTF-8?q?=20for=20notes,=20verbs=20for=20functions=20("Renders=E2=80=A6"?= =?UTF-8?q?=20for=20a=20component),=20parameters=20named=20in=20the=20pros?= =?UTF-8?q?e,=20`{@link}`=20only=20for=20symbols=20in=20scope,=20and=20a?= =?UTF-8?q?=20page's=20JSDoc=20names=20its=20route=20and=20who=20may=20see?= =?UTF-8?q?=20it=20(App.tsx's=20module=20doc=20lists=20the=20route=20table?= =?UTF-8?q?).=20For=20deno=20doc=20to=20show=20them,=20every=20exported=20?= =?UTF-8?q?function=20states=20its=20return=20type:=20components=20`React.?= =?UTF-8?q?JSX.Element`=20(or=20`|=20null`,=20or=20a=20portal),=20memo=20a?= =?UTF-8?q?nd=20forwardRef=20components=20their=20exotic=20types,=20contex?= =?UTF-8?q?ts=20`React.Context<=E2=80=A6>`,=20and=20a=20hook=20returning?= =?UTF-8?q?=20several=20things=20a=20documented=20`UseResult`=20inte?= =?UTF-8?q?rface=20(useAuth,=20useRequiredAuth,=20useFading,=20useEmojiTri?= =?UTF-8?q?gger,=20useMentionAutocomplete,=20useDumpCardLink,=20usePlayRic?= =?UTF-8?q?hContent;=20FeedCacheResult,=20YouTubePlaylistControls=20and=20?= =?UTF-8?q?UseApiFormReturn=20are=20renamed=20to=20match).=20Every=20compo?= =?UTF-8?q?nent=20that=20takes=20props=20takes=20an=20exported,=20document?= =?UTF-8?q?ed=20`Props`=20interface=20=E2=80=94=20the=2064=20inline?= =?UTF-8?q?=20prop=20types=20are=20lifted=20into=20one,=20DumpFeed=20gets?= =?UTF-8?q?=20DumpFeedProps=20extending=20MainFeedProps.=20React=20types?= =?UTF-8?q?=20go=20through=20the=20global=20`React`=20namespace:=20the=20f?= =?UTF-8?q?our=20default=20imports=20of=20`react`=20are=20gone,=20since=20?= =?UTF-8?q?deno=20doc=20can't=20follow=20them.=20Two=20duplicates=20go=20t?= =?UTF-8?q?oo:=20useMentionAutocomplete's=20UserResult=20was=20shared/'s?= =?UTF-8?q?=20UserSuggestion,=20and=20JournalCard=20re-exported=20JournalS?= =?UTF-8?q?hape=20for=20no=20one;=20i18n.ts=20no=20longer=20re-exports=20L?= =?UTF-8?q?ingui's=20`i18n`,=20which=20its=20three=20users=20now=20import?= =?UTF-8?q?=20from=20@lingui/core.=20`deno=20task=20doc`=20and=20`doc:lint?= =?UTF-8?q?`=20now=20take=20src/=20(but=20its=20tests=20and=20the=20genera?= =?UTF-8?q?ted=20locales),=20the=20index=20page=20gains=20a=20Frontend=20s?= =?UTF-8?q?ection=20linking=20every=20module=20by=20layer=20and=20domain,?= =?UTF-8?q?=20mod.test.ts=20walks=20src/=20as=20well,=20and=20the=20docs?= =?UTF-8?q?=20workflow=20runs=20on=20changes=20to=20src/.=20deno=20doc=20d?= =?UTF-8?q?oesn't=20document=20npm=20packages,=20so=20it=20takes=20a=20typ?= =?UTF-8?q?e=20imported=20from=20one=20=E2=80=94=20react-hook-form's=20Fie?= =?UTF-8?q?ldValues=20in=20the=20form=20fields,=20frimousse's=20SkinTone?= =?UTF-8?q?=20=E2=80=94=20for=20a=20private=20one;=20`doc:lint`=20now=20go?= =?UTF-8?q?es=20through=20doc-lint.ts,=20which=20fails=20on=20every=20diag?= =?UTF-8?q?nostic=20but=20those,=2025=20of=20them.=20The=20README=20states?= =?UTF-8?q?=20the=20frontend's=20part=20of=20the=20rule.=20No=20behaviour?= =?UTF-8?q?=20changes;=20205=20backend=20and=20628=20frontend=20tests=20pa?= =?UTF-8?q?ss,=20and=20tsc,=20deno=20check=20and=20deno=20lint=20are=20cle?= =?UTF-8?q?an.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/docs.yaml | 3 +- README.md | 44 ++-- deno.json | 4 +- doc-lint.ts | 77 +++++++ mod.test.ts | 8 +- mod.ts | 199 +++++++++++++++++- src/App.tsx | 57 ++++- src/components/category/CategoryManager.tsx | Bin 7295 -> 7567 bytes src/components/category/CategorySelect.tsx | 30 ++- src/components/category/CategorySwitcher.tsx | 15 +- src/components/chat/ChatEditBox.tsx | 18 +- src/components/chat/ChatMessageItem.tsx | 25 ++- src/components/chat/ChatModal.tsx | 69 ++++-- src/components/chat/PendingItem.tsx | 22 +- src/components/chat/ReplyRef.tsx | 21 +- src/components/comment/CommentForm.tsx | 21 +- src/components/comment/CommentNode.tsx | 26 ++- src/components/comment/CommentThread.tsx | 26 ++- src/components/dump/DumpCard.tsx | 30 ++- src/components/dump/FilePreview.tsx | 31 ++- src/components/dump/JournalCard.tsx | 29 ++- src/components/dump/RichContentCard.tsx | 18 +- .../dump/create/DumpCreateModal.tsx | 27 ++- .../dump/create/DuplicateNotice.tsx | 26 ++- .../dump/create/LocalFilePreview.tsx | 18 +- src/components/dump/create/StepIndicator.tsx | 30 ++- src/components/dump/create/TitleField.tsx | 36 +++- src/components/editor/EmojiPickerPopup.tsx | 25 ++- src/components/editor/Markdown.tsx | 56 +++-- src/components/editor/MentionDropdown.tsx | 22 +- src/components/editor/TextEditor.tsx | 35 ++- src/components/feed/DumpFeed.tsx | 36 +++- src/components/feed/FeedTabBar.tsx | 14 +- src/components/feed/FollowedFeed.tsx | 34 ++- src/components/feed/JournalFeed.tsx | 12 +- src/components/feed/PresenceRow.tsx | 39 +++- src/components/form/FileDropZone.tsx | 21 +- src/components/form/FileField.tsx | 22 +- src/components/form/FormActions.tsx | 21 +- src/components/form/FormError.tsx | 22 +- src/components/form/RichTextField.tsx | 24 ++- src/components/form/SegmentedField.tsx | 47 +++-- src/components/form/SubmitButton.tsx | 20 +- src/components/form/TextField.tsx | 34 ++- src/components/layout/AppHeader.tsx | 48 ++++- src/components/layout/ChatButton.tsx | 13 +- src/components/layout/ChatFab.tsx | 14 +- src/components/layout/DumpFab.tsx | 17 +- src/components/layout/NotificationBell.tsx | 12 +- src/components/layout/PageError.tsx | 25 ++- src/components/layout/PageShell.tsx | 15 +- src/components/layout/SearchBar.tsx | 22 +- src/components/layout/UserMenu.tsx | 18 +- src/components/player/GlobalPlayer.tsx | 38 +++- src/components/player/MediaPlayer.tsx | 27 ++- src/components/player/PlayerIcons.tsx | 26 ++- src/components/player/SeekBar.tsx | 37 +++- src/components/player/Waveform.tsx | 31 ++- .../playlist/AddToPlaylistModal.tsx | 18 +- src/components/playlist/NewPlaylistButton.tsx | 15 +- src/components/playlist/PlaylistCard.tsx | 19 +- .../playlist/PlaylistCreateForm.tsx | 19 +- .../playlist/PlaylistMembershipPanel.tsx | 20 +- .../profile/ChangePasswordModal.tsx | 20 +- .../profile/ProfileSubpageHeader.tsx | 19 +- src/components/shared/Avatar.tsx | 21 +- src/components/shared/ConfirmModal.tsx | 19 +- src/components/shared/CountedInput.tsx | 22 +- src/components/shared/ErrorCard.tsx | 19 +- src/components/shared/ImagePicker.tsx | 22 +- src/components/shared/Modal.tsx | 19 +- src/components/shared/TabBar.tsx | 31 ++- src/components/shared/Thumbnail.tsx | 40 ++-- .../shared/ThumbnailPlaceholder.tsx | 18 +- src/components/shared/Tooltip.tsx | 14 +- src/components/social/FollowButton.tsx | 28 ++- src/components/social/LikeButton.tsx | 21 +- src/components/social/UserListPopover.tsx | 24 ++- src/components/social/VoteButton.tsx | 20 +- src/config/api.ts | 25 ++- src/config/feedTabs.ts | 12 +- src/config/playerMode.ts | 9 + src/contexts/auth/AuthContext.ts | 15 +- src/contexts/auth/AuthProvider.tsx | 24 ++- src/contexts/category/CategoriesContext.ts | 29 ++- src/contexts/category/CategoriesProvider.tsx | 22 +- src/contexts/chat/ChatContext.ts | 15 +- src/contexts/chat/ChatProvider.tsx | 26 ++- src/contexts/player/PlayerContext.ts | 104 +++++++-- src/contexts/player/PlayerProvider.tsx | 34 ++- src/contexts/social/FollowContext.ts | 20 +- src/contexts/social/FollowProvider.tsx | 22 +- src/contexts/theme/ThemeContext.ts | 18 +- src/contexts/theme/ThemeProvider.tsx | 23 +- src/contexts/ws/AvatarOverrideContext.ts | 28 ++- src/contexts/ws/WSContext.ts | 159 +++++++++++--- src/contexts/ws/WSProvider.tsx | 73 +++++-- src/hooks/auth/useAuth.ts | 92 ++++++-- src/hooks/category/useCategories.ts | 18 +- src/hooks/chat/useChat.ts | 16 +- src/hooks/dump/useDumpCardLink.ts | 49 ++++- src/hooks/dump/useDumpListSync.ts | 21 +- src/hooks/dump/useUserDumpFeed.ts | 40 ++-- src/hooks/editor/useEmojiTrigger.ts | 66 ++++-- src/hooks/editor/useMentionAutocomplete.ts | 81 +++++-- src/hooks/feed/useDefaultFeedTab.ts | 19 +- src/hooks/feed/useFading.ts | 40 ++-- src/hooks/feed/useFeedCache.ts | 30 ++- src/hooks/feed/useInfiniteScroll.ts | 34 +-- src/hooks/feed/usePositionAwareSync.ts | 25 ++- src/hooks/feed/useScrollSave.ts | 8 +- src/hooks/form/useApiForm.ts | 15 +- src/hooks/player/usePlayRichContent.ts | 44 +++- src/hooks/player/useYouTubePlaylist.ts | 71 ++++--- src/hooks/playlist/usePlaylistListSync.ts | 21 +- src/hooks/shared/useAnchoredFloat.ts | 22 +- src/hooks/shared/useDocumentTitle.ts | 48 +++-- src/hooks/shared/useHeightTransition.ts | 17 +- src/hooks/shared/useRoomBeside.ts | 44 ++-- src/hooks/shared/useScrollLock.ts | 15 +- src/hooks/shared/useSoundsEnabled.ts | 12 +- src/hooks/shared/useTabParam.ts | 15 +- src/hooks/social/useFollows.ts | 18 +- src/hooks/theme/useTheme.ts | 16 +- src/hooks/ws/useWS.ts | 23 +- src/i18n.ts | 27 ++- src/main.tsx | 20 +- src/pages/Dump.tsx | 15 +- src/pages/DumpEdit.tsx | 17 +- src/pages/Index.tsx | 26 ++- src/pages/NotFound.tsx | 9 +- src/pages/Notifications.tsx | 29 ++- src/pages/PlaylistDetail.tsx | 20 +- src/pages/ResetPassword.tsx | 13 +- src/pages/RestrictedGuest.tsx | 19 +- src/pages/RestrictedLoggedIn.tsx | 20 +- src/pages/Search.tsx | 15 +- src/pages/UserDumps.tsx | 12 +- src/pages/UserLogin.tsx | 18 +- src/pages/UserPlaylists.tsx | 16 +- src/pages/UserPublicProfile.tsx | 57 ++++- src/pages/UserRegister.tsx | 14 +- src/pages/UserUpvoted.tsx | 13 +- src/test/utils/shared/relativeTime.test.ts | 2 +- src/utils/auth/permissions.ts | 7 + src/utils/auth/storage.ts | 21 +- src/utils/chat/draft.ts | 19 +- src/utils/chat/message.ts | 24 ++- src/utils/chat/text.ts | 22 +- src/utils/chat/time.ts | 22 +- src/utils/dump/draft.ts | 38 +++- src/utils/editor/skinTone.ts | 8 +- src/utils/editor/text.ts | 19 +- src/utils/feed/hotScore.ts | 7 + src/utils/feed/journalLayout.ts | 79 ++++--- src/utils/player/bandcamp.ts | 16 +- src/utils/player/duration.ts | 10 +- src/utils/player/streamSources.ts | 22 +- src/utils/player/volume.ts | 18 +- src/utils/player/waveform.ts | 21 +- src/utils/player/youtube.ts | 19 +- src/utils/shared/api.ts | 24 ++- src/utils/shared/charCount.ts | 16 +- src/utils/shared/deserialize.ts | 29 ++- src/utils/shared/format.ts | 16 +- src/utils/shared/proxyImage.ts | 23 +- src/utils/shared/relativeTime.ts | 9 +- src/utils/shared/sound.ts | 49 ++++- src/utils/shared/thumbnailTint.ts | 42 +++- src/utils/shared/urls.ts | 47 +++-- src/utils/shared/visited.ts | 20 +- 171 files changed, 3921 insertions(+), 851 deletions(-) create mode 100644 doc-lint.ts 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 9bf3d6e7f07cfadd109d4074e5528aec5204cb50..cea72c8646cc4543e19935626999b3ef9692a720 100644 GIT binary patch delta 344 zcmYk2KTE_g7{-yRLsdAr$?fGpgnG8?p&$oxi{fB)aBzvu+g`AFbEI!MM8t2PLqCR# z(9b8D^y=Z3kmvXO$qR2SD zyuFWSC2AyYAGQv+kN@l!)+{P8IlN3uMNe?i_nURGuYKUKX?EV<`Z@oz6*&rFRnpst z^+H#fgm(2vLRHqq7?z6XP9U*F_DkWhW;QeiV^=8Qa*g|sNy5CQjFlnZt3BCG2fyXn BZ4Lkc delta 54 zcmeCT{%^6-B8YkN66RHmj+3KV>;x1_GEx; + /** 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