Compare commits
2 Commits
d62e4a9883
...
563daa720c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
563daa720c | ||
|
|
b85a71d86e |
@@ -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"
|
||||
|
||||
45
README.md
45
README.md
@@ -158,23 +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 is 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; `shared/` is not documented yet, so it still
|
||||
fails there.
|
||||
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 `Use<Name>Result` interface.
|
||||
A component that takes props takes an exported `<Name>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`,
|
||||
|
||||
@@ -406,7 +406,8 @@ export function removeDumpFromPlaylist(
|
||||
* Puts the playlist's members the requester can see in the order of
|
||||
* `dumpIds`, and announces the change.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator; 400 when `dumpIds` isn't exactly the
|
||||
* @throws {APIException} 404 when there is no such playlist, 403 when the user
|
||||
* is neither its owner nor a moderator, 400 when `dumpIds` isn't exactly the
|
||||
* members they can see.
|
||||
*/
|
||||
export function reorderPlaylist(
|
||||
|
||||
@@ -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",
|
||||
|
||||
77
doc-lint.ts
Normal file
77
doc-lint.ts
Normal file
@@ -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<Diagnostic[]> {
|
||||
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);
|
||||
}
|
||||
@@ -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)), []);
|
||||
});
|
||||
|
||||
202
mod.ts
202
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/`
|
||||
*
|
||||
@@ -137,8 +137,7 @@
|
||||
*
|
||||
* The types and constants both halves use: the API envelope, each domain's
|
||||
* types and request bodies, the socket protocol, the validation limits and the
|
||||
* role permissions. It imports nothing from either half. Its modules aren't
|
||||
* documented yet.
|
||||
* role permissions. It imports nothing from either half.
|
||||
*
|
||||
* Modules: [api](./shared/api.ts/index.html),
|
||||
* [category](./shared/category.ts/index.html),
|
||||
@@ -156,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
|
||||
*/
|
||||
|
||||
@@ -1,39 +1,66 @@
|
||||
/**
|
||||
* The envelope every endpoint answers with, and the error codes it carries.
|
||||
* The envelope every endpoint answers with, the error codes it carries, the
|
||||
* shape of a paginated list, and {@link Wire}, which maps a type to what JSON
|
||||
* makes of it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** The error codes an {@link APIError} carries, by name. */
|
||||
export const APIErrorCode = {
|
||||
/** The request is malformed or not allowed in the current state. */
|
||||
BAD_REQUEST: "BAD_REQUEST",
|
||||
/** What the request names doesn't exist, or the caller may not see it. */
|
||||
NOT_FOUND: "NOT_FOUND",
|
||||
/** The server failed. */
|
||||
SERVER_ERROR: "SERVER_ERROR",
|
||||
/** A remote site took too long to answer. */
|
||||
TIMEOUT: "TIMEOUT",
|
||||
/** The caller isn't signed in, or may not do this. */
|
||||
UNAUTHORIZED: "UNAUTHORIZED",
|
||||
/** The request body failed validation. */
|
||||
VALIDATION_ERROR: "VALIDATION_ERROR",
|
||||
} as const;
|
||||
|
||||
/** One of the {@link APIErrorCode} values. */
|
||||
export type APIErrorCode = typeof APIErrorCode[keyof typeof APIErrorCode];
|
||||
|
||||
/** What went wrong, in a failed response. */
|
||||
export interface APIError {
|
||||
/** The kind of failure. */
|
||||
code: APIErrorCode;
|
||||
/** A human-readable explanation. */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** A successful response, carrying its payload. */
|
||||
export interface APISuccess<T> {
|
||||
/** Always true. */
|
||||
success: true;
|
||||
/** The payload. */
|
||||
data: T;
|
||||
/** Never set on success. */
|
||||
error?: never;
|
||||
}
|
||||
|
||||
/** A failed response, carrying its error. */
|
||||
export interface APIFailure {
|
||||
/** Always false. */
|
||||
success: false;
|
||||
/** Never set on failure. */
|
||||
data?: never;
|
||||
/** What went wrong. */
|
||||
error: APIError;
|
||||
}
|
||||
|
||||
/** What every endpoint answers with, narrowed by `success`. */
|
||||
export type APIResponse<T> = APISuccess<T> | APIFailure;
|
||||
|
||||
/** One page of a list. */
|
||||
export interface PaginatedData<T> {
|
||||
/** The page's items. */
|
||||
items: T[];
|
||||
/** Whether another page follows. */
|
||||
hasMore: boolean;
|
||||
/** The whole list's size — only where it's shown (search results). */
|
||||
total?: number;
|
||||
|
||||
@@ -1,20 +1,42 @@
|
||||
/**
|
||||
* Categories, the admin-managed topics a dump can be filed under, and the
|
||||
* bodies that create and edit them.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** A category, which lists its dumps at `/<slug>`. */
|
||||
export interface Category {
|
||||
/** The category's id. */
|
||||
id: string;
|
||||
/** The category's URL slug, at the site root. */
|
||||
slug: string;
|
||||
/** The category's display name. */
|
||||
name: string;
|
||||
/** The category's place in the list, ascending. */
|
||||
position: number;
|
||||
/** When the category was created. */
|
||||
createdAt: Date;
|
||||
/** When the category was last changed, if ever. */
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
/** The body of `POST /api/categories`. */
|
||||
export interface CreateCategoryRequest {
|
||||
/** The new category's slug. */
|
||||
slug: string;
|
||||
/** The new category's display name. */
|
||||
name: string;
|
||||
/** Its place in the list; 0 when left out. */
|
||||
position?: number;
|
||||
}
|
||||
|
||||
/** The body of `PUT /api/categories/:id`: the fields to change. */
|
||||
export interface UpdateCategoryRequest {
|
||||
/** The new slug. */
|
||||
slug?: string;
|
||||
/** The new display name. */
|
||||
name?: string;
|
||||
/** The new place in the list. */
|
||||
position?: number;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
/**
|
||||
* A message of the site-wide chat.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** A chat message, with what the chat shows of its author and its reply. */
|
||||
export interface ChatMessage {
|
||||
/** The message's id. */
|
||||
id: string;
|
||||
/** The author's id. */
|
||||
userId: string;
|
||||
/** The message, in Markdown. */
|
||||
body: string;
|
||||
/** When the message was sent. */
|
||||
createdAt: Date;
|
||||
/** When the message was last edited, if ever. */
|
||||
updatedAt?: Date;
|
||||
/** The author's name. */
|
||||
authorUsername: string;
|
||||
/** The author's avatar type, undefined without one. */
|
||||
authorAvatarMime?: string;
|
||||
/** Id of the message this one replies to, if any. Retained even when the
|
||||
* target is later deleted (the preview fields below then go undefined). */
|
||||
/**
|
||||
* Id of the message this one replies to, if any. Retained even when the
|
||||
* target is later deleted (the preview fields below then go undefined).
|
||||
*/
|
||||
replyToId?: string;
|
||||
/** Author/snippet of the replied-to message, for a subtle inline reference.
|
||||
* Absent when there's no reply, or the target has since been deleted. */
|
||||
/**
|
||||
* Author of the replied-to message, for a subtle inline reference. Absent
|
||||
* when there's no reply, or the target has since been deleted.
|
||||
*/
|
||||
replyToAuthor?: string;
|
||||
/** Body of the replied-to message, absent under the same conditions. */
|
||||
replyToBody?: string;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,48 @@
|
||||
/**
|
||||
* Comments under a dump, and the bodies that post and edit them.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** A comment, with what the thread shows of its author. */
|
||||
export interface Comment {
|
||||
/** The comment's id. */
|
||||
id: string;
|
||||
/** The id of the dump it is posted under. */
|
||||
dumpId: string;
|
||||
/** The author's id. */
|
||||
userId: string;
|
||||
/** The id of the comment it replies to; undefined at the top level. */
|
||||
parentId?: string;
|
||||
/** The comment, in Markdown; empty once deleted. */
|
||||
body: string;
|
||||
/** When the comment was posted. */
|
||||
createdAt: Date;
|
||||
/** When the comment was last edited, if ever. */
|
||||
updatedAt?: Date;
|
||||
/**
|
||||
* Whether the comment was deleted: it stays in the thread, emptied, so its
|
||||
* replies keep their place.
|
||||
*/
|
||||
deleted: boolean;
|
||||
/** How many users like the comment. */
|
||||
likeCount: number;
|
||||
/** The author's name. */
|
||||
authorUsername: string;
|
||||
/** The author's avatar type, undefined without one. */
|
||||
authorAvatarMime?: string;
|
||||
}
|
||||
|
||||
/** The body of `POST /api/dumps/:dumpId/comments`. */
|
||||
export interface CreateCommentRequest {
|
||||
/** The comment, in Markdown. */
|
||||
body: string;
|
||||
/** The id of the comment it replies to, for a reply. */
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
/** The body of `PATCH /api/comments/:commentId`. */
|
||||
export interface UpdateCommentRequest {
|
||||
/** The new text, in Markdown. */
|
||||
body: string;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
/**
|
||||
* Dumps — the links and files users post — their link previews, and the bodies
|
||||
* that post and edit them.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** What a link's page says about itself, as its preview card shows it. */
|
||||
export interface RichContent {
|
||||
/**
|
||||
* Which provider read it: `youtube`, `bandcamp`, `soundcloud` or `generic`.
|
||||
*/
|
||||
type: string;
|
||||
/** The link the preview is of. */
|
||||
url: string;
|
||||
/** The site's name. */
|
||||
siteName?: string;
|
||||
/** The page's title. */
|
||||
title?: string;
|
||||
/** The page's description. */
|
||||
description?: string;
|
||||
/**
|
||||
* A real preview image (og:image, a large content image, a video still).
|
||||
@@ -14,28 +29,49 @@ export interface RichContent {
|
||||
faviconUrl?: string;
|
||||
/** The page's declared brand color, normalized to `#rrggbb`. */
|
||||
accentColor?: string;
|
||||
/** The YouTube video's id, for a YouTube link. */
|
||||
videoId?: string;
|
||||
/** The URL of the page's embeddable player, if it has one. */
|
||||
embedUrl?: string;
|
||||
}
|
||||
|
||||
/** A dump: a link or a file a user posted. */
|
||||
export interface Dump {
|
||||
/** The dump's id. */
|
||||
id: string;
|
||||
/** `url` for a link, `file` for an upload. */
|
||||
kind: "url" | "file";
|
||||
/** The dump's title. */
|
||||
title: string;
|
||||
/** The dump's URL slug. */
|
||||
slug?: string;
|
||||
/** The poster's description, in Markdown. */
|
||||
comment?: string;
|
||||
/** The poster's id. */
|
||||
userId: string;
|
||||
/** When the dump was posted. */
|
||||
createdAt: Date;
|
||||
/** When the dump was last edited, if ever. */
|
||||
updatedAt?: Date;
|
||||
/** The link, for a URL dump. */
|
||||
url?: string;
|
||||
/** The link's preview, for a URL dump. */
|
||||
richContent?: RichContent;
|
||||
/** The uploaded file's name, for a file dump. */
|
||||
fileName?: string;
|
||||
/** The uploaded file's type, for a file dump. */
|
||||
fileMime?: string;
|
||||
/** The uploaded file's size in bytes, for a file dump. */
|
||||
fileSize?: number;
|
||||
/** How many users upvoted the dump. */
|
||||
voteCount: number;
|
||||
/** How many comments it has, not counting deleted ones. */
|
||||
commentCount: number;
|
||||
/** Whether only the poster sees the dump. */
|
||||
isPrivate: boolean;
|
||||
/** The uploaded thumbnail's type, undefined without one. */
|
||||
thumbnailMime?: string;
|
||||
/** The ids of the categories it is filed under. */
|
||||
categoryIds?: string[];
|
||||
}
|
||||
|
||||
@@ -45,12 +81,19 @@ export interface Dump {
|
||||
* `findDumpsByUrl`.
|
||||
*/
|
||||
export interface DumpUrlMatch {
|
||||
/** The dump's id. */
|
||||
id: string;
|
||||
/** The dump's URL slug. */
|
||||
slug?: string;
|
||||
/** The dump's title. */
|
||||
title: string;
|
||||
/** The poster's name. */
|
||||
username: string;
|
||||
/** When the dump was posted. */
|
||||
createdAt: Date;
|
||||
/** How many users upvoted the dump. */
|
||||
voteCount: number;
|
||||
/** How many comments it has. */
|
||||
commentCount: number;
|
||||
}
|
||||
|
||||
@@ -60,29 +103,42 @@ export interface DumpUrlMatch {
|
||||
* page has no preview" from "this link is dead".
|
||||
*/
|
||||
export interface LinkPreview {
|
||||
/** Whether the page could be fetched. */
|
||||
reached: boolean;
|
||||
/** The preview, null when there is none at all. */
|
||||
richContent: RichContent | null;
|
||||
}
|
||||
|
||||
/** The JSON body of `POST /api/dumps`, which posts a link. */
|
||||
export interface CreateUrlDumpRequest {
|
||||
/** The link. */
|
||||
url: string;
|
||||
/** Overrides the title scraped from the page — the poster edited the preview. */
|
||||
title?: string;
|
||||
/** The poster's description, in Markdown. */
|
||||
comment?: string;
|
||||
/** Whether only the poster will see it. */
|
||||
isPrivate?: boolean;
|
||||
/** The ids of the categories to file it under. */
|
||||
categoryIds?: string[];
|
||||
}
|
||||
|
||||
/** The body of `PUT /api/dumps/:dumpId`: the fields to change. */
|
||||
export interface UpdateDumpRequest {
|
||||
/** The new link, for a URL dump. */
|
||||
url?: string;
|
||||
/** The new title. */
|
||||
title?: string;
|
||||
/** `null` removes the note; leaving it out keeps it. */
|
||||
comment?: string | null;
|
||||
/** Whether only the poster will see it. */
|
||||
isPrivate?: boolean;
|
||||
/** The ids of the categories to file it under, replacing the current ones. */
|
||||
categoryIds?: string[];
|
||||
}
|
||||
|
||||
/** `POST /api/attachments`: the id the uploaded image is served under. */
|
||||
export interface AttachmentResponse {
|
||||
/** The attachment's id, for `GET /api/attachments/:attachmentId`. */
|
||||
id: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
/**
|
||||
* What a user follows.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** `GET /api/follows/status`: the ids of what the caller follows. */
|
||||
export interface FollowStatus {
|
||||
/** The ids of the users they follow. */
|
||||
followedUserIds: string[];
|
||||
/** The ids of the playlists they follow. */
|
||||
followedPlaylistIds: string[];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
// Matches @username not already inside a markdown link ([...] or (...)) and
|
||||
// not mid-word, so an email address doesn't mention its domain. The server
|
||||
// notifies whoever it matches; the chat highlights the same names.
|
||||
export const MENTION_RE = /(?<![[(\w])@(\w+)/g;
|
||||
/**
|
||||
* The @mention pattern, one for the server and the chat.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* Matches @username not already inside a markdown link ([...] or (...)) and
|
||||
* not mid-word, so an email address doesn't mention its domain; the name is
|
||||
* the first group. The server notifies whoever it matches; the chat highlights
|
||||
* the same names.
|
||||
*/
|
||||
export const MENTION_RE: RegExp = /(?<![[(\w])@(\w+)/g;
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* Notifications: their types, and the data each type carries.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** What a notification is about. */
|
||||
export type NotificationType =
|
||||
| "playlist_followed"
|
||||
| "user_followed"
|
||||
@@ -8,64 +15,110 @@ export type NotificationType =
|
||||
| "dump_commented"
|
||||
| "comment_liked";
|
||||
|
||||
/** `playlist_followed`: someone followed one of the user's playlists. */
|
||||
export interface PlaylistFollowedData {
|
||||
/** The follower's id. */
|
||||
followerId: string;
|
||||
/** The follower's name. */
|
||||
followerUsername: string;
|
||||
/** The playlist's id. */
|
||||
playlistId: string;
|
||||
/** The playlist's title. */
|
||||
playlistTitle: string;
|
||||
}
|
||||
|
||||
/** `user_followed`: someone followed the user. */
|
||||
export interface UserFollowedData {
|
||||
/** The follower's id. */
|
||||
followerId: string;
|
||||
/** The follower's name. */
|
||||
followerUsername: string;
|
||||
}
|
||||
|
||||
/** `user_dump_posted`: a user they follow posted a dump. */
|
||||
export interface UserDumpPostedData {
|
||||
/** The poster's id. */
|
||||
dumperId: string;
|
||||
/** The poster's name. */
|
||||
dumperUsername: string;
|
||||
/** The dump's id. */
|
||||
dumpId: string;
|
||||
/** The dump's title. */
|
||||
dumpTitle: string;
|
||||
}
|
||||
|
||||
/** `playlist_dump_added`: a dump was added to a playlist they follow. */
|
||||
export interface PlaylistDumpAddedData {
|
||||
/** The dump's id. */
|
||||
dumpId: string;
|
||||
/** The dump's title. */
|
||||
dumpTitle: string;
|
||||
/** The playlist's id. */
|
||||
playlistId: string;
|
||||
/** The playlist's title. */
|
||||
playlistTitle: string;
|
||||
}
|
||||
|
||||
/** `dump_upvoted`: someone upvoted one of the user's dumps. */
|
||||
export interface DumpUpvotedData {
|
||||
/** The voter's id. */
|
||||
voterId: string;
|
||||
/** The voter's name. */
|
||||
voterUsername: string;
|
||||
/** The dump's id. */
|
||||
dumpId: string;
|
||||
/** The dump's title. */
|
||||
dumpTitle: string;
|
||||
}
|
||||
|
||||
/** `user_mentioned`: someone @mentioned the user. */
|
||||
export interface UserMentionedData {
|
||||
/** The mentioner's id. */
|
||||
mentionerId: string;
|
||||
/** The mentioner's name. */
|
||||
mentionerUsername: string;
|
||||
/** Where the mention was written. */
|
||||
contextType: "comment" | "dump" | "playlist" | "chat";
|
||||
/** The id of the comment, dump, playlist or chat message. */
|
||||
contextId: string;
|
||||
/**
|
||||
* The title of the dump or playlist, or of the dump a comment is under;
|
||||
* empty for the chat.
|
||||
*/
|
||||
contextTitle: string;
|
||||
/** The dump a comment is under, for a mention in a comment. */
|
||||
dumpId?: string;
|
||||
}
|
||||
|
||||
/** `dump_commented`: someone commented on one of the user's dumps. */
|
||||
export interface DumpCommentedData {
|
||||
/** The commenter's id. */
|
||||
commenterId: string;
|
||||
/** The commenter's name. */
|
||||
commenterUsername: string;
|
||||
/** The comment's id. */
|
||||
commentId: string;
|
||||
/** The dump's id. */
|
||||
dumpId: string;
|
||||
/** The dump's title. */
|
||||
dumpTitle: string;
|
||||
}
|
||||
|
||||
/** `comment_liked`: someone liked one of the user's comments. */
|
||||
export interface CommentLikedData {
|
||||
/** The liker's id. */
|
||||
likerId: string;
|
||||
/** The liker's name. */
|
||||
likerUsername: string;
|
||||
/** The comment's id. */
|
||||
commentId: string;
|
||||
/** The id of the dump it is under. */
|
||||
dumpId: string;
|
||||
/** The title of the dump it is under. */
|
||||
dumpTitle: string;
|
||||
}
|
||||
|
||||
/** The data of any notification; its {@link NotificationType} says which. */
|
||||
export type NotificationData =
|
||||
| PlaylistFollowedData
|
||||
| UserFollowedData
|
||||
@@ -76,11 +129,18 @@ export type NotificationData =
|
||||
| DumpCommentedData
|
||||
| CommentLikedData;
|
||||
|
||||
/** A notification sent to a user. */
|
||||
export interface Notification {
|
||||
/** The notification's id. */
|
||||
id: string;
|
||||
/** The id of the user it is for. */
|
||||
userId: string;
|
||||
/** What it is about. */
|
||||
type: NotificationType;
|
||||
/** Its details, shaped by `type`. */
|
||||
data: NotificationData;
|
||||
/** Whether the user has read it. */
|
||||
read: boolean;
|
||||
/** When it was sent. */
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
/**
|
||||
* The role permissions, one table for the server that enforces them and the
|
||||
* client that hides what a role can't do.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Role } from "./user.ts";
|
||||
|
||||
// Named capabilities checked at enforcement points. Extend this union as new
|
||||
// gated capabilities are added (e.g. "invite:manage").
|
||||
/**
|
||||
* Named capabilities checked at enforcement points. Extend this union as new
|
||||
* gated capabilities are added (e.g. "invite:manage").
|
||||
*/
|
||||
export type Permission =
|
||||
| "dump:moderate"
|
||||
| "comment:moderate"
|
||||
@@ -10,6 +19,7 @@ export type Permission =
|
||||
| "user:manage"
|
||||
| "category:manage";
|
||||
|
||||
/** Every {@link Permission}. */
|
||||
const ALL_PERMISSIONS: readonly Permission[] = [
|
||||
"dump:moderate",
|
||||
"comment:moderate",
|
||||
@@ -19,8 +29,11 @@ const ALL_PERMISSIONS: readonly Permission[] = [
|
||||
"category:manage",
|
||||
];
|
||||
|
||||
// Roles are fixed in code. `admin` is a superset of every permission (current
|
||||
// and future); `moderator` gets the content-moderation permissions only.
|
||||
/**
|
||||
* What each role may do. Roles are fixed in code. `admin` is a superset of
|
||||
* every permission (current and future); `moderator` gets the
|
||||
* content-moderation permissions only.
|
||||
*/
|
||||
export const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
|
||||
admin: ALL_PERMISSIONS,
|
||||
moderator: [
|
||||
@@ -32,6 +45,7 @@ export const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
|
||||
user: [],
|
||||
};
|
||||
|
||||
/** Checks whether `role` grants `permission`. */
|
||||
export function hasPermission(role: Role, permission: Permission): boolean {
|
||||
return ROLE_PERMISSIONS[role].includes(permission);
|
||||
}
|
||||
|
||||
@@ -1,41 +1,68 @@
|
||||
/** `GET /api/bandcamp/tracks`: a Bandcamp page resolved at play time. */
|
||||
/**
|
||||
* What the player's resolvers answer: a Bandcamp release's streams and a
|
||||
* YouTube playlist's tracks.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** A track of a Bandcamp release, as {@link Tralbum} lists it. */
|
||||
export interface TralbumTrack {
|
||||
/** Position in the parsed array — the stable handle used to re-resolve a
|
||||
* track after its signed URL expires. */
|
||||
/**
|
||||
* Position in the parsed array — the stable handle used to re-resolve a
|
||||
* track after its signed URL expires.
|
||||
*/
|
||||
index: number;
|
||||
/** The track's number on the release. */
|
||||
trackNum?: number;
|
||||
/** The track's title. */
|
||||
title: string;
|
||||
/** Seconds, fractional. */
|
||||
duration?: number;
|
||||
/** The signed stream URL, null when the track can't be played. */
|
||||
streamUrl: string | null;
|
||||
/** False for preorder, unreleased and still-encoding tracks. They stay in
|
||||
* the list so numbering matches the release, but never enter the queue. */
|
||||
/**
|
||||
* False for preorder, unreleased and still-encoding tracks. They stay in
|
||||
* the list so numbering matches the release, but never enter the queue.
|
||||
*/
|
||||
streamable: boolean;
|
||||
/** Bandcamp caps free plays per track; a capped track may serve a clip. */
|
||||
capped: boolean;
|
||||
}
|
||||
|
||||
/** `GET /api/bandcamp/tracks`: a Bandcamp page resolved at play time. */
|
||||
export interface Tralbum {
|
||||
/** The page it was resolved from. */
|
||||
sourceUrl: string;
|
||||
/** Whether the page is an album or a single track. */
|
||||
itemType: "album" | "track";
|
||||
/** The release's title. */
|
||||
title?: string;
|
||||
/** The artist's name. */
|
||||
artist?: string;
|
||||
/** The release's cover. */
|
||||
artworkUrl?: string;
|
||||
/** The release's tracks, in order. */
|
||||
tracks: TralbumTrack[];
|
||||
/** Epoch ms of the fetch that produced these signed URLs. */
|
||||
resolvedAt: number;
|
||||
}
|
||||
|
||||
/** `GET /api/youtube/playlist`: a playlist's tracklist, for the player. */
|
||||
/** A video of a YouTube playlist, as {@link YouTubePlaylist} lists it. */
|
||||
export interface YouTubePlaylistTrack {
|
||||
/** The video's id. */
|
||||
videoId: string;
|
||||
/** The video's title. */
|
||||
title: string;
|
||||
/** Seconds. */
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/** `GET /api/youtube/playlist`: a playlist's tracklist, for the player. */
|
||||
export interface YouTubePlaylist {
|
||||
/** The playlist's id. */
|
||||
listId: string;
|
||||
/** The playlist's title. */
|
||||
title?: string;
|
||||
/** Its videos, in order. */
|
||||
tracks: YouTubePlaylistTrack[];
|
||||
}
|
||||
|
||||
@@ -1,40 +1,80 @@
|
||||
/**
|
||||
* Playlists — ordered collections of dumps — and the bodies that create, edit
|
||||
* and reorder them.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Dump } from "./dump.ts";
|
||||
|
||||
/** A playlist. */
|
||||
export interface Playlist {
|
||||
/** The playlist's id. */
|
||||
id: string;
|
||||
/** The owner's id. */
|
||||
userId: string;
|
||||
/** The playlist's title. */
|
||||
title: string;
|
||||
/** The playlist's URL slug. */
|
||||
slug?: string;
|
||||
/** The owner's description, in Markdown. */
|
||||
description?: string;
|
||||
/** Whether anyone may see it, or only its owner. */
|
||||
isPublic: boolean;
|
||||
/** When the playlist was created. */
|
||||
createdAt: Date;
|
||||
/** When the playlist was last changed, if ever. */
|
||||
updatedAt?: Date;
|
||||
/** The cover image's type, undefined without one. */
|
||||
imageMime?: string;
|
||||
/**
|
||||
* How many dumps it holds, private ones included, where the query counts
|
||||
* them.
|
||||
*/
|
||||
dumpCount?: number;
|
||||
/** The owner's name, where the query joins it. */
|
||||
ownerUsername?: string;
|
||||
}
|
||||
|
||||
/** A playlist with the dumps the caller may see in it, in order. */
|
||||
export interface PlaylistWithDumps extends Playlist {
|
||||
/** The dumps, in the playlist's order. */
|
||||
dumps: Dump[];
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /api/playlists/by-dump/:dumpId/memberships`: one of the caller's
|
||||
* playlists, and whether it holds the dump.
|
||||
*/
|
||||
export interface PlaylistMembership {
|
||||
/** The playlist. */
|
||||
playlist: Playlist;
|
||||
/** Whether it holds the dump. */
|
||||
hasDump: boolean;
|
||||
}
|
||||
|
||||
/** The body of `POST /api/playlists`. */
|
||||
export interface CreatePlaylistRequest {
|
||||
/** The new playlist's title. */
|
||||
title: string;
|
||||
/** Its description, in Markdown. */
|
||||
description?: string;
|
||||
/** Whether anyone may see it. */
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
/** The body of `PUT /api/playlists/:playlistId`: the fields to change. */
|
||||
export interface UpdatePlaylistRequest {
|
||||
/** The new title. */
|
||||
title?: string;
|
||||
/** The new description; `null` removes it. */
|
||||
description?: string | null;
|
||||
/** Whether anyone may see it. */
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
/** The body of `PUT /api/playlists/:playlistId/order`. */
|
||||
export interface ReorderPlaylistRequest {
|
||||
/** Every dump of the playlist the caller can see, in the new order. */
|
||||
dumpIds: string[];
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
/**
|
||||
* What a search answers.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { PaginatedData } from "./api.ts";
|
||||
import type { Dump } from "./dump.ts";
|
||||
import type { Playlist } from "./playlist.ts";
|
||||
import type { PublicUser } from "./user.ts";
|
||||
|
||||
/** `GET /api/search`: what matches the query, in each kind. */
|
||||
export interface SearchResults {
|
||||
/** A page of the matching dumps, with their total. */
|
||||
dumps: PaginatedData<Dump>;
|
||||
/** The matching users. */
|
||||
users: PublicUser[];
|
||||
/** The matching playlists. */
|
||||
playlists: Playlist[];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
/**
|
||||
* Users as the API shows them, their roles, and the bodies and answers of
|
||||
* sign-up, sign-in, profile edits and invites.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** What a user may do beyond the basics; see `ROLE_PERMISSIONS`. */
|
||||
export type Role = "user" | "moderator" | "admin";
|
||||
|
||||
/** Every {@link Role}, least to most privileged. */
|
||||
export const ROLES: readonly Role[] = ["user", "moderator", "admin"];
|
||||
|
||||
/** Checks whether `value` is a {@link Role}. */
|
||||
export function isRole(value: unknown): value is Role {
|
||||
return typeof value === "string" &&
|
||||
(ROLES as readonly string[]).includes(value);
|
||||
@@ -9,69 +19,111 @@ export function isRole(value: unknown): value is Role {
|
||||
|
||||
/** A user as anyone may see them: no password hash, no email. */
|
||||
export interface PublicUser {
|
||||
/** The user's id. */
|
||||
id: string;
|
||||
/** The user's name, fixed at sign-up. */
|
||||
username: string;
|
||||
/** The user's role. */
|
||||
role: Role;
|
||||
/** When the user signed up. */
|
||||
createdAt: Date;
|
||||
/** When the user last changed, if ever. */
|
||||
updatedAt?: Date;
|
||||
/** The avatar's type, undefined without one. */
|
||||
avatarMime?: string;
|
||||
/** The user's description of themselves, in Markdown. */
|
||||
description?: string;
|
||||
/** The name of whoever invited them. */
|
||||
invitedByUsername?: string;
|
||||
}
|
||||
|
||||
/** A user as they see themselves: everything but the password hash. */
|
||||
export interface SelfUser extends PublicUser {
|
||||
/** The address password resets are sent to. */
|
||||
email: string;
|
||||
}
|
||||
|
||||
/** A match for the @mention autocomplete. */
|
||||
export interface UserSuggestion {
|
||||
/** The user's id. */
|
||||
id: string;
|
||||
/** The user's name. */
|
||||
username: string;
|
||||
/** The avatar's type, null without one. */
|
||||
avatarMime: string | null;
|
||||
}
|
||||
|
||||
/** Someone a user invited, directly or down the line. */
|
||||
export interface Invitee {
|
||||
/** The user's id. */
|
||||
id: string;
|
||||
/** The user's name. */
|
||||
username: string;
|
||||
/** The avatar's type, undefined without one. */
|
||||
avatarMime?: string;
|
||||
/** The id of whoever invited them, to build the tree. */
|
||||
invitedById: string;
|
||||
/** When they signed up. */
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/** The body of `POST /api/users/login`. */
|
||||
export interface LoginUserRequest {
|
||||
/** The user's name. */
|
||||
username: string;
|
||||
/** The user's password. */
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** The body of `POST /api/users/register`. */
|
||||
export interface RegisterUserRequest {
|
||||
/** The name to sign up with. */
|
||||
username: string;
|
||||
/** The password to sign up with. */
|
||||
password: string;
|
||||
/** The invite's token. */
|
||||
inviteToken: string;
|
||||
/** The address password resets will be sent to. */
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields of a user to change. `PATCH /api/users/me` takes only `description`
|
||||
* and `email` from it; the role and the password change through their own
|
||||
* endpoints.
|
||||
*/
|
||||
export interface UpdateUserRequest {
|
||||
/** The new name. */
|
||||
username?: string;
|
||||
/** The new password. */
|
||||
password?: string;
|
||||
/** The new role. */
|
||||
role?: Role;
|
||||
/** The new description; `null` removes it. */
|
||||
description?: string | null;
|
||||
/** The new email address. */
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/** The session token, plus the media-only one for file and thumbnail URLs. */
|
||||
export interface AuthTokens {
|
||||
/** The session token, sent as a bearer token. */
|
||||
token: string;
|
||||
/** The token media URLs carry as `?token=`, good for nothing else. */
|
||||
mediaToken: string;
|
||||
}
|
||||
|
||||
/** What sign-up and sign-in answer: the user and their tokens. */
|
||||
export interface AuthResponse extends AuthTokens {
|
||||
/** The signed-in user. */
|
||||
user: SelfUser;
|
||||
}
|
||||
|
||||
/** `GET /api/users/me/media-token`: a fresh media token. */
|
||||
export type MediaTokenResponse = Pick<AuthTokens, "mediaToken">;
|
||||
|
||||
/** `POST /api/invites`: the new invite's token. */
|
||||
export interface InviteResponse {
|
||||
/** The token, which the invite link carries. */
|
||||
token: string;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
/**
|
||||
* The limits on what users submit, one set for the server that enforces them
|
||||
* and the forms that check them before sending.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** Limits the server enforces and the forms check before sending. */
|
||||
export const VALIDATION = {
|
||||
/** Shortest username, in characters. */
|
||||
USERNAME_MIN: 1,
|
||||
/** Longest username. */
|
||||
USERNAME_MAX: 32,
|
||||
/** Shortest password. */
|
||||
PASSWORD_MIN: 8,
|
||||
/** Longest password. */
|
||||
PASSWORD_MAX: 128,
|
||||
/** Longest dump title. */
|
||||
DUMP_TITLE_MAX: 200,
|
||||
/** Longest dump description. */
|
||||
DUMP_COMMENT_MAX: 5000,
|
||||
/** Longest playlist title. */
|
||||
PLAYLIST_TITLE_MAX: 100,
|
||||
/** Longest playlist description. */
|
||||
PLAYLIST_DESCRIPTION_MAX: 2000,
|
||||
/** Longest comment. */
|
||||
COMMENT_BODY_MAX: 5000,
|
||||
/** Longest profile description. */
|
||||
USER_DESCRIPTION_MAX: 2000,
|
||||
/** Longest category name. */
|
||||
CATEGORY_NAME_MAX: 50,
|
||||
/** Longest category slug. */
|
||||
CATEGORY_SLUG_MAX: 50,
|
||||
/** Longest chat message. */
|
||||
CHAT_MESSAGE_MAX: 2000,
|
||||
} as const;
|
||||
|
||||
/** Largest file a file dump may hold. */
|
||||
export const DUMP_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB
|
||||
/** Largest file a file dump may hold, in bytes (50 MB). */
|
||||
export const DUMP_MAX_FILE_SIZE_BYTES: number = 50 * 1024 * 1024;
|
||||
|
||||
190
shared/ws.ts
190
shared/ws.ts
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* The socket protocol. Every message has a `type` discriminant, so each union
|
||||
* narrows with a switch. The server sends these as JSON, so the client reads
|
||||
* `Wire<ServerToClientMessage>`: its Dates arrive as strings.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { ChatMessage } from "./chat.ts";
|
||||
import type { Comment } from "./comment.ts";
|
||||
import type { Dump } from "./dump.ts";
|
||||
@@ -5,83 +13,117 @@ import type { Notification } from "./notification.ts";
|
||||
import type { Playlist } from "./playlist.ts";
|
||||
import type { PublicUser } from "./user.ts";
|
||||
|
||||
/**
|
||||
* The socket protocol. Every message has a `type` discriminant, so each union
|
||||
* narrows with a switch. The server sends these as JSON, so the client reads
|
||||
* `Wire<ServerToClientMessage>`: its Dates arrive as strings.
|
||||
*/
|
||||
|
||||
// ── Client → Server ──────────────────────────────────────────────────────────
|
||||
|
||||
/** A keepalive; the other side answers {@link PongMessage}. Both directions. */
|
||||
export interface PingMessage {
|
||||
/** The discriminant. */
|
||||
type: "ping";
|
||||
}
|
||||
|
||||
/** The answer to a {@link PingMessage}. Both directions. */
|
||||
export interface PongMessage {
|
||||
/** The discriminant. */
|
||||
type: "pong";
|
||||
}
|
||||
|
||||
/** Upvotes a dump; answered by {@link VoteAckMessage}. */
|
||||
export interface VoteCastMessage {
|
||||
/** The discriminant. */
|
||||
type: "vote_cast";
|
||||
/** The dump's id. */
|
||||
dumpId: string;
|
||||
}
|
||||
|
||||
/** Withdraws an upvote; answered by {@link VoteAckMessage}. */
|
||||
export interface VoteRemoveMessage {
|
||||
/** The discriminant. */
|
||||
type: "vote_remove";
|
||||
/** The dump's id. */
|
||||
dumpId: string;
|
||||
}
|
||||
|
||||
/** Likes a comment; answered by {@link CommentLikeAckMessage}. */
|
||||
export interface CommentLikeCastMessage {
|
||||
/** The discriminant. */
|
||||
type: "comment_like_cast";
|
||||
/** The comment's id. */
|
||||
commentId: string;
|
||||
}
|
||||
|
||||
/** Withdraws a like; answered by {@link CommentLikeAckMessage}. */
|
||||
export interface CommentLikeRemoveMessage {
|
||||
/** The discriminant. */
|
||||
type: "comment_like_remove";
|
||||
/** The comment's id. */
|
||||
commentId: string;
|
||||
}
|
||||
|
||||
/** Posts a chat message; broadcast back as {@link ChatMessageMessage}. */
|
||||
export interface ChatSendMessage {
|
||||
/** The discriminant. */
|
||||
type: "chat_send";
|
||||
/** The message, in Markdown. */
|
||||
body: string;
|
||||
/** Id of the message being replied to, if this is a reply. */
|
||||
replyToId?: string;
|
||||
/** Client-chosen id echoed on the broadcast (or on the error), so the
|
||||
* sender can match the outcome to its pending message. */
|
||||
/**
|
||||
* Client-chosen id echoed on the broadcast (or on the error), so the
|
||||
* sender can match the outcome to its pending message.
|
||||
*/
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
// Sent while the user is composing (true) and when they stop (false). The
|
||||
// server rebroadcasts the live set of typers to everyone as ChatTypingUpdate.
|
||||
/**
|
||||
* Sent while the user is composing (true) and when they stop (false). The
|
||||
* server rebroadcasts the live set of typers to everyone as
|
||||
* {@link ChatTypingUpdateMessage}.
|
||||
*/
|
||||
export interface ChatTypingMessage {
|
||||
/** The discriminant. */
|
||||
type: "chat_typing";
|
||||
/** Whether the user is composing. */
|
||||
typing: boolean;
|
||||
}
|
||||
|
||||
// Tells the server whether this client currently has the chatbox open, so that
|
||||
// mention notifications can be skipped for users who are already reading chat.
|
||||
/**
|
||||
* Tells the server whether this client currently has the chatbox open, so that
|
||||
* mention notifications can be skipped for users who are already reading chat.
|
||||
*/
|
||||
export interface ChatFocusMessage {
|
||||
/** The discriminant. */
|
||||
type: "chat_focus";
|
||||
/** Whether the chatbox is open. */
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
/** Edits a chat message: the user's own, or any with `chat:moderate`. */
|
||||
export interface ChatEditMessage {
|
||||
/** The discriminant. */
|
||||
type: "chat_edit";
|
||||
/** The message's id. */
|
||||
id: string;
|
||||
/** The new text, in Markdown. */
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** Deletes a chat message: the user's own, or any with `chat:moderate`. */
|
||||
export interface ChatDeleteMessage {
|
||||
/** The discriminant. */
|
||||
type: "chat_delete";
|
||||
/** The message's id. */
|
||||
id: string;
|
||||
}
|
||||
|
||||
// The user has seen the chat up to the message created at `at`.
|
||||
/** The user has seen the chat up to the message created at `at`. */
|
||||
export interface ChatReadMessage {
|
||||
/** The discriminant. */
|
||||
type: "chat_read";
|
||||
/** The creation time of the last message seen, as an ISO string. */
|
||||
at: string;
|
||||
}
|
||||
|
||||
/** Any message a client sends. */
|
||||
export type ClientToServerMessage =
|
||||
| PingMessage
|
||||
| PongMessage
|
||||
@@ -98,93 +140,156 @@ export type ClientToServerMessage =
|
||||
|
||||
// ── Server → Client ──────────────────────────────────────────────────────────
|
||||
|
||||
/** A signed-in user with at least one socket open. */
|
||||
export interface OnlineUser {
|
||||
/** The user's id. */
|
||||
userId: string;
|
||||
/** The user's name. */
|
||||
username: string;
|
||||
/** Whether the user has an avatar. */
|
||||
hasAvatar: boolean;
|
||||
/** When the avatar last changed, to bust the client's cache. */
|
||||
avatarVersion?: number;
|
||||
}
|
||||
|
||||
/** The first message on a new socket: the state the client starts from. */
|
||||
export interface WelcomeMessage {
|
||||
/** The discriminant. */
|
||||
type: "welcome";
|
||||
/** Who is online. */
|
||||
users: OnlineUser[];
|
||||
/** The ids of the dumps the user upvoted; empty when signed out. */
|
||||
myVotes: string[];
|
||||
/** The ids of the comments the user likes; empty when signed out. */
|
||||
myCommentLikes: string[];
|
||||
/** How many notifications the user hasn't read. */
|
||||
unreadNotificationCount: number;
|
||||
/** The user's chat read marker and the unread count behind it (signed-in
|
||||
* users only). */
|
||||
/**
|
||||
* The user's chat read marker (signed-in users only), as an ISO string.
|
||||
*/
|
||||
chatReadAt?: string;
|
||||
/** How many chat messages came after the read marker. */
|
||||
unreadChatCount: number;
|
||||
}
|
||||
|
||||
/** Who is online, sent to everyone whenever it changes. */
|
||||
export interface PresenceUpdateMessage {
|
||||
/** The discriminant. */
|
||||
type: "presence_update";
|
||||
/** Who is online. */
|
||||
users: OnlineUser[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A dump's vote count changed; sent to everyone, or only to the poster for a
|
||||
* private dump.
|
||||
*/
|
||||
export interface VotesUpdateMessage {
|
||||
/** The discriminant. */
|
||||
type: "votes_update";
|
||||
/** The dump's id. */
|
||||
dumpId: string;
|
||||
/** Its new vote count. */
|
||||
voteCount: number;
|
||||
/** Who voted. */
|
||||
voterId: string;
|
||||
/** Whether the vote was cast or withdrawn. */
|
||||
action: "cast" | "remove";
|
||||
}
|
||||
|
||||
/** Confirms the sender's own vote, with the count it led to. */
|
||||
export interface VoteAckMessage {
|
||||
/** The discriminant. */
|
||||
type: "vote_ack";
|
||||
/** The dump's id. */
|
||||
dumpId: string;
|
||||
/** Whether the vote was cast or withdrawn. */
|
||||
action: "cast" | "remove";
|
||||
/** The dump's new vote count. */
|
||||
voteCount: number;
|
||||
}
|
||||
|
||||
/** A comment's like count changed. */
|
||||
export interface CommentLikesUpdateMessage {
|
||||
/** The discriminant. */
|
||||
type: "comment_likes_update";
|
||||
/** The comment's id. */
|
||||
commentId: string;
|
||||
/** Its new like count. */
|
||||
likeCount: number;
|
||||
/** Who liked it. */
|
||||
likerId: string;
|
||||
/** Whether the like was cast or withdrawn. */
|
||||
action: "cast" | "remove";
|
||||
}
|
||||
|
||||
/** Confirms the sender's own like, with the count it led to. */
|
||||
export interface CommentLikeAckMessage {
|
||||
/** The discriminant. */
|
||||
type: "comment_like_ack";
|
||||
/** The comment's id. */
|
||||
commentId: string;
|
||||
/** Whether the like was cast or withdrawn. */
|
||||
action: "cast" | "remove";
|
||||
/** The comment's new like count. */
|
||||
likeCount: number;
|
||||
}
|
||||
|
||||
/** A dump was posted. */
|
||||
export interface DumpCreatedMessage {
|
||||
/** The discriminant. */
|
||||
type: "dump_created";
|
||||
/** The new dump. */
|
||||
dump: Dump;
|
||||
}
|
||||
|
||||
/** A dump changed. */
|
||||
export interface DumpUpdatedMessage {
|
||||
/** The discriminant. */
|
||||
type: "dump_updated";
|
||||
/** The dump as it is now. */
|
||||
dump: Dump;
|
||||
}
|
||||
|
||||
/** A dump was deleted. */
|
||||
export interface DumpDeletedMessage {
|
||||
/** The discriminant. */
|
||||
type: "dump_deleted";
|
||||
/** The deleted dump's id. */
|
||||
dumpId: string;
|
||||
}
|
||||
|
||||
/** A playlist was created; sent to whoever may see it. */
|
||||
export interface PlaylistCreatedMessage {
|
||||
/** The discriminant. */
|
||||
type: "playlist_created";
|
||||
/** The new playlist. */
|
||||
playlist: Playlist;
|
||||
}
|
||||
|
||||
/** A playlist changed; sent to whoever may see it, or could before. */
|
||||
export interface PlaylistUpdatedMessage {
|
||||
/** The discriminant. */
|
||||
type: "playlist_updated";
|
||||
/** The playlist as it is now. */
|
||||
playlist: Playlist;
|
||||
}
|
||||
|
||||
/** A playlist was deleted; sent to whoever could see it. */
|
||||
export interface PlaylistDeletedMessage {
|
||||
/** The discriminant. */
|
||||
type: "playlist_deleted";
|
||||
/** The deleted playlist's id. */
|
||||
playlistId: string;
|
||||
/** Its owner's id. */
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/** A playlist's dumps changed; each recipient gets the ones they may see. */
|
||||
export interface PlaylistDumpsUpdatedMessage {
|
||||
/** The discriminant. */
|
||||
type: "playlist_dumps_updated";
|
||||
/** The playlist's id. */
|
||||
playlistId: string;
|
||||
/** The members this recipient can see, in order. */
|
||||
dumpIds: string[];
|
||||
@@ -192,39 +297,64 @@ export interface PlaylistDumpsUpdatedMessage {
|
||||
dumpCount: number;
|
||||
}
|
||||
|
||||
/** A user's public profile changed. */
|
||||
export interface UserUpdatedMessage {
|
||||
/** The discriminant. */
|
||||
type: "user_updated";
|
||||
/** The user as they are now. */
|
||||
user: PublicUser;
|
||||
}
|
||||
|
||||
/** A comment was posted. */
|
||||
export interface CommentCreatedMessage {
|
||||
/** The discriminant. */
|
||||
type: "comment_created";
|
||||
/** The new comment. */
|
||||
comment: Comment;
|
||||
}
|
||||
|
||||
/** A comment was edited. */
|
||||
export interface CommentUpdatedMessage {
|
||||
/** The discriminant. */
|
||||
type: "comment_updated";
|
||||
/** The comment as it is now. */
|
||||
comment: Comment;
|
||||
}
|
||||
|
||||
/** A comment was deleted. */
|
||||
export interface CommentDeletedMessage {
|
||||
/** The discriminant. */
|
||||
type: "comment_deleted";
|
||||
/** The deleted comment's id. */
|
||||
commentId: string;
|
||||
/** The id of the dump it was under. */
|
||||
dumpId: string;
|
||||
}
|
||||
|
||||
/** A notification for the recipient. */
|
||||
export interface NotificationCreatedMessage {
|
||||
/** The discriminant. */
|
||||
type: "notification_created";
|
||||
/** The notification. */
|
||||
notification: Notification;
|
||||
}
|
||||
|
||||
/** An action the client sent failed. */
|
||||
export interface ErrorMessage {
|
||||
/** The discriminant. */
|
||||
type: "error";
|
||||
/** What went wrong. */
|
||||
message?: string;
|
||||
// Context for optimistic-action failures so the client can revert the exact
|
||||
// pending vote/like immediately instead of waiting for its ACK timeout.
|
||||
/**
|
||||
* The dump of a failed vote. With `commentId` and `action`, it gives the
|
||||
* client the context of an optimistic-action failure, so it can revert the
|
||||
* exact pending vote or like immediately instead of waiting for its ACK
|
||||
* timeout.
|
||||
*/
|
||||
dumpId?: string;
|
||||
/** The comment of a failed like. */
|
||||
commentId?: string;
|
||||
/** The failed vote's or like's action. */
|
||||
action?: "cast" | "remove";
|
||||
/** Set when a chat action failed, so the client can surface it in the chat. */
|
||||
scope?: "chat";
|
||||
@@ -232,41 +362,61 @@ export interface ErrorMessage {
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
/** The session ended — the account was deleted or its token revoked. */
|
||||
export interface ForceLogoutMessage {
|
||||
/** The discriminant. */
|
||||
type: "force_logout";
|
||||
}
|
||||
|
||||
/** A chat message was posted. */
|
||||
export interface ChatMessageMessage {
|
||||
/** The discriminant. */
|
||||
type: "chat_message";
|
||||
/** The new message. */
|
||||
message: ChatMessage;
|
||||
/** The sender's nonce, so its pending copy can be retired. */
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
/** A chat message was edited. */
|
||||
export interface ChatMessageUpdatedMessage {
|
||||
/** The discriminant. */
|
||||
type: "chat_message_updated";
|
||||
/** The message as it is now. */
|
||||
message: ChatMessage;
|
||||
}
|
||||
|
||||
/** A chat message was deleted. */
|
||||
export interface ChatMessageDeletedMessage {
|
||||
/** The discriminant. */
|
||||
type: "chat_message_deleted";
|
||||
/** The deleted message's id. */
|
||||
id: string;
|
||||
}
|
||||
|
||||
// The current set of users composing a chat message. Reuses OnlineUser so the
|
||||
// client can render the same avatars as the presence row.
|
||||
/**
|
||||
* The current set of users composing a chat message. Reuses
|
||||
* {@link OnlineUser} so the client can render the same avatars as the
|
||||
* presence row.
|
||||
*/
|
||||
export interface ChatTypingUpdateMessage {
|
||||
/** The discriminant. */
|
||||
type: "chat_typing_update";
|
||||
/** Who is composing. */
|
||||
users: OnlineUser[];
|
||||
}
|
||||
|
||||
// The user's read marker moved (from any of their tabs or devices).
|
||||
/** The user's read marker moved (from any of their tabs or devices). */
|
||||
export interface ChatReadUpdateMessage {
|
||||
/** The discriminant. */
|
||||
type: "chat_read_update";
|
||||
/** The new marker, as an ISO string. */
|
||||
readAt: string;
|
||||
/** How many messages came after it. */
|
||||
unread: number;
|
||||
}
|
||||
|
||||
/** Any message the server sends. */
|
||||
export type ServerToClientMessage =
|
||||
| PingMessage
|
||||
| PongMessage
|
||||
|
||||
57
src/App.tsx
57
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 <Navigate to={`/~/${useResolvedDefaultTab()}${search}`} replace />;
|
||||
}
|
||||
|
||||
// Bare `/<slug>` lands on that category's default feed tab.
|
||||
/** Redirects a bare `/<slug>` 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 <Index key={categorySlug ?? "~"} categorySlug={categorySlug} />;
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
|
||||
Binary file not shown.
@@ -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<string>;
|
||||
/** 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<string>;
|
||||
onToggle: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
},
|
||||
) {
|
||||
{ selected, onToggle, disabled }: CategorySelectProps,
|
||||
): React.JSX.Element | null {
|
||||
const { categories } = useCategories();
|
||||
if (categories.length === 0) return null;
|
||||
|
||||
|
||||
@@ -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 <select>) keeps it on-theme; the menu uses
|
||||
* fixed positioning so the row's `overflow-x: auto` can't clip it. `~` is the
|
||||
* sentinel value for the all-categories feed. Hidden when no categories exist.
|
||||
*/
|
||||
export function CategorySwitcher() {
|
||||
export function CategorySwitcher(): React.JSX.Element | null {
|
||||
const { categories } = useCategories();
|
||||
const navigate = useNavigate();
|
||||
const { categorySlug, feedTab } = useParams();
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
/**
|
||||
* The inline editor of a chat message.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
|
||||
import { COUNT_FROM, sendable } from "../../utils/chat/message.ts";
|
||||
import { VALIDATION } from "../../../shared/validation.ts";
|
||||
|
||||
/** The props of {@link ChatEditBox}. */
|
||||
export interface ChatEditBoxProps {
|
||||
/** The message's current text. */
|
||||
initial: string;
|
||||
/** Called with the trimmed new text. */
|
||||
onSave: (body: string) => void;
|
||||
/** Called to leave without saving. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ChatEditBox({ initial, onSave, onCancel }: ChatEditBoxProps) {
|
||||
/**
|
||||
* Renders an editor over a chat message's text, with Save and Cancel; it takes
|
||||
* focus and scrolls itself into view once its box has settled.
|
||||
*/
|
||||
export function ChatEditBox(
|
||||
{ initial, onSave, onCancel }: ChatEditBoxProps,
|
||||
): React.JSX.Element {
|
||||
const [draft, setDraft] = useState(initial);
|
||||
const editorRef = useRef<TextEditorHandle>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* A message in the chat.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -14,25 +20,42 @@ import { ReplyRef } from "./ReplyRef.tsx";
|
||||
import { messageTextClass } from "../../utils/chat/message.ts";
|
||||
import { clockTime } from "../../utils/chat/time.ts";
|
||||
|
||||
/** The props of {@link ChatMessageItem}. */
|
||||
export interface ChatMessageItemProps {
|
||||
/** The message. */
|
||||
message: ChatMessage;
|
||||
/** Continues the previous message's run: no avatar or author line. */
|
||||
grouped: boolean;
|
||||
/** Whether it @mentions the user, which highlights it. */
|
||||
mentionsMe: boolean;
|
||||
/** The replied-to message has been deleted since this one loaded. */
|
||||
replyDeleted: boolean;
|
||||
/** Whether the user may edit it. */
|
||||
canEdit: boolean;
|
||||
/** Whether the user may delete it. */
|
||||
canDelete: boolean;
|
||||
/** Whether it is flashed, having just been jumped to. */
|
||||
flashed: boolean;
|
||||
/** Whether it is being edited. */
|
||||
editing: boolean;
|
||||
/** Starts editing the message `id`. */
|
||||
onStartEdit: (id: string) => void;
|
||||
/** Ends the editing. */
|
||||
onEndEdit: () => void;
|
||||
/** Saves `body` as the text of message `id`. */
|
||||
onEdit: (id: string, body: string) => void;
|
||||
/** Deletes message `id`. */
|
||||
onDelete: (id: string) => void;
|
||||
/** Replies to `message`. */
|
||||
onReply: (message: ChatMessage) => void;
|
||||
/** Scrolls to message `id`, for the reply reference. */
|
||||
onJumpTo: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a chat message: its author and time (unless grouped), the reply it
|
||||
* answers, its text or its editor, and its reply, edit and delete actions.
|
||||
*/
|
||||
export function ChatMessageItem(
|
||||
{
|
||||
message,
|
||||
@@ -50,7 +73,7 @@ export function ChatMessageItem(
|
||||
onReply,
|
||||
onJumpTo,
|
||||
}: ChatMessageItemProps,
|
||||
) {
|
||||
): React.JSX.Element {
|
||||
const bodyRef = useHeightTransition<HTMLDivElement>(editing);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The live chat, in a modal.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
useCallback,
|
||||
@@ -31,28 +37,44 @@ import { COUNT_FROM, sendable, snippet } from "../../utils/chat/message.ts";
|
||||
import { dayLabel, isSameDay } from "../../utils/chat/time.ts";
|
||||
import { VALIDATION } from "../../../shared/validation.ts";
|
||||
|
||||
// Matches the server's default page size; a full page means there may be more.
|
||||
/**
|
||||
* Matches the server's default page size; a full page means there may be more.
|
||||
*/
|
||||
const PAGE_SIZE = 50;
|
||||
// Resync after a reconnect walks forward from the oldest loaded message in
|
||||
// pages of this size; past the cap it gives up and reloads the latest page.
|
||||
/**
|
||||
* Resync after a reconnect walks forward from the oldest loaded message in
|
||||
* pages of this size; past the cap it gives up and reloads the latest page.
|
||||
*/
|
||||
const RESYNC_PAGE = 100;
|
||||
/** How many pages a resync walks at most. */
|
||||
const RESYNC_MAX_PAGES = 5;
|
||||
// Consecutive messages from one author within this window share a header.
|
||||
/** Consecutive messages from one author within this window share a header. */
|
||||
const GROUP_WINDOW = 5 * 60_000;
|
||||
// Relative timestamps ("2 minutes ago") are refreshed this often.
|
||||
/** Relative timestamps ("2 minutes ago") are refreshed this often. */
|
||||
const TICK = 30_000;
|
||||
// How long a failed edit/delete notice stays up.
|
||||
/** How long a failed edit/delete notice stays up. */
|
||||
const ERROR_TTL = 6_000;
|
||||
// How close to the bottom still counts as "at the bottom" (px).
|
||||
/** How close to the bottom still counts as "at the bottom" (px). */
|
||||
const BOTTOM_SLACK = 80;
|
||||
// How long the composer stays "typing" after the last keystroke, and how often
|
||||
// we refresh that signal while typing continuously. The refresh interval must
|
||||
// stay below the server's TYPING_TTL so a steady typist never flickers out.
|
||||
/**
|
||||
* How long the composer stays "typing" after the last keystroke. The server
|
||||
* drops a typer it hasn't heard from in its TYPING_TTL.
|
||||
*/
|
||||
const TYPING_STOP_DELAY = 3_500;
|
||||
/**
|
||||
* How often the "typing" signal is refreshed while typing continuously; below
|
||||
* the server's TYPING_TTL, so a steady typist never flickers out.
|
||||
*/
|
||||
const TYPING_REFRESH = 3_000;
|
||||
|
||||
/** The session's `authFetch`. */
|
||||
type AuthFetch = (input: RequestInfo, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* Returns the chat messages `query` selects from `GET /api/chat`.
|
||||
*
|
||||
* @throws {Error} When the API answers a failure.
|
||||
*/
|
||||
async function fetchChat(
|
||||
authFetch: AuthFetch,
|
||||
query: string,
|
||||
@@ -63,12 +85,19 @@ async function fetchChat(
|
||||
return (json.data as Wire<ChatMessage>[]).map(deserializeChatMessage);
|
||||
}
|
||||
|
||||
// Stream order, matching the server's: by time, then id to break ties.
|
||||
/**
|
||||
* Orders two messages as the stream does, matching the server's: by time, then
|
||||
* id to break ties.
|
||||
*/
|
||||
function compareMessages(a: ChatMessage, b: ChatMessage): number {
|
||||
return a.createdAt.getTime() - b.createdAt.getTime() ||
|
||||
(a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of the message list: a day separator, the "new" divider, a message, or
|
||||
* a pending message.
|
||||
*/
|
||||
type Row =
|
||||
| { kind: "sep"; key: string; label: string }
|
||||
| { kind: "new"; key: string }
|
||||
@@ -80,7 +109,23 @@ type Row =
|
||||
grouped: boolean;
|
||||
};
|
||||
|
||||
export function ChatModal({ onClose }: { onClose: () => void }) {
|
||||
/** The props of {@link ChatModal}. */
|
||||
export interface ChatModalProps {
|
||||
/** Called to close the chat. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the live chat in a modal: the history, loaded a page at a time as it
|
||||
* is scrolled up and resynced after a reconnect, merged with the live stream
|
||||
* and the messages still on their way, grouped by author and split by day, a
|
||||
* "new" divider at the read marker, who is typing, and the composer, which
|
||||
* keeps its draft and reply target. The read marker follows what is scrolled
|
||||
* into view.
|
||||
*/
|
||||
export function ChatModal(
|
||||
{ onClose }: ChatModalProps,
|
||||
): React.JSX.Element {
|
||||
const { wsStatus, connectionEpoch } = useWS();
|
||||
const {
|
||||
chatMessages,
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* A chat message on its way.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Markdown } from "../editor/Markdown.tsx";
|
||||
import { Avatar } from "../shared/Avatar.tsx";
|
||||
@@ -6,22 +12,32 @@ import type { PublicUser } from "../../../shared/user.ts";
|
||||
import { ReplyRef } from "./ReplyRef.tsx";
|
||||
import { messageTextClass } from "../../utils/chat/message.ts";
|
||||
|
||||
/** The props of {@link PendingItem}. */
|
||||
export interface PendingItemProps {
|
||||
/** The message. */
|
||||
pending: PendingChatMessage;
|
||||
/** Continues the previous message's run: no avatar or author line. */
|
||||
grouped: boolean;
|
||||
/** The sender: the user. */
|
||||
user: PublicUser;
|
||||
/** Resends message `nonce`. */
|
||||
onRetry: (nonce: string) => void;
|
||||
/** Drops message `nonce`. */
|
||||
onDiscard: (nonce: string) => void;
|
||||
/** Takes the message back into the composer. */
|
||||
onRestore: (pending: PendingChatMessage) => void;
|
||||
/** Scrolls to message `id`, for the reply reference. */
|
||||
onJumpTo: (id: string) => void;
|
||||
}
|
||||
|
||||
// A message on its way: dimmed while sending; if it fails, it says why and
|
||||
// offers to try again, take it back into the composer, or drop it.
|
||||
/**
|
||||
* Renders a message on its way: dimmed while sending; if it fails, it says why
|
||||
* and offers to try again, take it back into the composer, or drop it.
|
||||
*/
|
||||
export function PendingItem(
|
||||
{ pending, grouped, user, onRetry, onDiscard, onRestore, onJumpTo }:
|
||||
PendingItemProps,
|
||||
) {
|
||||
): React.JSX.Element {
|
||||
const failed = pending.status === "failed";
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
/**
|
||||
* A chat message's reference to the message it replies to.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { snippet } from "../../utils/chat/message.ts";
|
||||
|
||||
/** The props of {@link ReplyRef}. */
|
||||
export interface ReplyRefProps {
|
||||
/** The replied-to message's id. */
|
||||
replyToId: string;
|
||||
/** Its author; undefined when it was deleted. */
|
||||
author?: string;
|
||||
/** Its text. */
|
||||
body?: string;
|
||||
/** Scrolls to message `id`. */
|
||||
onJumpTo: (id: string) => void;
|
||||
}
|
||||
|
||||
// Subtle one-line reference to the replied-to message; clicking jumps to it.
|
||||
// Falls back to a muted note when the target was deleted.
|
||||
export function ReplyRef({ replyToId, author, body, onJumpTo }: ReplyRefProps) {
|
||||
/**
|
||||
* Renders a subtle one-line reference to the replied-to message; clicking jumps
|
||||
* to it. Falls back to a muted note when the target was deleted.
|
||||
*/
|
||||
export function ReplyRef(
|
||||
{ replyToId, author, body, onJumpTo }: ReplyRefProps,
|
||||
): React.JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import React from "react";
|
||||
/**
|
||||
* The comment editor, for posting, replying and editing.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Controller, FormProvider } from "react-hook-form";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { VALIDATION } from "../../../shared/validation.ts";
|
||||
@@ -7,26 +12,36 @@ import { FormError } from "../form/FormError.tsx";
|
||||
import { SubmitButton } from "../form/SubmitButton.tsx";
|
||||
import { useApiForm } from "../../hooks/form/useApiForm.ts";
|
||||
|
||||
/** The props of {@link CommentForm}. */
|
||||
export interface CommentFormProps {
|
||||
/** Async submit; throw to surface a root error, resolve to clear/close. */
|
||||
onSubmit: (body: string) => Promise<void>;
|
||||
/** The submit button's label. */
|
||||
submitLabel: React.ReactNode;
|
||||
/** The submit button's label while submitting. */
|
||||
pendingLabel: React.ReactNode;
|
||||
/** The title of the error shown when the submit fails. */
|
||||
errorTitle: string;
|
||||
/** The editor's placeholder. */
|
||||
placeholder?: string;
|
||||
/** The initial text; empty by default. */
|
||||
defaultValue?: string;
|
||||
/** Called by the Cancel button; no Cancel button without it. */
|
||||
onCancel?: () => void;
|
||||
/** Top-level form: only show Cancel once there's text to discard. */
|
||||
cancelWhenEmpty?: boolean;
|
||||
/** Forwarded to the editor so callers can focus it. */
|
||||
editorRef?: React.Ref<TextEditorHandle>;
|
||||
/** `top` renders the avatar-flanked layout; `inline` is for reply/edit. */
|
||||
layout?: "inline" | "top";
|
||||
/** Shown before the editor in the `top` layout (the user's avatar). */
|
||||
leading?: React.ReactNode;
|
||||
/** An extra class for the form. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-field comment editor (post / reply / edit). Wraps the shared
|
||||
* Renders a single-field comment editor (post / reply / edit). Wraps the shared
|
||||
* react-hook-form lifecycle so the three call sites stay identical.
|
||||
*/
|
||||
export function CommentForm({
|
||||
@@ -42,7 +57,7 @@ export function CommentForm({
|
||||
layout = "inline",
|
||||
leading,
|
||||
className,
|
||||
}: CommentFormProps) {
|
||||
}: CommentFormProps): React.JSX.Element {
|
||||
const form = useApiForm<{ body: string }>({
|
||||
defaultValues: { body: defaultValue },
|
||||
});
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
/**
|
||||
* A comment in the thread, with its replies.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -25,18 +31,32 @@ import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { expectOk } from "../../utils/shared/api.ts";
|
||||
import { CommentForm } from "./CommentForm.tsx";
|
||||
|
||||
interface CommentNodeProps {
|
||||
/** The props of {@link CommentNode}. */
|
||||
export interface CommentNodeProps {
|
||||
/** The comment. */
|
||||
comment: Comment;
|
||||
/** Every comment's replies, by parent id. */
|
||||
tree: Map<string, Comment[]>;
|
||||
/** How deep it is nested. */
|
||||
depth: number;
|
||||
/** The id of the dump it is under. */
|
||||
dumpId: string;
|
||||
/** The signed-in user, null when signed out. */
|
||||
currentUser: SelfUser | null;
|
||||
/** The session token. */
|
||||
token: string | null;
|
||||
/** Called with a reply posted. */
|
||||
onCommentCreated: (comment: Comment) => void;
|
||||
/** Called with the id of a comment deleted. */
|
||||
onCommentDeleted: (commentId: string) => void;
|
||||
/** Called with a comment edited. */
|
||||
onCommentUpdated: (comment: Comment) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a comment — its author, time, text and like button, with reply, edit
|
||||
* and delete for whoever may — and under it its replies, recursively.
|
||||
*/
|
||||
export function CommentNode({
|
||||
comment,
|
||||
tree,
|
||||
@@ -47,7 +67,7 @@ export function CommentNode({
|
||||
onCommentCreated,
|
||||
onCommentDeleted,
|
||||
onCommentUpdated,
|
||||
}: CommentNodeProps) {
|
||||
}: CommentNodeProps): React.JSX.Element {
|
||||
const [replyOpen, setReplyOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* A dump's comment thread.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
@@ -12,16 +18,28 @@ import { expectOk } from "../../utils/shared/api.ts";
|
||||
import { CommentForm } from "./CommentForm.tsx";
|
||||
import { CommentNode } from "./CommentNode.tsx";
|
||||
|
||||
interface CommentThreadProps {
|
||||
/** The props of {@link CommentThread}. */
|
||||
export interface CommentThreadProps {
|
||||
/** The dump's id. */
|
||||
dumpId: string;
|
||||
/** Every comment on the dump. */
|
||||
comments: Comment[];
|
||||
/** The signed-in user, null when signed out. */
|
||||
currentUser: SelfUser | null;
|
||||
/** The session token. */
|
||||
token: string | null;
|
||||
/** Called with a comment posted. */
|
||||
onCommentCreated: (comment: Comment) => void;
|
||||
/** Called with the id of a comment deleted. */
|
||||
onCommentDeleted: (commentId: string) => void;
|
||||
/** Called with a comment edited. */
|
||||
onCommentUpdated: (comment: Comment) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the replies of each comment of `comments`, by parent id (`root` for
|
||||
* the top level).
|
||||
*/
|
||||
function buildTree(comments: Comment[]): Map<string, Comment[]> {
|
||||
const map = new Map<string, Comment[]>();
|
||||
for (const c of comments) {
|
||||
@@ -32,6 +50,10 @@ function buildTree(comments: Comment[]): Map<string, Comment[]> {
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the form that posts a comment (for a signed-in user), and the thread
|
||||
* under it.
|
||||
*/
|
||||
export function CommentThread({
|
||||
dumpId,
|
||||
comments,
|
||||
@@ -40,7 +62,7 @@ export function CommentThread({
|
||||
onCommentCreated,
|
||||
onCommentDeleted,
|
||||
onCommentUpdated,
|
||||
}: CommentThreadProps) {
|
||||
}: CommentThreadProps): React.JSX.Element {
|
||||
const tree = useMemo(() => buildTree(comments), [comments]);
|
||||
const roots = tree.get("root") ?? [];
|
||||
const { authFetch } = useAuth();
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* A dump, as a card in the hot and new feeds.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
@@ -19,20 +25,36 @@ import { VoteButton } from "../social/VoteButton.tsx";
|
||||
import { Markdown } from "../editor/Markdown.tsx";
|
||||
import { Tooltip } from "../shared/Tooltip.tsx";
|
||||
|
||||
interface DumpCardProps {
|
||||
/** The props of {@link DumpCard}. */
|
||||
export interface DumpCardProps {
|
||||
/** The dump. */
|
||||
dump: Dump;
|
||||
/** Its live vote count. */
|
||||
voteCount: number;
|
||||
/** Whether the user upvoted it. */
|
||||
voted: boolean;
|
||||
/** Whether the user may vote (signed in). */
|
||||
canVote: boolean;
|
||||
/** Upvotes dump `id`; `shownCount` is the count on screen. */
|
||||
castVote: (id: string, shownCount?: number) => void;
|
||||
/** Withdraws the vote on dump `id`; `shownCount` is the count on screen. */
|
||||
removeVote: (id: string, shownCount?: number) => void;
|
||||
/** An extra class. */
|
||||
className?: string;
|
||||
/** Whether the viewer posted it: no unread dot. */
|
||||
isOwner?: boolean;
|
||||
}
|
||||
|
||||
// Memoized: a feed re-renders on every live event (a vote anywhere, a new
|
||||
// dump), and a card whose props haven't changed has nothing to redraw.
|
||||
export const DumpCard = memo(function DumpCard(
|
||||
/**
|
||||
* Renders a dump as a card: its preview (playable where it can be), title,
|
||||
* note, categories, poster, age, comment count and vote button, with an unread
|
||||
* dot until it is opened. The card opens the dump. Memoized: a feed re-renders
|
||||
* on every live event (a vote anywhere, a new dump), and a card whose props
|
||||
* haven't changed has nothing to redraw.
|
||||
*/
|
||||
export const DumpCard: React.MemoExoticComponent<
|
||||
(props: DumpCardProps) => React.JSX.Element
|
||||
> = memo(function DumpCard(
|
||||
{ dump, voteCount, voted, canVote, castVote, removeVote, className, isOwner }:
|
||||
DumpCardProps,
|
||||
) {
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* The preview of an uploaded file: an image, a player, a PDF or a download
|
||||
* link.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import type { Dump } from "../../../shared/dump.ts";
|
||||
import { formatBytes } from "../../utils/shared/format.ts";
|
||||
@@ -25,14 +32,18 @@ import {
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
interface FilePreviewProps {
|
||||
/** The props of {@link FilePreview}. */
|
||||
export interface FilePreviewProps {
|
||||
/** The file dump. */
|
||||
dump: Dump;
|
||||
/** Whether to render the small thumbnail of a card. */
|
||||
compact?: boolean;
|
||||
/** Whether audio and video play in the global player rather than inline. */
|
||||
global?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the global-player item for an uploaded file, so every entry point
|
||||
* Returns the global-player item for an uploaded file, so every entry point
|
||||
* (compact cards, the detail page, the waveform) puts the same title, artwork
|
||||
* and subtitle in the player header.
|
||||
*
|
||||
@@ -58,8 +69,11 @@ function filePlayerItem(
|
||||
};
|
||||
}
|
||||
|
||||
// Waveform preview for the dump detail page — routes to global player,
|
||||
// reflects live play state and position from PlayerContext/PlayerTimeContext.
|
||||
/**
|
||||
* Renders the waveform preview for the dump detail page — routes to the global
|
||||
* player, reflects live play state and position from PlayerContext and
|
||||
* PlayerTimeContext.
|
||||
*/
|
||||
function AudioFilePreview(
|
||||
{ fileUrl, mime, dump }: { fileUrl: string; mime: string; dump: Dump },
|
||||
) {
|
||||
@@ -160,6 +174,7 @@ function AudioFilePreview(
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a video's still, or `fallback` when it fails to load. */
|
||||
function VideoThumb({ src, fallback }: { src: string; fallback: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (failed) {
|
||||
@@ -177,6 +192,7 @@ function VideoThumb({ src, fallback }: { src: string; fallback: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the icon for a file of type `mime`. */
|
||||
function mimeIcon(mime: string): string {
|
||||
if (mime.startsWith("video/")) return "🎬";
|
||||
if (mime.startsWith("audio/")) return "🎵";
|
||||
@@ -185,9 +201,14 @@ function mimeIcon(mime: string): string {
|
||||
return "📁";
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an uploaded file by its type: compact, a thumbnail or icon that plays
|
||||
* it; full size, the image, a player (or a play button for the global player),
|
||||
* the PDF, or a download link.
|
||||
*/
|
||||
export function FilePreview(
|
||||
{ dump, compact = false, global: useGlobal = false }: FilePreviewProps,
|
||||
) {
|
||||
): React.JSX.Element {
|
||||
const { current, playing, play, togglePlay } = useContext(PlayerContext);
|
||||
const { mediaToken: token } = useAuth();
|
||||
const fileUrl = dumpFileUrl(dump, token);
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* A dump, as a tile of the journal's mosaic.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
@@ -25,21 +31,34 @@ import {
|
||||
usePlayRichContent,
|
||||
} from "../../hooks/player/usePlayRichContent.ts";
|
||||
|
||||
export type { JournalShape };
|
||||
|
||||
interface JournalCardProps {
|
||||
/** The props of {@link JournalCard}. */
|
||||
export interface JournalCardProps {
|
||||
/** The dump. */
|
||||
dump: Dump;
|
||||
/** The tile's footprint in the mosaic. */
|
||||
shape: JournalShape;
|
||||
/** Its live vote count. */
|
||||
voteCount: number;
|
||||
/** Whether the user upvoted it. */
|
||||
voted: boolean;
|
||||
/** Whether the user may vote (signed in). */
|
||||
canVote: boolean;
|
||||
/** Upvotes dump `id`; `shownCount` is the count on screen. */
|
||||
castVote: (id: string, shownCount?: number) => void;
|
||||
/** Withdraws the vote on dump `id`; `shownCount` is the count on screen. */
|
||||
removeVote: (id: string, shownCount?: number) => void;
|
||||
/** Whether the viewer posted it: no unread dot. */
|
||||
isOwner?: boolean;
|
||||
}
|
||||
|
||||
// Memoized, like DumpCard: live events re-render the feed, not every card.
|
||||
export const JournalCard = memo(function JournalCard(
|
||||
/**
|
||||
* Renders a dump as a mosaic tile — an image card, a pull-quote or a text card,
|
||||
* by its content. A playable card plays; one that can't opens the dump.
|
||||
* Memoized, like DumpCard: live events re-render the feed, not every card.
|
||||
*/
|
||||
export const JournalCard: React.MemoExoticComponent<
|
||||
(props: JournalCardProps) => React.JSX.Element
|
||||
> = memo(function JournalCard(
|
||||
{ dump, shape, voteCount, voted, canVote, castVote, removeVote, isOwner }:
|
||||
JournalCardProps,
|
||||
) {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* A link's preview card.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useContext } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import type { RichContent } from "../../../shared/dump.ts";
|
||||
@@ -8,8 +14,11 @@ import {
|
||||
} from "../../hooks/player/usePlayRichContent.ts";
|
||||
import { Thumbnail } from "../shared/Thumbnail.tsx";
|
||||
|
||||
interface RichContentCardProps {
|
||||
/** The props of {@link RichContentCard}. */
|
||||
export interface RichContentCardProps {
|
||||
/** The link's preview. */
|
||||
richContent: RichContent;
|
||||
/** Whether to render the small thumbnail of a card. */
|
||||
compact?: boolean;
|
||||
/** Overrides richContent.thumbnailUrl with a custom dump thumbnail, if set. */
|
||||
thumbnailOverrideUrl?: string;
|
||||
@@ -17,10 +26,15 @@ interface RichContentCardProps {
|
||||
dumpHref?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a link's preview: its thumbnail (or a placeholder), which plays it in
|
||||
* the global player when it can be played and otherwise opens the link, and
|
||||
* full size its site, title and description.
|
||||
*/
|
||||
export function RichContentCard(
|
||||
{ richContent, compact = false, thumbnailOverrideUrl, dumpHref }:
|
||||
RichContentCardProps,
|
||||
) {
|
||||
): React.JSX.Element {
|
||||
const { current, playing, togglePlay } = useContext(PlayerContext);
|
||||
const { playRichContent, pending } = usePlayRichContent();
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The dump creation modal.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
@@ -54,11 +60,13 @@ import { TitleField } from "./TitleField.tsx";
|
||||
/** Stable empty result, so rendering doesn't churn on a fresh array each pass. */
|
||||
const NO_DUPLICATES: DumpUrlMatch[] = [];
|
||||
|
||||
/** The link's preview, as fetched for the form. */
|
||||
type UrlPreview =
|
||||
| { status: "idle" }
|
||||
| { status: "loading" }
|
||||
| { status: "done"; reached: boolean; richContent: RichContent | null };
|
||||
|
||||
/** The form's fields. */
|
||||
interface CreateValues {
|
||||
url: string;
|
||||
file: File | null;
|
||||
@@ -68,7 +76,7 @@ interface CreateValues {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the pointer is coarse — a touch device, where autofocusing the URL
|
||||
* Checks whether the pointer is coarse — a touch device, where autofocusing the URL
|
||||
* input raises a keyboard that covers most of the modal before the user has
|
||||
* seen it.
|
||||
*/
|
||||
@@ -76,14 +84,27 @@ function prefersNoAutoFocus(): boolean {
|
||||
return globalThis.matchMedia?.("(pointer: coarse)").matches ?? false;
|
||||
}
|
||||
|
||||
interface DumpCreateModalProps {
|
||||
/** The props of {@link DumpCreateModal}. */
|
||||
export interface DumpCreateModalProps {
|
||||
/** Called to close it. */
|
||||
onClose: () => void;
|
||||
/**
|
||||
* A link to start from (the Android share sheet); it replaces any saved
|
||||
* draft.
|
||||
*/
|
||||
initialUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the modal that posts a dump, in three panels: the link (previewed as
|
||||
* it is typed, with a hint when it was already dumped) or the file, and the
|
||||
* title; the note, categories and visibility; then, once posted, the playlists
|
||||
* to add it to. Everything but the file is kept as a draft until it is posted,
|
||||
* and closing with unsaved work asks first.
|
||||
*/
|
||||
export function DumpCreateModal(
|
||||
{ onClose, initialUrl = "" }: DumpCreateModalProps,
|
||||
) {
|
||||
): React.JSX.Element {
|
||||
const { authFetch, authUpload } = useAuth();
|
||||
const { injectDump } = useWS();
|
||||
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
/**
|
||||
* The create form's "already dumped" hint.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Link } from "react-router";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import type { DumpUrlMatch } from "../../../../shared/dump.ts";
|
||||
import { dumpUrl } from "../../../utils/shared/urls.ts";
|
||||
import { relativeTime } from "../../../utils/shared/relativeTime.ts";
|
||||
|
||||
/** "Already dumped" hint — never blocking, since reposts are sometimes on purpose. */
|
||||
/** The props of {@link DuplicateNotice}. */
|
||||
export interface DuplicateNoticeProps {
|
||||
/** The dumps already pointing at the link, most recent first. */
|
||||
matches: DumpUrlMatch[];
|
||||
/** Called when a link to one of them is followed. */
|
||||
onNavigate: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the "Already dumped" hint — never blocking, since reposts are
|
||||
* sometimes on purpose.
|
||||
*/
|
||||
export function DuplicateNotice(
|
||||
{ matches, onNavigate }: {
|
||||
matches: DumpUrlMatch[];
|
||||
onNavigate: () => void;
|
||||
},
|
||||
) {
|
||||
{ matches, onNavigate }: DuplicateNoticeProps,
|
||||
): React.JSX.Element {
|
||||
const [first, ...rest] = matches;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
/**
|
||||
* The preview of a picked file, before it is uploaded.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { MediaPlayer } from "../../player/MediaPlayer.tsx";
|
||||
|
||||
/** What a picked file will look like, before it's uploaded. */
|
||||
export function LocalFilePreview({ file }: { file: File }) {
|
||||
/** The props of {@link LocalFilePreview}. */
|
||||
export interface LocalFilePreviewProps {
|
||||
/** The file picked. */
|
||||
file: File;
|
||||
}
|
||||
|
||||
/** Renders what a picked file will look like, before it's uploaded. */
|
||||
export function LocalFilePreview(
|
||||
{ file }: LocalFilePreviewProps,
|
||||
): React.JSX.Element | null {
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
/**
|
||||
* The create modal's steps, and where the user is in them.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
/**
|
||||
* Posting a dump walks three panels. The split is deliberate: categories and
|
||||
* visibility used to sit behind a disclosure, where they read as optional and
|
||||
* went untouched — they are neither, so "why & where" is a stop on the way to
|
||||
* posting rather than something to go looking for.
|
||||
* A panel of the create modal. Posting a dump walks three panels. The split is
|
||||
* deliberate: categories and visibility used to sit behind a disclosure, where
|
||||
* they read as optional and went untouched — they are neither, so "why & where"
|
||||
* is a stop on the way to posting rather than something to go looking for.
|
||||
*/
|
||||
export type Step = "what" | "details" | "playlist";
|
||||
|
||||
/** The panels, in order, with their labels. */
|
||||
const STEPS: { id: Step; label: () => string }[] = [
|
||||
{ id: "what", label: () => t`Link or file` },
|
||||
{ id: "details", label: () => t`Why & where` },
|
||||
{ id: "playlist", label: () => t`Playlists` },
|
||||
];
|
||||
|
||||
/** Where you are in the three panels — and, past the first, how far you got. */
|
||||
export function StepIndicator({ current }: { current: Step }) {
|
||||
/** The props of {@link StepIndicator}. */
|
||||
export interface StepIndicatorProps {
|
||||
/** The panel shown. */
|
||||
current: Step;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders where you are in the three panels — and, past the first, how far you
|
||||
* got.
|
||||
*/
|
||||
export function StepIndicator(
|
||||
{ current }: StepIndicatorProps,
|
||||
): React.JSX.Element {
|
||||
const currentIndex = STEPS.findIndex((s) => s.id === current);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,14 +1,34 @@
|
||||
/**
|
||||
* The create form's title row.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useRef } from "react";
|
||||
import type { UseFormRegisterReturn } from "react-hook-form";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { VALIDATION } from "../../../../shared/validation.ts";
|
||||
|
||||
/** The props of {@link TitleField}. */
|
||||
export interface TitleFieldProps {
|
||||
/** The title field's react-hook-form registration. */
|
||||
registration: UseFormRegisterReturn<"title">;
|
||||
/** Whether the title is being edited. */
|
||||
editing: boolean;
|
||||
/** Starts editing it. */
|
||||
onEdit: () => void;
|
||||
/** Puts the suggestion back; no reset button without it. */
|
||||
onReset?: () => void;
|
||||
/** Whether the row is disabled. */
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Title row shared by both kinds of dump: the suggested title (scraped from the
|
||||
* page, or derived from the filename) sits there read-only until the ✎ is used,
|
||||
* which is enough to stop stray typing while keeping a correction one click
|
||||
* away. Once edited, a reset offers the suggestion back.
|
||||
* Renders the title row shared by both kinds of dump: the suggested title
|
||||
* (scraped from the page, or derived from the filename) sits there read-only
|
||||
* until the ✎ is used, which is enough to stop stray typing while keeping a
|
||||
* correction one click away. Once edited, a reset offers the suggestion back.
|
||||
*/
|
||||
export function TitleField({
|
||||
registration,
|
||||
@@ -16,13 +36,7 @@ export function TitleField({
|
||||
onEdit,
|
||||
onReset,
|
||||
disabled,
|
||||
}: {
|
||||
registration: UseFormRegisterReturn<"title">;
|
||||
editing: boolean;
|
||||
onEdit: () => void;
|
||||
onReset?: () => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
}: TitleFieldProps): React.JSX.Element {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const { ref, ...titleReg } = registration;
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The editor's floating emoji picker.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import {
|
||||
type CSSProperties,
|
||||
type KeyboardEvent,
|
||||
@@ -27,22 +33,35 @@ function SkinTonePersist() {
|
||||
return null;
|
||||
}
|
||||
|
||||
interface EmojiPickerPopupProps {
|
||||
/** The props of {@link EmojiPickerPopup}. */
|
||||
export interface EmojiPickerPopupProps {
|
||||
/** The ref of the floating panel, for outside-click checks. */
|
||||
floatRef: Ref<HTMLDivElement>;
|
||||
/** The ref of the picker's search field, to focus it. */
|
||||
searchRef: Ref<HTMLInputElement>;
|
||||
/** The ref of the picker's scrolling grid. */
|
||||
viewportRef: Ref<HTMLDivElement>;
|
||||
/** Fixed-position placement from `useAnchoredFloat`; see TextEditor. */
|
||||
style: CSSProperties;
|
||||
/** Inline, the query is typed in the textarea and the search only shows it;
|
||||
* browsing, the search field takes it. */
|
||||
inline: boolean;
|
||||
/** What the picker searches for. */
|
||||
query: string;
|
||||
/** Called as the search field is typed in. */
|
||||
onQueryChange: (query: string) => void;
|
||||
/** Called with the emoji picked. */
|
||||
onSelect: (emoji: string) => void;
|
||||
/** Called to close the picker. */
|
||||
onClose: () => void;
|
||||
/** The editor's key handling while focus is in the picker. */
|
||||
onKeyDownCapture: (e: KeyboardEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
/** The floating emoji picker the editor opens from its button or a `:query`. */
|
||||
/**
|
||||
* Renders the floating emoji picker the editor opens from its button or a
|
||||
* `:query`, in the current locale, remembering the skin tone.
|
||||
*/
|
||||
export function EmojiPickerPopup(
|
||||
{
|
||||
floatRef,
|
||||
@@ -56,7 +75,7 @@ export function EmojiPickerPopup(
|
||||
onClose,
|
||||
onKeyDownCapture,
|
||||
}: EmojiPickerPopupProps,
|
||||
) {
|
||||
): React.JSX.Element {
|
||||
const { i18n } = useLingui();
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
/**
|
||||
* Markdown, rendered: GFM, @mentions as profile links, line breaks kept, and
|
||||
* links into the app kept in the app.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Link } from "react-router";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
interface MarkdownProps {
|
||||
/** The props of {@link Markdown}. */
|
||||
export interface MarkdownProps {
|
||||
/** The Markdown source. */
|
||||
children: string;
|
||||
/** An extra class. */
|
||||
className?: string;
|
||||
/** Whether it is a line-clamped teaser inside a card: images are dropped. */
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
// The mention rule the server notifies by (see utils/chatText.ts): an `@` not
|
||||
// preceded by a word character, so an email address isn't a mention.
|
||||
/**
|
||||
* The mention rule the server notifies by (see shared/mention.ts), on parsed
|
||||
* text: an `@` not preceded by a word character, so an email address isn't a
|
||||
* mention. Link syntax is already parsed away here.
|
||||
*/
|
||||
const MENTION_RE = /(?<!\w)@(\w+)/g;
|
||||
|
||||
/** A node of the Markdown syntax tree, the part the plugins read. */
|
||||
interface MdNode {
|
||||
type: string;
|
||||
value?: string;
|
||||
@@ -59,7 +74,7 @@ function remarkMentions() {
|
||||
}
|
||||
|
||||
/**
|
||||
* A single newline is a line break, as it is in chat everywhere: markdown's
|
||||
* Turns a single newline into a line break, as in chat everywhere: markdown's
|
||||
* own rule folds it into a space, so a message typed on three lines with
|
||||
* Shift+Enter came out as one. (Code keeps its newlines; it has no text
|
||||
* nodes.)
|
||||
@@ -82,10 +97,13 @@ function remarkBreaks() {
|
||||
return (tree: MdNode) => walk(tree);
|
||||
}
|
||||
|
||||
/** The remark plugins, in order. */
|
||||
const REMARK_PLUGINS = [remarkGfm, remarkMentions, remarkBreaks];
|
||||
|
||||
/** The path of a link into this app — relative, or absolute to this origin —
|
||||
* or null for anywhere else. */
|
||||
/**
|
||||
* Returns the path of a link into this app — relative, or absolute to this
|
||||
* origin — or null for anywhere else (the API's files included).
|
||||
*/
|
||||
function internalPath(href: string | undefined): string | null {
|
||||
if (!href) return null;
|
||||
if (href.startsWith("/") && !href.startsWith("//")) {
|
||||
@@ -102,9 +120,14 @@ function internalPath(href: string | undefined): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** react-markdown's element overrides. */
|
||||
type Components = React.ComponentProps<typeof ReactMarkdown>["components"];
|
||||
|
||||
// Static components objects — defined once at module scope to avoid recreation on every render
|
||||
/**
|
||||
* The element overrides, defined once at module scope to avoid recreation on
|
||||
* every render. Mentions and links to other pages here stay in the app; the
|
||||
* rest open in a new tab; a wide GFM table scrolls inside its own box.
|
||||
*/
|
||||
const MARKDOWN_COMPONENTS: Components = {
|
||||
// Mentions and links to other pages here stay in the app; the rest open
|
||||
// in a new tab.
|
||||
@@ -128,17 +151,24 @@ const MARKDOWN_COMPONENTS: Components = {
|
||||
),
|
||||
};
|
||||
|
||||
// Inline renderings are line-clamped teasers inside cards, where an embedded
|
||||
// image blows the layout apart. Drop images there — the full description is
|
||||
// one click away on the detail page.
|
||||
/**
|
||||
* The element overrides of an inline rendering. Inline renderings are
|
||||
* line-clamped teasers inside cards, where an embedded image blows the layout
|
||||
* apart; images are dropped there — the full description is one click away on
|
||||
* the detail page.
|
||||
*/
|
||||
const INLINE_MARKDOWN_COMPONENTS: Components = {
|
||||
...MARKDOWN_COMPONENTS,
|
||||
img: () => null,
|
||||
};
|
||||
|
||||
// Memoized: parsing runs the whole remark pipeline, and the text rarely
|
||||
// changes when the component around it re-renders.
|
||||
export const Markdown = memo(function Markdown(
|
||||
/**
|
||||
* Renders Markdown. Memoized: parsing runs the whole remark pipeline, and the
|
||||
* text rarely changes when the component around it re-renders.
|
||||
*/
|
||||
export const Markdown: React.MemoExoticComponent<
|
||||
(props: MarkdownProps) => React.JSX.Element
|
||||
> = memo(function Markdown(
|
||||
{ children, className, inline = false }: MarkdownProps,
|
||||
) {
|
||||
return (
|
||||
|
||||
@@ -1,20 +1,34 @@
|
||||
/**
|
||||
* The editor's @mention suggestions.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { type CSSProperties, useLayoutEffect, useRef } from "react";
|
||||
import { Avatar } from "../shared/Avatar.tsx";
|
||||
import type { UserResult } from "../../hooks/editor/useMentionAutocomplete.ts";
|
||||
import type { UserSuggestion } from "../../../shared/user.ts";
|
||||
|
||||
interface MentionDropdownProps {
|
||||
/** The props of {@link MentionDropdown}. */
|
||||
export interface MentionDropdownProps {
|
||||
/** For the textarea's `aria-controls` / `aria-activedescendant`. */
|
||||
id: string;
|
||||
results: UserResult[];
|
||||
/** The matching users. */
|
||||
results: UserSuggestion[];
|
||||
/** The highlighted match. */
|
||||
selectedIndex: number;
|
||||
/** Called with the name picked. */
|
||||
onSelect: (username: string) => void;
|
||||
/** Fixed-position placement from `useAnchoredFloat`; see TextEditor. */
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the users matching the `@query` as a listbox, keeping the highlighted
|
||||
* one in view; nothing when none match.
|
||||
*/
|
||||
export function MentionDropdown(
|
||||
{ id, results, selectedIndex, onSelect, style }: MentionDropdownProps,
|
||||
) {
|
||||
): React.JSX.Element | null {
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
// Arrowing past the visible rows keeps the selection in view.
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* The site's text editor: a Markdown textarea with @mention and emoji
|
||||
* autocompletes, image upload by paste or drop, and a character counter.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
@@ -20,20 +27,30 @@ import { useAnchoredFloat } from "../../hooks/shared/useAnchoredFloat.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
|
||||
/** What a ref to {@link TextEditor} can do. */
|
||||
export interface TextEditorHandle {
|
||||
/** `atEnd` puts the caret after the last character (and scrolls the text
|
||||
* there); `preventScroll` leaves the page and its scrollers where they are. */
|
||||
focus(options?: { atEnd?: boolean; preventScroll?: boolean }): void;
|
||||
}
|
||||
|
||||
interface TextEditorProps {
|
||||
/** The props of {@link TextEditor}. */
|
||||
export interface TextEditorProps {
|
||||
/** The text. */
|
||||
value: string;
|
||||
/** Called with the new text. */
|
||||
onChange: (value: string) => void;
|
||||
/** The textarea's placeholder. */
|
||||
placeholder?: string;
|
||||
/** Whether the textarea is disabled. */
|
||||
disabled?: boolean;
|
||||
/** The textarea's height, in rows. */
|
||||
rows?: number;
|
||||
/** The textarea's id. */
|
||||
id?: string;
|
||||
/** An extra class. */
|
||||
className?: string;
|
||||
/** Whether the textarea grows with its text. */
|
||||
autoResize?: boolean;
|
||||
/** Ctrl/Cmd+Enter. Not called while an image is still uploading. */
|
||||
onSubmit?: () => void;
|
||||
@@ -44,19 +61,24 @@ interface TextEditorProps {
|
||||
* while an IME is composing, so an Enter that confirms a candidate isn't
|
||||
* taken for "send". */
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
/** The most characters it takes, counted and warned about. */
|
||||
maxLength?: number;
|
||||
/** Show the character counter only from this length. Defaults to 80% of
|
||||
* `maxLength`: a standing "0 / 5000" is just noise, but the counter turns
|
||||
* up well before the warning colour does. */
|
||||
countFrom?: number;
|
||||
/** Marks the textarea invalid. */
|
||||
"aria-invalid"?: boolean;
|
||||
/** The id of what describes it (its error). */
|
||||
"aria-describedby"?: string;
|
||||
}
|
||||
|
||||
/** Checks whether `dt` carries files. */
|
||||
function hasFiles(dt: DataTransfer): boolean {
|
||||
return Array.from(dt.types).includes("Files");
|
||||
}
|
||||
|
||||
/** Text to put in once the textarea can take it. */
|
||||
interface PendingInsert {
|
||||
start: number;
|
||||
end: number;
|
||||
@@ -64,7 +86,16 @@ interface PendingInsert {
|
||||
focus: boolean;
|
||||
}
|
||||
|
||||
export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
/**
|
||||
* Renders the editor: a textarea that autocompletes @mentions and `:emoji`,
|
||||
* opens an emoji picker from its button, uploads pasted or dropped images as
|
||||
* attachments and inserts them as Markdown, and counts characters near the
|
||||
* limit. Both popups are portaled to <body> and anchored to the textarea, so
|
||||
* the cards, modals and scrollers it sits in can't clip them.
|
||||
*/
|
||||
export const TextEditor: React.ForwardRefExoticComponent<
|
||||
TextEditorProps & React.RefAttributes<TextEditorHandle>
|
||||
> = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
function TextEditor(
|
||||
{
|
||||
value,
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The hot and new feeds, and the props every main feed of the index takes.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { type Ref, useMemo } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -7,22 +13,44 @@ import type { Dump } from "../../../shared/dump.ts";
|
||||
import type { SelfUser } from "../../../shared/user.ts";
|
||||
import { hotScore } from "../../utils/feed/hotScore.ts";
|
||||
|
||||
// What the index hands whichever main feed its tab shows.
|
||||
/** What the index hands whichever main feed its tab shows. */
|
||||
export interface MainFeedProps {
|
||||
/** The dumps loaded so far. */
|
||||
dumps: Dump[];
|
||||
/** Whether the first page is loading. */
|
||||
loading: boolean;
|
||||
/** Why loading failed, null when it didn't. */
|
||||
error: string | null;
|
||||
/** Whether another page follows. */
|
||||
hasMore: boolean;
|
||||
/** Whether the next page is loading. */
|
||||
loadingMore: boolean;
|
||||
/**
|
||||
* The ref of the element that loads the next page when scrolled into view.
|
||||
*/
|
||||
sentinelRef: Ref<HTMLDivElement>;
|
||||
/** Live vote counts, by dump id. */
|
||||
voteCounts: Record<string, number>;
|
||||
/** The ids of the dumps the user upvoted. */
|
||||
myVotes: Set<string>;
|
||||
/** The signed-in user, null when signed out. */
|
||||
user: SelfUser | null;
|
||||
/** Upvotes dump `id`; `shownCount` is the count on screen. */
|
||||
castVote: (id: string, shownCount?: number) => void;
|
||||
/** Withdraws the vote on dump `id`; `shownCount` is the count on screen. */
|
||||
removeVote: (id: string, shownCount?: number) => void;
|
||||
}
|
||||
|
||||
// The hot and new tabs: the same list, ordered by hot score or by date.
|
||||
/** The props of {@link DumpFeed}. */
|
||||
export interface DumpFeedProps extends MainFeedProps {
|
||||
/** Whether the list is ordered by hot score or by date. */
|
||||
order: "hot" | "new";
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the hot and new tabs: the same list of dump cards, ordered by hot
|
||||
* score or by date, loading more at its end.
|
||||
*/
|
||||
export function DumpFeed(
|
||||
{
|
||||
order,
|
||||
@@ -37,8 +65,8 @@ export function DumpFeed(
|
||||
user,
|
||||
castVote,
|
||||
removeVote,
|
||||
}: MainFeedProps & { order: "hot" | "new" },
|
||||
) {
|
||||
}: DumpFeedProps,
|
||||
): React.JSX.Element {
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...dumps].sort((a, b) =>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The index's feed tabs.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
@@ -11,11 +17,11 @@ import { TabBar } from "../shared/TabBar.tsx";
|
||||
import { CategorySwitcher } from "../category/CategorySwitcher.tsx";
|
||||
|
||||
/**
|
||||
* Feed sort tabs (hot/new/journal/followed). Navigation stays within the
|
||||
* current scope: `~` for all categories, or the active category slug — so the
|
||||
* tabs link to `/~/new`, `/music/new`, etc.
|
||||
* Renders the feed sort tabs (hot/new/journal/followed). Navigation stays
|
||||
* within the current scope: `~` for all categories, or the active category slug
|
||||
* — so the tabs link to `/~/new`, `/music/new`, etc.
|
||||
*/
|
||||
export function FeedTabBar() {
|
||||
export function FeedTabBar(): React.JSX.Element {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { categorySlug, feedTab } = useParams();
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The followed feed: dumps from the users and the playlists the user follows.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { TabBar } from "../shared/TabBar.tsx";
|
||||
import { t } from "@lingui/core/macro";
|
||||
@@ -19,6 +25,7 @@ import type { PaginatedData, Wire } from "../../../shared/api.ts";
|
||||
import type { SelfUser } from "../../../shared/user.ts";
|
||||
import { friendlyFetchError } from "../../utils/shared/api.ts";
|
||||
|
||||
/** A section's state: loading, failed, or loaded with a page of dumps. */
|
||||
type FeedState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
@@ -30,8 +37,12 @@ type FeedState =
|
||||
loadingMore: boolean;
|
||||
};
|
||||
|
||||
/** Page of a followed feed. Throws the server's own message on an error
|
||||
* envelope, so it isn't mistaken for a lost connection. */
|
||||
/**
|
||||
* Returns a page of a followed feed from `res`.
|
||||
*
|
||||
* @throws {Error} The server's own message on an error envelope, so it isn't
|
||||
* mistaken for a lost connection.
|
||||
*/
|
||||
async function readPage(res: Response): Promise<PaginatedData<Wire<Dump>>> {
|
||||
const body = await res.json();
|
||||
if (!body.success) {
|
||||
@@ -40,20 +51,30 @@ async function readPage(res: Response): Promise<PaginatedData<Wire<Dump>>> {
|
||||
return body.data as PaginatedData<Wire<Dump>>;
|
||||
}
|
||||
|
||||
/** A section of the followed feed. */
|
||||
type FollowedSection = "users" | "playlists";
|
||||
|
||||
interface FollowedFeedProps {
|
||||
/** The props of {@link FollowedFeed}. */
|
||||
export interface FollowedFeedProps {
|
||||
/** The category the feed is scoped to; all when undefined. */
|
||||
categorySlug?: string;
|
||||
/** Live vote counts, by dump id. */
|
||||
voteCounts: Record<string, number>;
|
||||
/** The ids of the dumps the user upvoted. */
|
||||
myVotes: Set<string>;
|
||||
/** The signed-in user. */
|
||||
user: SelfUser;
|
||||
/** Upvotes dump `id`; `shownCount` is the count on screen. */
|
||||
castVote: (id: string, shownCount?: number) => void;
|
||||
/** Withdraws the vote on dump `id`; `shownCount` is the count on screen. */
|
||||
removeVote: (id: string, shownCount?: number) => void;
|
||||
/** Ids of dumps deleted this session, filtered out. */
|
||||
deletedDumpIds: Set<string>;
|
||||
}
|
||||
|
||||
// ── FollowedSubFeed ───────────────────────────────────────────────────────────
|
||||
|
||||
/** The props of {@link FollowedSubFeed}. */
|
||||
interface FollowedSubFeedProps {
|
||||
state: FeedState;
|
||||
voteCounts: Record<string, number>;
|
||||
@@ -66,6 +87,7 @@ interface FollowedSubFeedProps {
|
||||
onLoadMore: () => void;
|
||||
}
|
||||
|
||||
/** Renders a section of the followed feed, loading more on demand. */
|
||||
function FollowedSubFeed({
|
||||
state,
|
||||
voteCounts,
|
||||
@@ -131,6 +153,10 @@ function FollowedSubFeed({
|
||||
|
||||
// ── FollowedFeed ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Renders the followed feed in two sections, from people and from playlists,
|
||||
* each paginated and scoped to `categorySlug`.
|
||||
*/
|
||||
export function FollowedFeed({
|
||||
categorySlug,
|
||||
voteCounts,
|
||||
@@ -139,7 +165,7 @@ export function FollowedFeed({
|
||||
castVote,
|
||||
removeVote,
|
||||
deletedDumpIds,
|
||||
}: FollowedFeedProps) {
|
||||
}: FollowedFeedProps): React.JSX.Element {
|
||||
const { token } = useAuth();
|
||||
|
||||
// Scope key + query suffix applied to every followed-feed request.
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The journal feed: the editorial mosaic.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -6,6 +12,10 @@ import { JournalCard } from "../dump/JournalCard.tsx";
|
||||
import { composeJournal } from "../../utils/feed/journalLayout.ts";
|
||||
import type { MainFeedProps } from "./DumpFeed.tsx";
|
||||
|
||||
/**
|
||||
* Renders the dumps as the journal's mosaic, block by block, loading more at
|
||||
* its end.
|
||||
*/
|
||||
export function JournalFeed(
|
||||
{
|
||||
dumps,
|
||||
@@ -20,7 +30,7 @@ export function JournalFeed(
|
||||
castVote,
|
||||
removeVote,
|
||||
}: MainFeedProps,
|
||||
) {
|
||||
): React.JSX.Element {
|
||||
const blocks = useMemo(() => composeJournal(dumps), [dumps]);
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The row of who's online, in the feed's header.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
@@ -5,25 +11,40 @@ import { Avatar } from "../shared/Avatar.tsx";
|
||||
import { useWSPresence } from "../../hooks/ws/useWS.ts";
|
||||
import { useRoomBeside } from "../../hooks/shared/useRoomBeside.ts";
|
||||
|
||||
// Past this many, the row ends in a "+N" counter rather than growing, so a
|
||||
// busy evening doesn't push the feed tabs out of the header.
|
||||
/**
|
||||
* Past this many, the row ends in a "+N" counter rather than growing, so a busy
|
||||
* evening doesn't push the feed tabs out of the header.
|
||||
*/
|
||||
const MAX_SEATS = 6;
|
||||
// However tight the row, it keeps an avatar and the counter — past that, the
|
||||
// tabs beside it scroll instead.
|
||||
/**
|
||||
* However tight the row, it keeps an avatar and the counter — past that, the
|
||||
* tabs beside it scroll instead.
|
||||
*/
|
||||
const MIN_SEATS = 2;
|
||||
// Each seat (avatar or counter) is 32px wide and overlaps the one before it
|
||||
// by 10px (.index-presence-avatar's negative margin), so adds 22px.
|
||||
/** Each seat (avatar or counter) is 32px wide… */
|
||||
const SEAT = 32;
|
||||
/**
|
||||
* …and overlaps the one before it by 10px (.index-presence-avatar's negative
|
||||
* margin), so adds 22px.
|
||||
*/
|
||||
const SEAT_STEP = 22;
|
||||
|
||||
/** The props of {@link PresenceRow}. */
|
||||
export interface PresenceRowProps {
|
||||
/** An ancestor selector bounding the row it shares. */
|
||||
fitWithin?: string;
|
||||
/** A selector for what comes first in that row: the tabs. */
|
||||
fitBeside?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who's online, as a row of overlapping avatars. Given `fitWithin` and
|
||||
* Renders who's online, as a row of overlapping avatars. Given `fitWithin` and
|
||||
* `fitBeside`, it gives up seats to what's beside it — as many avatars as the
|
||||
* room left by the tabs allows, the rest folded into the counter.
|
||||
*/
|
||||
export function PresenceRow(
|
||||
{ fitWithin, fitBeside }: { fitWithin?: string; fitBeside?: string },
|
||||
) {
|
||||
{ fitWithin, fitBeside }: PresenceRowProps,
|
||||
): React.JSX.Element | null {
|
||||
const { onlineUsers } = useWSPresence();
|
||||
const [el, setEl] = useState<HTMLDivElement | null>(null);
|
||||
const room = useRoomBeside(el, { within: fitWithin, beside: fitBeside });
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
/**
|
||||
* A file drop zone, which is also a file picker.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { formatBytes } from "../../utils/shared/format.ts";
|
||||
|
||||
/** Returns the icon for a file of type `mime`. */
|
||||
function fileIcon(mime: string): string {
|
||||
if (mime.startsWith("image/")) return "🖼";
|
||||
if (mime.startsWith("video/")) return "🎬";
|
||||
@@ -11,21 +18,31 @@ function fileIcon(mime: string): string {
|
||||
return "📎";
|
||||
}
|
||||
|
||||
interface FileDropZoneProps {
|
||||
/** The props of {@link FileDropZone}. */
|
||||
export interface FileDropZoneProps {
|
||||
/** The chosen file, null without one. */
|
||||
file: File | null;
|
||||
/** Called with the file dropped or picked, or null when it is removed. */
|
||||
onChange: (file: File | null) => void;
|
||||
/** Whether it takes no file. */
|
||||
disabled?: boolean;
|
||||
/** The prompt shown while empty; "Drop a file here" by default. */
|
||||
hint?: string;
|
||||
/** Whether to state the size limit; true by default. */
|
||||
showLimit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a zone that takes a dropped file, or opens a file picker when
|
||||
* clicked, and shows the file once chosen, with a button to remove it.
|
||||
*/
|
||||
export function FileDropZone({
|
||||
file,
|
||||
onChange,
|
||||
disabled,
|
||||
hint,
|
||||
showLimit = true,
|
||||
}: FileDropZoneProps) {
|
||||
}: FileDropZoneProps): React.JSX.Element {
|
||||
const resolvedHint = hint ?? t`Drop a file here`;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* A form's file field.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import {
|
||||
Controller,
|
||||
type FieldValues,
|
||||
@@ -8,18 +14,28 @@ import {
|
||||
|
||||
import { FileDropZone } from "./FileDropZone.tsx";
|
||||
|
||||
interface FileFieldProps<T extends FieldValues> {
|
||||
/** The props of {@link FileField}. */
|
||||
export interface FileFieldProps<T extends FieldValues> {
|
||||
/** The field's name in the form. */
|
||||
name: Path<T>;
|
||||
/** The field's label. */
|
||||
label?: string;
|
||||
/** The drop zone's prompt. */
|
||||
hint?: string;
|
||||
/** Whether to state the size limit. */
|
||||
showLimit?: boolean;
|
||||
/** Whether the field takes no file. */
|
||||
disabled?: boolean;
|
||||
/** The field's validation rules. */
|
||||
rules?: RegisterOptions<T, Path<T>>;
|
||||
/** Side effect run after the field value changes (e.g. derive a title). */
|
||||
onValueChange?: (file: File | null) => void;
|
||||
}
|
||||
|
||||
/** `Controller`-wrapped {@link FileDropZone} bound to react-hook-form. */
|
||||
/**
|
||||
* Renders a `Controller`-wrapped {@link FileDropZone} bound to react-hook-form,
|
||||
* with the field's error under it.
|
||||
*/
|
||||
export function FileField<T extends FieldValues>({
|
||||
name,
|
||||
label,
|
||||
@@ -28,7 +44,7 @@ export function FileField<T extends FieldValues>({
|
||||
disabled,
|
||||
rules,
|
||||
onValueChange,
|
||||
}: FileFieldProps<T>) {
|
||||
}: FileFieldProps<T>): React.JSX.Element {
|
||||
const { control, formState: { errors } } = useFormContext<T>();
|
||||
const error = errors[name];
|
||||
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
/**
|
||||
* A form's row of actions.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
interface FormActionsProps {
|
||||
/** The props of {@link FormActions}. */
|
||||
export interface FormActionsProps {
|
||||
/** The submit control(s). */
|
||||
children: ReactNode;
|
||||
/** Called by the Cancel button; no Cancel button without it. */
|
||||
onCancel?: () => void;
|
||||
/** The Cancel button's label; "Cancel" by default. */
|
||||
cancelLabel?: ReactNode;
|
||||
/** Whether the Cancel button is disabled. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-aligned form action row: an optional Cancel button followed by the
|
||||
* submit control(s). Standardizes the `.form-actions > .form-actions-right`
|
||||
* markup duplicated across forms.
|
||||
* Renders a right-aligned form action row: an optional Cancel button followed
|
||||
* by the submit control(s). Standardizes the `.form-actions >
|
||||
* .form-actions-right` markup duplicated across forms.
|
||||
*/
|
||||
export function FormActions({
|
||||
children,
|
||||
onCancel,
|
||||
cancelLabel,
|
||||
disabled,
|
||||
}: FormActionsProps) {
|
||||
}: FormActionsProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="form-actions">
|
||||
<div className="form-actions-right">
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
/**
|
||||
* A form's submit error.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useFormState } from "react-hook-form";
|
||||
|
||||
import { ErrorCard } from "../shared/ErrorCard.tsx";
|
||||
|
||||
/** The props of {@link FormError}. */
|
||||
export interface FormErrorProps {
|
||||
/** The card's title; "Something went wrong" by default. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the form's root error (set by {@link useApiForm}'s `submit` on a
|
||||
* thrown/failed request) through the shared {@link ErrorCard}. One consistent
|
||||
* place for server/submit errors across every form.
|
||||
* Renders the form's root error (set by `useApiForm`'s `submit` on a
|
||||
* thrown/failed request) through the shared {@link ErrorCard}, and nothing
|
||||
* without one. One consistent place for server/submit errors across every form.
|
||||
*/
|
||||
export function FormError({ title }: { title?: string }) {
|
||||
export function FormError(
|
||||
{ title }: FormErrorProps,
|
||||
): React.JSX.Element | null {
|
||||
const { errors } = useFormState();
|
||||
const message = errors.root?.message;
|
||||
if (!message) return null;
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* A form's rich-text field.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useId } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
@@ -10,15 +16,23 @@ import {
|
||||
|
||||
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx";
|
||||
|
||||
interface RichTextFieldProps<T extends FieldValues> {
|
||||
/** The props of {@link RichTextField}. */
|
||||
export interface RichTextFieldProps<T extends FieldValues> {
|
||||
/** The field's name in the form. */
|
||||
name: Path<T>;
|
||||
/** The field's label. */
|
||||
label?: string;
|
||||
/** The editor's placeholder. */
|
||||
placeholder?: string;
|
||||
/** The editor's height, in rows. */
|
||||
rows?: number;
|
||||
/** Counted in the editor and enforced on submit, with a message saying so. */
|
||||
maxLength?: number;
|
||||
/** Whether the editor grows with its text. */
|
||||
autoResize?: boolean;
|
||||
/** Whether the editor is disabled. */
|
||||
disabled?: boolean;
|
||||
/** The field's validation rules. */
|
||||
rules?: RegisterOptions<T, Path<T>>;
|
||||
/** Forwarded to the underlying TextEditor so callers can focus it. */
|
||||
editorRef?: React.Ref<TextEditorHandle>;
|
||||
@@ -27,9 +41,9 @@ interface RichTextFieldProps<T extends FieldValues> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rich-text field: a `Controller`-wrapped {@link TextEditor} (mentions, emoji,
|
||||
* image upload, char count) bound to react-hook-form via the surrounding
|
||||
* `FormProvider`.
|
||||
* Renders a rich-text field: a `Controller`-wrapped {@link TextEditor}
|
||||
* (mentions, emoji, image upload, char count) bound to react-hook-form via the
|
||||
* surrounding `FormProvider`, with the field's error under it.
|
||||
*/
|
||||
export function RichTextField<T extends FieldValues>({
|
||||
name,
|
||||
@@ -42,7 +56,7 @@ export function RichTextField<T extends FieldValues>({
|
||||
rules,
|
||||
editorRef,
|
||||
onSubmit,
|
||||
}: RichTextFieldProps<T>) {
|
||||
}: RichTextFieldProps<T>): React.JSX.Element {
|
||||
const id = useId();
|
||||
const hintId = useId();
|
||||
const { control, formState: { errors } } = useFormContext<T>();
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* A form's segmented control, and the public/private toggle built on it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
@@ -7,28 +13,37 @@ import {
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
|
||||
interface SegmentedOption<V> {
|
||||
/** A choice of a {@link SegmentedField}. */
|
||||
export interface SegmentedOption<V> {
|
||||
/** The value it sets. */
|
||||
value: V;
|
||||
/** What it shows. */
|
||||
label: ReactNode;
|
||||
}
|
||||
|
||||
interface SegmentedFieldProps<T extends FieldValues, V> {
|
||||
/** The props of {@link SegmentedField}. */
|
||||
export interface SegmentedFieldProps<T extends FieldValues, V> {
|
||||
/** The field's name in the form. */
|
||||
name: Path<T>;
|
||||
/** The choices, in order. */
|
||||
options: SegmentedOption<V>[];
|
||||
/** The field's label. */
|
||||
label?: string;
|
||||
/** Whether the control is disabled. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Segmented control (the `.visibility-toggle` style) bound to react-hook-form.
|
||||
* Generic over the field value, so it works for booleans, string unions, etc.
|
||||
* Renders a segmented control (the `.visibility-toggle` style) bound to
|
||||
* react-hook-form. Generic over the field value, so it works for booleans,
|
||||
* string unions, etc.
|
||||
*/
|
||||
export function SegmentedField<T extends FieldValues, V>({
|
||||
name,
|
||||
options,
|
||||
label,
|
||||
disabled,
|
||||
}: SegmentedFieldProps<T, V>) {
|
||||
}: SegmentedFieldProps<T, V>): React.JSX.Element {
|
||||
const { control } = useFormContext<T>();
|
||||
|
||||
return (
|
||||
@@ -57,20 +72,26 @@ export function SegmentedField<T extends FieldValues, V>({
|
||||
);
|
||||
}
|
||||
|
||||
/** The props of {@link VisibilityToggle}. */
|
||||
export interface VisibilityToggleProps<T extends FieldValues> {
|
||||
/** The boolean field's name in the form. */
|
||||
name: Path<T>;
|
||||
/** The field's label. */
|
||||
label?: string;
|
||||
/** Whether the toggle is disabled. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public/Private toggle on a boolean field (default `isPublic`). Forms that
|
||||
* persist `isPrivate` should still bind this to an `isPublic` field and invert
|
||||
* at submit time.
|
||||
* Renders a Public/Private toggle on a boolean field (default `isPublic`).
|
||||
* Forms that persist `isPrivate` should still bind this to an `isPublic` field
|
||||
* and invert at submit time.
|
||||
*/
|
||||
export function VisibilityToggle<T extends FieldValues>({
|
||||
name,
|
||||
label,
|
||||
disabled,
|
||||
}: {
|
||||
name: Path<T>;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
}: VisibilityToggleProps<T>): React.JSX.Element {
|
||||
return (
|
||||
<SegmentedField<T, boolean>
|
||||
name={name}
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
/**
|
||||
* A form's submit button.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useFormState } from "react-hook-form";
|
||||
|
||||
interface SubmitButtonProps {
|
||||
/** The props of {@link SubmitButton}. */
|
||||
export interface SubmitButtonProps {
|
||||
/** The button's label. */
|
||||
children: ReactNode;
|
||||
/** Replaces the label while the form is submitting (e.g. "Saving…"). */
|
||||
pendingLabel?: ReactNode;
|
||||
/** Whether it is disabled, besides while submitting. */
|
||||
disabled?: boolean;
|
||||
/** Its class; `btn-primary` by default. */
|
||||
className?: string;
|
||||
/** For an icon-only button (e.g. "✓"), its accessible name. */
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit button that reflects react-hook-form's `isSubmitting` from the
|
||||
* surrounding `FormProvider`: it disables itself while submitting and swaps in
|
||||
* `pendingLabel`. Standardizes the hand-rolled "Save → Saving…" pattern.
|
||||
* Renders a submit button that reflects react-hook-form's `isSubmitting` from
|
||||
* the surrounding `FormProvider`: it disables itself while submitting and swaps
|
||||
* in `pendingLabel`. Standardizes the hand-rolled "Save → Saving…" pattern.
|
||||
*/
|
||||
export function SubmitButton({
|
||||
children,
|
||||
@@ -22,7 +32,7 @@ export function SubmitButton({
|
||||
disabled,
|
||||
className = "btn-primary",
|
||||
"aria-label": ariaLabel,
|
||||
}: SubmitButtonProps) {
|
||||
}: SubmitButtonProps): React.JSX.Element {
|
||||
const { isSubmitting } = useFormState();
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* A form's text field.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { type ReactNode, useId } from "react";
|
||||
import {
|
||||
type FieldValues,
|
||||
@@ -9,16 +15,27 @@ import {
|
||||
|
||||
import { charCountClass } from "../../utils/shared/charCount.ts";
|
||||
|
||||
interface TextFieldProps<T extends FieldValues> {
|
||||
/** The props of {@link TextField}. */
|
||||
export interface TextFieldProps<T extends FieldValues> {
|
||||
/** The field's name in the form. */
|
||||
name: Path<T>;
|
||||
/** The field's label. */
|
||||
label?: string;
|
||||
/** Optional control rendered to the right of the label (e.g. a reset button). */
|
||||
/**
|
||||
* Optional control rendered to the right of the label (e.g. a reset button).
|
||||
*/
|
||||
labelAction?: ReactNode;
|
||||
/** The input's type; `text` by default. */
|
||||
type?: "text" | "email" | "password" | "url" | "search";
|
||||
/** The input's placeholder. */
|
||||
placeholder?: string;
|
||||
/** The input's autocomplete hint. */
|
||||
autoComplete?: string;
|
||||
/** Whether the input takes focus on mount. */
|
||||
autoFocus?: boolean;
|
||||
/** Whether the input is disabled. */
|
||||
disabled?: boolean;
|
||||
/** The field's validation rules. */
|
||||
rules?: RegisterOptions<T, Path<T>>;
|
||||
/** Cap input length at `max` (native `maxLength`). */
|
||||
maxLength?: number;
|
||||
@@ -26,7 +43,10 @@ interface TextFieldProps<T extends FieldValues> {
|
||||
showCount?: boolean;
|
||||
}
|
||||
|
||||
/** Live `n / max` counter; isolated so only counted fields subscribe to value. */
|
||||
/**
|
||||
* Renders a live `n / max` counter of field `name`; isolated so only counted
|
||||
* fields subscribe to the value.
|
||||
*/
|
||||
function CharCounter<T extends FieldValues>(
|
||||
{ name, max }: { name: Path<T>; max: number },
|
||||
) {
|
||||
@@ -40,9 +60,9 @@ function CharCounter<T extends FieldValues>(
|
||||
}
|
||||
|
||||
/**
|
||||
* Native text input wired to react-hook-form via the surrounding `FormProvider`.
|
||||
* Renders a label, the input (with an optional character counter), and a
|
||||
* validation hint sourced from `formState.errors[name]`.
|
||||
* Renders a native text input wired to react-hook-form via the surrounding
|
||||
* `FormProvider`: a label, the input (with an optional character counter), and
|
||||
* a validation hint sourced from `formState.errors[name]`.
|
||||
*/
|
||||
export function TextField<T extends FieldValues>({
|
||||
name,
|
||||
@@ -56,7 +76,7 @@ export function TextField<T extends FieldValues>({
|
||||
rules,
|
||||
maxLength,
|
||||
showCount = false,
|
||||
}: TextFieldProps<T>) {
|
||||
}: TextFieldProps<T>): React.JSX.Element {
|
||||
const id = useId();
|
||||
const { register, formState: { errors } } = useFormContext<T>();
|
||||
const error = errors[name];
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* The site's header: the brand, the search, the user's links and buttons, and
|
||||
* the socket's status.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { lazy, type ReactNode, Suspense, useRef, useState } from "react";
|
||||
import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
@@ -10,32 +17,51 @@ import { UserMenu } from "./UserMenu.tsx";
|
||||
import { SearchBar } from "./SearchBar.tsx";
|
||||
import { SITE_EMOJI, SITE_NAME } from "../../hooks/shared/useDocumentTitle.ts";
|
||||
|
||||
/** The dump creation modal, loaded on first open. */
|
||||
const DumpCreateModal = lazy(() =>
|
||||
import("../dump/create/DumpCreateModal.tsx").then((m) => ({
|
||||
default: m.DumpCreateModal,
|
||||
}))
|
||||
);
|
||||
|
||||
/** Carried in a search entry's history state: the history index of the page
|
||||
* the search started from, so leaving search returns there in one step
|
||||
* however many searches (or result tabs) came in between. */
|
||||
/** Carried in a search entry's history state. */
|
||||
interface SearchEntryState {
|
||||
/**
|
||||
* The history index of the page the search started from, so leaving search
|
||||
* returns there in one step however many searches (or result tabs) came in
|
||||
* between.
|
||||
*/
|
||||
searchExit?: number;
|
||||
}
|
||||
|
||||
/** React Router's index of the current history entry — 0 for the first page
|
||||
* of the session, which has nothing in the app to go back to. */
|
||||
/**
|
||||
* Returns React Router's index of the current history entry — 0 for the first
|
||||
* page of the session, which has nothing in the app to go back to.
|
||||
*/
|
||||
function historyIndex(): number {
|
||||
return (globalThis.history.state as { idx?: number } | null)?.idx ?? 0;
|
||||
}
|
||||
|
||||
/** The props of {@link AppHeader}. */
|
||||
export interface AppHeaderProps {
|
||||
/** What the page shows in the header's centre, beside the search. */
|
||||
centerSlot?: ReactNode;
|
||||
/** Whether the new-dump button is disabled (the server is unreachable). */
|
||||
disableNew?: boolean;
|
||||
/** A link to open the dump creation modal with, at mount. */
|
||||
initialDumpUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the site's header: the brand; the search, which stays open on the
|
||||
* results page and slides open under the header on small viewports; for a
|
||||
* signed-in user their profile and playlists links (an avatar menu on small
|
||||
* viewports), the chat and notification buttons and the new-dump button, else a
|
||||
* login button; and a banner while live updates are down.
|
||||
*/
|
||||
export function AppHeader(
|
||||
{ centerSlot, disableNew, initialDumpUrl }: {
|
||||
centerSlot?: ReactNode;
|
||||
disableNew?: boolean;
|
||||
initialDumpUrl?: string;
|
||||
},
|
||||
) {
|
||||
{ centerSlot, disableNew, initialDumpUrl }: AppHeaderProps,
|
||||
): React.JSX.Element {
|
||||
const { user } = useAuth();
|
||||
const { wsStatus, wsErrorMessage } = useWS();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
/**
|
||||
* The header's chat button.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useWSChat } from "../../hooks/ws/useWS.ts";
|
||||
import { useChat } from "../../hooks/chat/useChat.ts";
|
||||
|
||||
/** Header chat button with an unread-message badge. Opens the app-wide chat. */
|
||||
export function ChatButton() {
|
||||
/**
|
||||
* Renders the header chat button with an unread-message badge. Opens the
|
||||
* app-wide chat.
|
||||
*/
|
||||
export function ChatButton(): React.JSX.Element {
|
||||
const { unreadChatCount } = useWSChat();
|
||||
const { openChat } = useChat();
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The floating chat button.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
@@ -6,11 +12,11 @@ import { useWSChat } from "../../hooks/ws/useWS.ts";
|
||||
import { useChat } from "../../hooks/chat/useChat.ts";
|
||||
|
||||
/**
|
||||
* Floating chat button shown once the header has scrolled out of view, mirroring
|
||||
* {@link DumpFab}. Opens the single app-wide chat modal (owned by ChatProvider).
|
||||
* Only rendered for signed-in users.
|
||||
* Renders the floating chat button shown once the header has scrolled out of
|
||||
* view, mirroring `DumpFab`, with an unread badge. Opens the single app-wide
|
||||
* chat modal (owned by ChatProvider). Only rendered for signed-in users.
|
||||
*/
|
||||
export function ChatFab() {
|
||||
export function ChatFab(): React.JSX.Element | null {
|
||||
const { user } = useAuth();
|
||||
const { unreadChatCount } = useWSChat();
|
||||
const { openChat } = useChat();
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
/**
|
||||
* The floating new-dump button.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
|
||||
/** The dump creation modal, loaded on first open. */
|
||||
const DumpCreateModal = lazy(() =>
|
||||
import("../dump/create/DumpCreateModal.tsx").then((m) => ({
|
||||
default: m.DumpCreateModal,
|
||||
@@ -10,17 +17,17 @@ const DumpCreateModal = lazy(() =>
|
||||
);
|
||||
|
||||
/**
|
||||
* Floating "new dump" button shown once the header has scrolled out of view.
|
||||
* Sits in the bottom-right corner and lifts above the global player (which is
|
||||
* full-width on mobile) so the two never overlap. Only rendered for signed-in
|
||||
* users — dump creation requires an account.
|
||||
* Renders the floating "new dump" button shown once the header has scrolled out
|
||||
* of view. Sits in the bottom-right corner and lifts above the global player
|
||||
* (which is full-width on mobile) so the two never overlap. Only rendered for
|
||||
* signed-in users — dump creation requires an account.
|
||||
*
|
||||
* Mounted once at the app root (not per-page) since some pages render their own
|
||||
* header instead of going through PageShell. Visibility is derived from a live
|
||||
* read of the current page's `.app-header`, so it survives client-side
|
||||
* navigation and lazily-mounted route headers.
|
||||
*/
|
||||
export function DumpFab() {
|
||||
export function DumpFab(): React.JSX.Element | null {
|
||||
const { user } = useAuth();
|
||||
const location = useLocation();
|
||||
const [headerHidden, setHeaderHidden] = useState(false);
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
/**
|
||||
* The header's notification bell.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useWS } from "../../hooks/ws/useWS.ts";
|
||||
|
||||
export function NotificationBell() {
|
||||
/**
|
||||
* Renders the header's notification bell with an unread badge; it rings when a
|
||||
* notification arrives, and opens the notifications page.
|
||||
*/
|
||||
export function NotificationBell(): React.JSX.Element {
|
||||
const { unreadNotificationCount, lastNotification } = useWS();
|
||||
const navigate = useNavigate();
|
||||
const [ringing, setRinging] = useState(false);
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
/**
|
||||
* A page that failed to load.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { PageShell } from "./PageShell.tsx";
|
||||
import { ErrorCard } from "../shared/ErrorCard.tsx";
|
||||
|
||||
/** The props of {@link PageError}. */
|
||||
export interface PageErrorProps {
|
||||
/** What failed; "Something went wrong" by default. */
|
||||
title?: string;
|
||||
/** Why. */
|
||||
message: string;
|
||||
/** Buttons offered under it. */
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
/** Renders an error card as the whole page, under the header. */
|
||||
export function PageError(
|
||||
{ title, message, actions }: {
|
||||
title?: string;
|
||||
message: string;
|
||||
actions?: ReactNode;
|
||||
},
|
||||
) {
|
||||
{ title, message, actions }: PageErrorProps,
|
||||
): React.JSX.Element {
|
||||
const resolvedTitle = title ?? t`Something went wrong`;
|
||||
return (
|
||||
<PageShell>
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
/**
|
||||
* A page's frame: the header, and the content under it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { AppHeader } from "./AppHeader.tsx";
|
||||
|
||||
interface PageShellProps {
|
||||
/** The props of {@link PageShell}. */
|
||||
export interface PageShellProps {
|
||||
/** The page's content. */
|
||||
children: ReactNode;
|
||||
/** Whether the content is centred. */
|
||||
centered?: boolean;
|
||||
/** What the page shows in the header's centre. */
|
||||
centerSlot?: ReactNode;
|
||||
}
|
||||
|
||||
/** Renders the header and, under it, the page's content. */
|
||||
export function PageShell(
|
||||
{ children, centered = false, centerSlot }: PageShellProps,
|
||||
) {
|
||||
): React.JSX.Element {
|
||||
return (
|
||||
<div className="page-shell">
|
||||
<AppHeader centerSlot={centerSlot} />
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
/**
|
||||
* The header's search form.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
interface SearchBarProps {
|
||||
/** The props of {@link SearchBar}. */
|
||||
export interface SearchBarProps {
|
||||
/** Whether it is open. */
|
||||
expanded: boolean;
|
||||
/** The query as typed. */
|
||||
value: string;
|
||||
/** Called as the query is typed. */
|
||||
onValueChange: (v: string) => void;
|
||||
/** Called with the trimmed query on submit. */
|
||||
onSubmit: (q: string) => void;
|
||||
/** Escape pressed in the input. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The header's search form. The header renders two of these (one in its
|
||||
* centre column, one in the row that slides open under it on small
|
||||
* viewports) over one shared state, so the draft survives crossing the
|
||||
* breakpoint.
|
||||
* Renders the header's search form. The header renders two of these (one in its
|
||||
* centre column, one in the row that slides open under it on small viewports)
|
||||
* over one shared state, so the draft survives crossing the breakpoint.
|
||||
*/
|
||||
export function SearchBar(
|
||||
{ expanded, value, onValueChange, onSubmit, onCancel }: SearchBarProps,
|
||||
) {
|
||||
): React.JSX.Element {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const wasExpanded = useRef(expanded);
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The header's avatar menu, on small viewports.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
@@ -5,7 +11,17 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { Avatar } from "../shared/Avatar.tsx";
|
||||
import type { SelfUser } from "../../../shared/user.ts";
|
||||
|
||||
export function UserMenu({ user }: { user: SelfUser }) {
|
||||
/** The props of {@link UserMenu}. */
|
||||
export interface UserMenuProps {
|
||||
/** The signed-in user. */
|
||||
user: SelfUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the signed-in user's avatar, which opens a menu with their profile
|
||||
* and playlists links.
|
||||
*/
|
||||
export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The global player, docked at the bottom of every page.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -13,10 +19,13 @@ import { Thumbnail } from "../shared/Thumbnail.tsx";
|
||||
import { useYouTubePlaylist } from "../../hooks/player/useYouTubePlaylist.ts";
|
||||
import { embedListId, withJsApi } from "../../utils/player/youtube.ts";
|
||||
|
||||
/** An embed item of the player. */
|
||||
type EmbedItem = Extract<PlayerItem, { kind: "embed" }>;
|
||||
|
||||
/** One row of the tracklist, whether it comes from the queue or from a
|
||||
* YouTube playlist the embed is holding. */
|
||||
/**
|
||||
* Renders one row of the tracklist, whether it comes from the queue or from a
|
||||
* YouTube playlist the embed is holding.
|
||||
*/
|
||||
function TrackRow(
|
||||
{ num, title, duration, isCurrent, disabled, onPlay, rowRef }: {
|
||||
num: number | string;
|
||||
@@ -44,13 +53,16 @@ function TrackRow(
|
||||
);
|
||||
}
|
||||
|
||||
// The stored embedUrl is the canonical, non-playing form — it is also rendered
|
||||
// outside the player, so the autoplay parameter is added here at playback time
|
||||
// rather than baked into rich_content. Only a fresh play() sets autoplay: a
|
||||
// session restored from localStorage has no user gesture behind it, so it stays
|
||||
// paused, matching how MediaPlayer treats file items.
|
||||
// Bandcamp's EmbeddedPlayer has no autoplay parameter — that is what the native
|
||||
// playback path (GERBEUR_BANDCAMP_PLAYER=native) exists to solve.
|
||||
/**
|
||||
* Returns the URL the iframe of `item` loads. The stored embedUrl is the
|
||||
* canonical, non-playing form — it is also rendered outside the player, so the
|
||||
* autoplay parameter is added here at playback time rather than baked into
|
||||
* rich_content. Only a fresh play() sets `autoplay`: a session restored from
|
||||
* localStorage has no user gesture behind it, so it stays paused, matching how
|
||||
* MediaPlayer treats file items. Bandcamp's EmbeddedPlayer has no autoplay
|
||||
* parameter — that is what the native playback path
|
||||
* (GERBEUR_BANDCAMP_PLAYER=native) exists to solve.
|
||||
*/
|
||||
function playbackUrl(item: EmbedItem, autoplay: boolean) {
|
||||
// A playlist embed is also driven from the outside — see useYouTubePlaylist.
|
||||
const base = item.type === "youtube" && embedListId(item.embedUrl)
|
||||
@@ -69,7 +81,13 @@ function playbackUrl(item: EmbedItem, autoplay: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
export function GlobalPlayer() {
|
||||
/**
|
||||
* Renders what the player plays — a media player for files and streams, the
|
||||
* provider's iframe for embeds — with its artwork, title, link to the dump,
|
||||
* transport, and the tracklist of the queue or of a YouTube playlist; nothing
|
||||
* while nothing plays.
|
||||
*/
|
||||
export function GlobalPlayer(): React.JSX.Element | null {
|
||||
const {
|
||||
current,
|
||||
queue,
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The site's audio and video player.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { fmt } from "../../utils/player/duration.ts";
|
||||
import { t } from "@lingui/core/macro";
|
||||
@@ -11,16 +17,26 @@ import { SeekBar } from "./SeekBar.tsx";
|
||||
import { Waveform } from "./Waveform.tsx";
|
||||
import { rememberVolume, storedVolume } from "../../utils/player/volume.ts";
|
||||
|
||||
/** How long a playing video's controls stay after the pointer stops, in ms. */
|
||||
const HIDE_DELAY = 2500;
|
||||
|
||||
interface MediaPlayerProps {
|
||||
/** The props of {@link MediaPlayer}. */
|
||||
export interface MediaPlayerProps {
|
||||
/** The media's URL. */
|
||||
src: string;
|
||||
/** Whether it is audio or video. */
|
||||
kind: "audio" | "video";
|
||||
/** The media's type. */
|
||||
mime?: string;
|
||||
/** A video's still before it plays. */
|
||||
poster?: string;
|
||||
/** Whether it starts playing on mount. */
|
||||
autoplay?: boolean;
|
||||
/** Where it starts, in seconds. */
|
||||
startTime?: number;
|
||||
/** Called when it starts or stops playing. */
|
||||
onPlayStateChange?: (playing: boolean) => void;
|
||||
/** Called as it plays, with the playhead and the duration. */
|
||||
onTimeUpdate?: (time: number, duration: number) => void;
|
||||
/** Fired when the media reaches its end — the queue's advance hook. */
|
||||
onEnded?: () => void;
|
||||
@@ -30,10 +46,17 @@ interface MediaPlayerProps {
|
||||
/** "waveform" (default) decodes peaks from the source; "progress" draws a
|
||||
* slim seek bar and never fetches the media a second time. */
|
||||
trackStyle?: "waveform" | "progress";
|
||||
/** Receives its seek handle, for the global player's controls. */
|
||||
seekRef?: { current: ((t: number) => void) | null };
|
||||
/** Receives its play/pause handle, for the global player's controls. */
|
||||
toggleRef?: { current: (() => void) | null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a media element with the site's own controls: play/pause, a waveform
|
||||
* or seek bar, time, volume (remembered across players) and, for video,
|
||||
* fullscreen.
|
||||
*/
|
||||
export function MediaPlayer(
|
||||
{
|
||||
src,
|
||||
@@ -50,7 +73,7 @@ export function MediaPlayer(
|
||||
seekRef,
|
||||
toggleRef,
|
||||
}: MediaPlayerProps,
|
||||
) {
|
||||
): React.JSX.Element {
|
||||
const mediaRef = useRef<HTMLMediaElement>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [current, setCurrent] = useState(0);
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
// Player control icons, shared by MediaPlayer and FilePreview.
|
||||
/**
|
||||
* Player control icons, shared by MediaPlayer and FilePreview.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
export const IconPlay = () => (
|
||||
/** Renders the play icon. */
|
||||
export const IconPlay = (): React.JSX.Element => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
|
||||
<polygon points="6,3 20,12 6,21" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconPause = () => (
|
||||
/** Renders the pause icon. */
|
||||
export const IconPause = (): React.JSX.Element => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" style={{ padding: "1px" }}>
|
||||
<rect x="5" y="3" width="4" height="18" rx="1" />
|
||||
<rect x="15" y="3" width="4" height="18" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconVolume = ({ muted }: { muted: boolean }) => (
|
||||
/** The props of {@link IconVolume}. */
|
||||
export interface IconVolumeProps {
|
||||
/** Whether the sound is off. */
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
/** Renders the volume icon, struck through when muted. */
|
||||
export const IconVolume = (
|
||||
{ muted }: IconVolumeProps,
|
||||
): React.JSX.Element => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
{muted
|
||||
? (
|
||||
@@ -25,7 +40,8 @@ export const IconVolume = ({ muted }: { muted: boolean }) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IconFullscreen = () => (
|
||||
/** Renders the fullscreen icon. */
|
||||
export const IconFullscreen = (): React.JSX.Element => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
||||
</svg>
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
/**
|
||||
* A slim seek bar.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
/** Slim progress + scrub bar. Used by video, and by audio sources whose peaks
|
||||
* can't be decoded (cross-origin streams) — where a waveform would be a lie. */
|
||||
/** The props of {@link SeekBar}. */
|
||||
export interface SeekBarProps {
|
||||
/** The playhead, in seconds. */
|
||||
current: number;
|
||||
/** The media's length, in seconds. */
|
||||
duration: number;
|
||||
/** Called with the time sought to. */
|
||||
onSeek: (t: number) => void;
|
||||
/** Called when a drag starts and ends. */
|
||||
onDragChange: (dragging: boolean) => void;
|
||||
/** An extra class. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a slim progress + scrub bar. Used by video, and by audio sources
|
||||
* whose peaks can't be decoded (cross-origin streams) — where a waveform would
|
||||
* be a lie.
|
||||
*/
|
||||
export function SeekBar(
|
||||
{ current, duration, onSeek, onDragChange, className }: {
|
||||
current: number;
|
||||
duration: number;
|
||||
onSeek: (t: number) => void;
|
||||
onDragChange: (dragging: boolean) => void;
|
||||
className?: string;
|
||||
},
|
||||
) {
|
||||
{ current, duration, onSeek, onDragChange, className }: SeekBarProps,
|
||||
): React.JSX.Element {
|
||||
const progress = duration > 0 ? current / duration : 0;
|
||||
return (
|
||||
<div className={`audio-player-track${className ? ` ${className}` : ""}`}>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* An audio file's waveform, which is also its seek bar.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
BAR_GAP,
|
||||
@@ -8,14 +14,25 @@ import {
|
||||
WAVEFORM_H,
|
||||
} from "../../utils/player/waveform.ts";
|
||||
|
||||
/** The props of {@link Waveform}. */
|
||||
export interface WaveformProps {
|
||||
/** The audio's URL. */
|
||||
src: string;
|
||||
/** The playhead, in seconds. */
|
||||
current: number;
|
||||
/** The audio's length, in seconds. */
|
||||
duration: number;
|
||||
/** Called with the time clicked. */
|
||||
onSeek: (t: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the audio's peaks as bars, played ones highlighted, seeking where
|
||||
* clicked.
|
||||
*/
|
||||
export function Waveform(
|
||||
{ src, current, duration, onSeek }: {
|
||||
src: string;
|
||||
current: number;
|
||||
duration: number;
|
||||
onSeek: (t: number) => void;
|
||||
},
|
||||
) {
|
||||
{ src, current, duration, onSeek }: WaveformProps,
|
||||
): React.JSX.Element {
|
||||
const [peaks, setPeaks] = useState<Float32Array | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The modal that adds a dump to playlists.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
@@ -8,14 +14,22 @@ import { deserializePlaylistMembership } from "../../utils/shared/deserialize.ts
|
||||
import { Modal } from "../shared/Modal.tsx";
|
||||
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
|
||||
|
||||
interface AddToPlaylistModalProps {
|
||||
/** The props of {@link AddToPlaylistModal}. */
|
||||
export interface AddToPlaylistModalProps {
|
||||
/** The dump's id. */
|
||||
dumpId: string;
|
||||
/** Called to close it. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a modal listing the user's playlists, each toggling whether it holds
|
||||
* the dump, with a form to make a new one. A toggle shows once the server
|
||||
* agrees.
|
||||
*/
|
||||
export function AddToPlaylistModal(
|
||||
{ dumpId, onClose }: AddToPlaylistModalProps,
|
||||
) {
|
||||
): React.JSX.Element {
|
||||
const { authFetch } = useAuth();
|
||||
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The new-playlist button.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -5,19 +11,24 @@ import type { Playlist } from "../../../shared/playlist.ts";
|
||||
import { Modal } from "../shared/Modal.tsx";
|
||||
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
||||
|
||||
interface NewPlaylistButtonProps {
|
||||
/** The props of {@link NewPlaylistButton}. */
|
||||
export interface NewPlaylistButtonProps {
|
||||
/** Called with the playlist created. */
|
||||
onCreated: (playlist: Playlist) => void;
|
||||
/** The button's label; "+ New playlist" by default. */
|
||||
toggleLabel?: string;
|
||||
/** The button's class. */
|
||||
toggleClassName?: string;
|
||||
}
|
||||
|
||||
/** Renders a button that opens the playlist creation form in a modal. */
|
||||
export function NewPlaylistButton(
|
||||
{
|
||||
onCreated,
|
||||
toggleLabel,
|
||||
toggleClassName = "new-item-toggle",
|
||||
}: NewPlaylistButtonProps,
|
||||
) {
|
||||
): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* A playlist, as a card in a list.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
@@ -12,15 +18,24 @@ import {
|
||||
} from "../../utils/shared/visited.ts";
|
||||
import { Tooltip } from "../shared/Tooltip.tsx";
|
||||
|
||||
interface PlaylistCardProps {
|
||||
/** The props of {@link PlaylistCard}. */
|
||||
export interface PlaylistCardProps {
|
||||
/** The playlist. */
|
||||
playlist: Playlist;
|
||||
/** Deletes it; no delete button without it. */
|
||||
onDelete?: () => void;
|
||||
/** Whether the viewer owns it: no owner link, no unread dot. */
|
||||
isOwner?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a playlist as a card: its cover, title, description, visibility,
|
||||
* owner, size and age, with an unread dot until it is opened. The card opens
|
||||
* the playlist.
|
||||
*/
|
||||
export function PlaylistCard(
|
||||
{ playlist, onDelete, isOwner }: PlaylistCardProps,
|
||||
) {
|
||||
): React.JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
const { mediaToken } = useAuth();
|
||||
const unread = !isOwner && isRecent(playlist.createdAt) &&
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The playlist creation form.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
@@ -19,23 +25,32 @@ import { TextField } from "../form/TextField.tsx";
|
||||
import { useApiForm } from "../../hooks/form/useApiForm.ts";
|
||||
import { VisibilityToggle } from "../form/SegmentedField.tsx";
|
||||
|
||||
interface PlaylistCreateFormProps {
|
||||
/** The props of {@link PlaylistCreateForm}. */
|
||||
export interface PlaylistCreateFormProps {
|
||||
/** If provided, the new playlist will have this dump added to it. */
|
||||
dumpId?: string;
|
||||
/** Called with the playlist created. */
|
||||
onCreated: (playlist: Playlist) => void;
|
||||
/** Called by the Cancel button. */
|
||||
onCancel: () => void;
|
||||
/** An extra class for the form. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** The form's fields. */
|
||||
interface Values {
|
||||
title: string;
|
||||
description: string;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the form that creates a playlist — title, description, visibility —
|
||||
* and adds `dumpId` to it when given.
|
||||
*/
|
||||
export function PlaylistCreateForm(
|
||||
{ dumpId, onCreated, onCancel, className }: PlaylistCreateFormProps,
|
||||
) {
|
||||
): React.JSX.Element {
|
||||
const { authFetch } = useAuth();
|
||||
const form = useApiForm<Values>({
|
||||
defaultValues: { title: "", description: "", isPublic: true },
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
/**
|
||||
* The list of a user's playlists, each toggling whether it holds a dump.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import type { PlaylistMembership } from "../../../shared/playlist.ts";
|
||||
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
||||
|
||||
interface PlaylistMembershipPanelProps {
|
||||
/** The props of {@link PlaylistMembershipPanel}. */
|
||||
export interface PlaylistMembershipPanelProps {
|
||||
/** The dump's id. */
|
||||
dumpId: string;
|
||||
/** The user's playlists, and whether each holds the dump. */
|
||||
memberships: PlaylistMembership[];
|
||||
/** Whether the list is loading. */
|
||||
loading: boolean;
|
||||
/** Called with the playlist toggled. */
|
||||
onToggle: (membership: PlaylistMembership) => void;
|
||||
/** Called with a playlist created from the panel, which holds the dump. */
|
||||
onPlaylistCreated: (membership: PlaylistMembership) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the user's playlists as checkboxes of whether each holds the dump,
|
||||
* and a form to make a new one with it.
|
||||
*/
|
||||
export function PlaylistMembershipPanel({
|
||||
dumpId,
|
||||
memberships,
|
||||
loading,
|
||||
onToggle,
|
||||
onPlaylistCreated,
|
||||
}: PlaylistMembershipPanelProps) {
|
||||
}: PlaylistMembershipPanelProps): React.JSX.Element {
|
||||
const [showNewForm, setShowNewForm] = useState(false);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* The password change modal.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -13,17 +19,27 @@ import { SubmitButton } from "../form/SubmitButton.tsx";
|
||||
import { TextField } from "../form/TextField.tsx";
|
||||
import { useApiForm } from "../../hooks/form/useApiForm.ts";
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
/** The props of {@link ChangePasswordModal}. */
|
||||
export interface ChangePasswordModalProps {
|
||||
/** Called to close it. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** The form's fields. */
|
||||
interface Values {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
export function ChangePasswordModal({ onClose }: ChangePasswordModalProps) {
|
||||
/**
|
||||
* Renders the form that changes the user's password, given the current one. The
|
||||
* change signs out every other session, and this one keeps the fresh tokens it
|
||||
* is handed.
|
||||
*/
|
||||
export function ChangePasswordModal(
|
||||
{ onClose }: ChangePasswordModalProps,
|
||||
): React.JSX.Element {
|
||||
const { authFetch, updateSession } = useAuth();
|
||||
const [done, setDone] = useState(false);
|
||||
const form = useApiForm<Values>({
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
/**
|
||||
* The header of a profile's subpage.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
import { Avatar } from "../shared/Avatar.tsx";
|
||||
|
||||
interface ProfileSubpageHeaderProps {
|
||||
/** The props of {@link ProfileSubpageHeader}. */
|
||||
export interface ProfileSubpageHeaderProps {
|
||||
/** The name in the route, for the back link. */
|
||||
username: string;
|
||||
/** The profile's user. */
|
||||
profileUser: PublicUser;
|
||||
/** The subpage's title. */
|
||||
title: string;
|
||||
/** Buttons on the title row. */
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a link back to the profile, and the subpage's title beside the user's
|
||||
* avatar.
|
||||
*/
|
||||
export function ProfileSubpageHeader(
|
||||
{ username, profileUser, title, actions }: ProfileSubpageHeaderProps,
|
||||
) {
|
||||
): React.JSX.Element {
|
||||
return (
|
||||
<div className="profile-subpage-header">
|
||||
<Link to={`/users/${username}`} className="profile-subpage-back">
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
/**
|
||||
* A user's avatar, or their initial.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useContext, useState } from "react";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { AvatarOverrideContext } from "../../contexts/ws/AvatarOverrideContext.ts";
|
||||
|
||||
interface AvatarProps {
|
||||
/** The props of {@link Avatar}. */
|
||||
export interface AvatarProps {
|
||||
/** The user's id. */
|
||||
userId: string;
|
||||
/** The user's name, for the alt text and the initial. */
|
||||
username: string;
|
||||
/** Whether they have an avatar, as the surrounding data says. */
|
||||
hasAvatar: boolean;
|
||||
/** The side, in px; 36 by default. */
|
||||
size?: number;
|
||||
/** Cache-busts the avatar URL. */
|
||||
version?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a user's avatar, or their initial when they have none or it fails to
|
||||
* load. A live `user_updated` override (avatar add/remove/change) wins over the
|
||||
* possibly-stale props the surrounding list was rendered with.
|
||||
*/
|
||||
export function Avatar(
|
||||
{ userId, username, hasAvatar, size = 36, version }: AvatarProps,
|
||||
) {
|
||||
): React.JSX.Element {
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const sizeStyle = { width: size, height: size };
|
||||
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
/**
|
||||
* A confirmation dialog, for destructive actions.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect, useId } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useScrollLock } from "../../hooks/shared/useScrollLock.ts";
|
||||
|
||||
interface ConfirmModalProps {
|
||||
/** The props of {@link ConfirmModal}. */
|
||||
export interface ConfirmModalProps {
|
||||
/** The question asked. */
|
||||
message: string;
|
||||
/** The confirm button's label; "Delete" by default. */
|
||||
confirmLabel?: string;
|
||||
/** Called on confirm. */
|
||||
onConfirm: () => void;
|
||||
/** Called on cancel, Escape or a backdrop click. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a confirmation dialog over the page. It takes Escape before a {@link
|
||||
* Modal} it sits on, so only the confirm goes.
|
||||
*/
|
||||
export function ConfirmModal(
|
||||
{ message, confirmLabel, onConfirm, onCancel }: ConfirmModalProps,
|
||||
) {
|
||||
): React.ReactPortal {
|
||||
const label = confirmLabel ?? t`Delete`;
|
||||
const messageId = useId();
|
||||
useScrollLock();
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
import type React from "react";
|
||||
/**
|
||||
* A text input with a character counter.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { charCountClass } from "../../utils/shared/charCount.ts";
|
||||
|
||||
interface CountedInputProps
|
||||
/**
|
||||
* The props of {@link CountedInput}: an input's, with a required value and
|
||||
* limit.
|
||||
*/
|
||||
export interface CountedInputProps
|
||||
extends
|
||||
Omit<React.InputHTMLAttributes<HTMLInputElement>, "maxLength" | "value"> {
|
||||
/** The input's value. */
|
||||
value: string;
|
||||
/** The most characters it takes. */
|
||||
maxLength: number;
|
||||
}
|
||||
|
||||
export function CountedInput({ value, maxLength, ...rest }: CountedInputProps) {
|
||||
/**
|
||||
* Renders a text input with a counter that warns as the value nears its limit.
|
||||
*/
|
||||
export function CountedInput(
|
||||
{ value, maxLength, ...rest }: CountedInputProps,
|
||||
): React.JSX.Element {
|
||||
const countClass = charCountClass(value.length, maxLength);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
/**
|
||||
* An error, as a card.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function ErrorCard({ title, message, actions }: {
|
||||
/** The props of {@link ErrorCard}. */
|
||||
export interface ErrorCardProps {
|
||||
/** What failed. */
|
||||
title: string;
|
||||
/** Why. */
|
||||
message: string;
|
||||
/** Buttons offered under it (retry, go back…). */
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
}
|
||||
|
||||
/** Renders an error as a card, with optional actions under it. */
|
||||
export function ErrorCard(
|
||||
{ title, message, actions }: ErrorCardProps,
|
||||
): React.JSX.Element {
|
||||
return (
|
||||
<div className="error-card-wrap">
|
||||
<div className="error-card" role="alert">
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
/**
|
||||
* An image that is also its own upload button.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useRef } from "react";
|
||||
|
||||
interface ImagePickerProps {
|
||||
/** The props of {@link ImagePicker}. */
|
||||
export interface ImagePickerProps {
|
||||
/** The current image, null without one. */
|
||||
src: string | null;
|
||||
/** Its alt text. */
|
||||
alt?: string;
|
||||
/** The side, in px; 80 by default. */
|
||||
size?: number;
|
||||
/** The corner radius, in px; 8 by default. */
|
||||
borderRadius?: number;
|
||||
/** Called with the file picked. */
|
||||
onChange: (file: File) => void;
|
||||
/** Whether an upload is under way: the picker is disabled meanwhile. */
|
||||
uploading?: boolean;
|
||||
/** The file types offered; JPEG, PNG, GIF and WebP by default. */
|
||||
accept?: string;
|
||||
/** An extra class. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an image (or a placeholder) that opens a file picker when clicked.
|
||||
*/
|
||||
export function ImagePicker({
|
||||
src,
|
||||
alt = "",
|
||||
@@ -20,7 +38,7 @@ export function ImagePicker({
|
||||
uploading = false,
|
||||
accept = "image/jpeg,image/png,image/gif,image/webp",
|
||||
className,
|
||||
}: ImagePickerProps) {
|
||||
}: ImagePickerProps): React.JSX.Element {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const sizeStyle = { width: size, height: size, borderRadius };
|
||||
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
/**
|
||||
* A modal dialog.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { type ReactNode, useCallback, useEffect, useId, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useScrollLock } from "../../hooks/shared/useScrollLock.ts";
|
||||
|
||||
interface ModalProps {
|
||||
/** The props of {@link Modal}. */
|
||||
export interface ModalProps {
|
||||
/** The dialog's title. */
|
||||
title: string;
|
||||
/** Called to close it. */
|
||||
onClose: () => void;
|
||||
/** The dialog's body. */
|
||||
children: ReactNode;
|
||||
/** Whether it takes the wide layout. */
|
||||
wide?: boolean;
|
||||
/**
|
||||
* Runs before every dismissal the user did not aim at the content — Escape,
|
||||
@@ -16,9 +27,13 @@ interface ModalProps {
|
||||
onBeforeClose?: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a modal dialog over the page, closed by Escape, the backdrop or its
|
||||
* ✕, and locks the page's scroll while it is open.
|
||||
*/
|
||||
export function Modal(
|
||||
{ title, onClose, children, wide = false, onBeforeClose }: ModalProps,
|
||||
) {
|
||||
): React.ReactPortal {
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
|
||||
|
||||
@@ -1,25 +1,46 @@
|
||||
/**
|
||||
* A row of tabs.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
interface Tab<T extends string> {
|
||||
/** A tab. */
|
||||
export interface Tab<T extends string> {
|
||||
/** What {@link TabBar} reports when it is chosen. */
|
||||
key: T;
|
||||
/** What it shows. */
|
||||
label: ReactNode;
|
||||
}
|
||||
|
||||
interface TabBarProps<T extends string> {
|
||||
/** The props of {@link TabBar}. */
|
||||
export interface TabBarProps<T extends string> {
|
||||
/** The tabs, in order. */
|
||||
tabs: Tab<T>[];
|
||||
/** The active tab's key. */
|
||||
activeTab: T;
|
||||
/** Called with the key of the tab chosen. */
|
||||
onChange: (key: T) => void;
|
||||
/** An extra class for the scroller. */
|
||||
className?: string;
|
||||
/** An extra class for the row. */
|
||||
innerClassName?: string;
|
||||
// Optional content rendered inside the row, before the tabs (e.g. a scope
|
||||
// selector). Sits in the same flex line so it scrolls and aligns with them.
|
||||
/**
|
||||
* Optional content rendered inside the row, before the tabs (e.g. a scope
|
||||
* selector). Sits in the same flex line so it scrolls and aligns with them.
|
||||
*/
|
||||
leading?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a row of tabs that scrolls when they don't all fit, keeping the
|
||||
* active one in view.
|
||||
*/
|
||||
export function TabBar<T extends string>(
|
||||
{ tabs, activeTab, onChange, className, innerClassName, leading }:
|
||||
TabBarProps<T>,
|
||||
) {
|
||||
): React.JSX.Element {
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
|
||||
|
||||
@@ -1,29 +1,43 @@
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import { canProxyImage, proxyImageUrl } from "../../utils/shared/proxyImage.ts";
|
||||
import { ThumbnailPlaceholder } from "./ThumbnailPlaceholder.tsx";
|
||||
/**
|
||||
* An external thumbnail, with fallbacks down to a placeholder.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
interface ThumbnailProps {
|
||||
import { useState } from "react";
|
||||
import { canProxyImage, proxyImageUrl } from "../../utils/shared/proxyImage.ts";
|
||||
import {
|
||||
ThumbnailPlaceholder,
|
||||
type ThumbnailPlaceholderProps,
|
||||
} from "./ThumbnailPlaceholder.tsx";
|
||||
|
||||
/** The props of {@link Thumbnail}. */
|
||||
export interface ThumbnailProps {
|
||||
/** Raw image URL. Proxying is decided here — don't pre-proxy it. */
|
||||
src?: string;
|
||||
/** Its alt text. */
|
||||
alt?: string;
|
||||
/** The image's class. */
|
||||
className?: string;
|
||||
/** When to load it; lazily by default. */
|
||||
loading?: "lazy" | "eager";
|
||||
/** Passed through to the placeholder when there's nothing to show. */
|
||||
placeholder: Omit<ComponentProps<typeof ThumbnailPlaceholder>, "className">;
|
||||
placeholder: Omit<ThumbnailPlaceholderProps, "className">;
|
||||
/** Extra class for the placeholder element only. */
|
||||
placeholderClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An external thumbnail that always renders *something*.
|
||||
* Renders an external thumbnail, and always renders *something*.
|
||||
*
|
||||
* Three attempts, in order:
|
||||
* 1. load it directly with no `Referer` — hotlink protection (Cloudflare's is
|
||||
* one click) answers a cross-site referrer with 403, and an empty one with
|
||||
* 200;
|
||||
* 2. retry through `/api/proxy-image`, which fetches server-side, for hosts
|
||||
* that reject an empty referrer too;
|
||||
* 3. fall back to the generated placeholder.
|
||||
*
|
||||
* 1. load it directly with no `Referer` — hotlink protection (Cloudflare's is
|
||||
* one click) answers a cross-site referrer with 403, and an empty one with
|
||||
* 200;
|
||||
* 2. retry through `/api/proxy-image`, which fetches server-side, for hosts
|
||||
* that reject an empty referrer too;
|
||||
* 3. fall back to the generated placeholder.
|
||||
*
|
||||
* Before this existed each of these failures ended at `display: none`, leaving
|
||||
* an empty framed box and no signal anywhere that a load had failed.
|
||||
@@ -37,7 +51,7 @@ export function Thumbnail(
|
||||
placeholder,
|
||||
placeholderClassName,
|
||||
}: ThumbnailProps,
|
||||
) {
|
||||
): React.JSX.Element {
|
||||
const [attempt, setAttempt] = useState<"direct" | "proxied" | "failed">(
|
||||
"direct",
|
||||
);
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
/**
|
||||
* Generated artwork for a page with no preview image.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { proxyImageUrl } from "../../utils/shared/proxyImage.ts";
|
||||
import { initialsFor, tintFor } from "../../utils/shared/thumbnailTint.ts";
|
||||
|
||||
interface ThumbnailPlaceholderProps {
|
||||
/** The props of {@link ThumbnailPlaceholder}. */
|
||||
export interface ThumbnailPlaceholderProps {
|
||||
/** The dumped URL — seeds the fallback hue and the initial. */
|
||||
url?: string;
|
||||
/** Explicit hue seed for dumps with no URL to hash (file dumps). */
|
||||
@@ -15,13 +22,14 @@ interface ThumbnailPlaceholderProps {
|
||||
siteName?: string;
|
||||
/** Emoji shown instead of an initial — file dumps say more with 🎬 than "T". */
|
||||
glyph?: string;
|
||||
/** An extra class. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand-in artwork for pages that offer no preview image: the site's own color
|
||||
* washed over the theme surface, with its favicon centered on top — or its
|
||||
* initial when the favicon is missing or fails to load.
|
||||
* Renders stand-in artwork for pages that offer no preview image: the site's
|
||||
* own color washed over the theme surface, with its favicon centered on top —
|
||||
* or its initial when the favicon is missing or fails to load.
|
||||
*
|
||||
* Fills whatever box it's placed in, so the same component serves the 36px
|
||||
* compact thumbnail, the 128×72 feed preview and a 2×2 mosaic tile.
|
||||
@@ -29,7 +37,7 @@ interface ThumbnailPlaceholderProps {
|
||||
export function ThumbnailPlaceholder(
|
||||
{ url, seed, accentColor, faviconUrl, siteName, glyph, className }:
|
||||
ThumbnailPlaceholderProps,
|
||||
) {
|
||||
): React.JSX.Element {
|
||||
const [iconFailed, setIconFailed] = useState(false);
|
||||
|
||||
// A different icon is a fresh thing to try — clear the prior failure during
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
/**
|
||||
* A tooltip.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface TooltipProps {
|
||||
/** The props of {@link Tooltip}. */
|
||||
export interface TooltipProps {
|
||||
/** The tooltip's text. */
|
||||
text: string;
|
||||
/** What it is shown over. */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Tooltip({ text, children }: TooltipProps) {
|
||||
/** Renders `children` with a tooltip above them on hover. */
|
||||
export function Tooltip({ text, children }: TooltipProps): React.JSX.Element {
|
||||
const [rect, setRect] = useState<DOMRect | null>(null);
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
/**
|
||||
* The follow buttons, for a user and for a playlist.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { useFollows } from "../../hooks/social/useFollows.ts";
|
||||
|
||||
interface FollowUserButtonProps {
|
||||
/** The props of {@link FollowUserButton}. */
|
||||
export interface FollowUserButtonProps {
|
||||
/** The user's id. */
|
||||
targetUserId: string;
|
||||
/** The user's name, for the button's label. */
|
||||
targetUsername: string;
|
||||
}
|
||||
|
||||
interface FollowPlaylistButtonProps {
|
||||
/** The props of {@link FollowPlaylistButton}. */
|
||||
export interface FollowPlaylistButtonProps {
|
||||
/** The playlist's id. */
|
||||
targetPlaylistId: string;
|
||||
/** Whether it is public; only a public playlist can be followed. */
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the button that follows or unfollows a user; nothing when signed out
|
||||
* or on one's own profile.
|
||||
*/
|
||||
export function FollowUserButton(
|
||||
{ targetUserId, targetUsername }: FollowUserButtonProps,
|
||||
) {
|
||||
): React.JSX.Element | null {
|
||||
const { user } = useAuth();
|
||||
const { followedUserIds, followUser, unfollowUser, isLoaded } = useFollows();
|
||||
|
||||
@@ -39,9 +55,13 @@ export function FollowUserButton(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the button that follows or unfollows a playlist; nothing when signed
|
||||
* out or for a private playlist.
|
||||
*/
|
||||
export function FollowPlaylistButton(
|
||||
{ targetPlaylistId, isPublic }: FollowPlaylistButtonProps,
|
||||
) {
|
||||
): React.JSX.Element | null {
|
||||
const { user } = useAuth();
|
||||
const { followedPlaylistIds, followPlaylist, unfollowPlaylist, isLoaded } =
|
||||
useFollows();
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
/**
|
||||
* A comment's like button.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { UserListPopover } from "./UserListPopover.tsx";
|
||||
|
||||
interface LikeButtonProps {
|
||||
/** The props of {@link LikeButton}. */
|
||||
export interface LikeButtonProps {
|
||||
/** The comment's id. */
|
||||
commentId: string;
|
||||
/** Its like count. */
|
||||
count: number;
|
||||
/** Whether the user likes it. */
|
||||
liked: boolean;
|
||||
/** Whether it is disabled (signed out). */
|
||||
disabled?: boolean;
|
||||
/** Likes the comment; `shownCount` is the count on screen. */
|
||||
onLike: (commentId: string, shownCount?: number) => void;
|
||||
/** Withdraws the like; `shownCount` is the count on screen. */
|
||||
onUnlike: (commentId: string, shownCount?: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a comment's like button with its count, which opens the list of who
|
||||
* likes it.
|
||||
*/
|
||||
export function LikeButton(
|
||||
{ commentId, count, liked, disabled, onLike, onUnlike }: LikeButtonProps,
|
||||
) {
|
||||
): React.JSX.Element {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const countRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* A popover listing users: who voted for a dump, who likes a comment.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Link } from "react-router";
|
||||
@@ -11,12 +17,17 @@ import type { PaginatedData, Wire } from "../../../shared/api.ts";
|
||||
import { parseAPIResponse } from "../../utils/shared/api.ts";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
|
||||
interface UserListPopoverProps {
|
||||
/** The props of {@link UserListPopover}. */
|
||||
export interface UserListPopoverProps {
|
||||
/** The element it opens against. */
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
/** Called to close it: an outside click, Escape, or a scroll. */
|
||||
onClose: () => void;
|
||||
/** The paginated endpoint listing the users. */
|
||||
fetchUrl: string;
|
||||
}
|
||||
|
||||
/** The list's state: loading, failed, or loaded with a page of users. */
|
||||
type State =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
@@ -28,6 +39,7 @@ type State =
|
||||
loadingMore: boolean;
|
||||
};
|
||||
|
||||
/** Where the popover sits, in viewport coordinates. */
|
||||
interface Placement {
|
||||
left: number;
|
||||
top?: number;
|
||||
@@ -35,13 +47,21 @@ interface Placement {
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
/** The popover's preferred largest height, in px. */
|
||||
const PREFERRED_MAX_HEIGHT = 280;
|
||||
/** The space kept clear of the viewport's edges, in px. */
|
||||
const VIEWPORT_MARGIN = 8;
|
||||
/** The least height worth opening below the anchor, in px. */
|
||||
const MIN_USABLE_HEIGHT = 100;
|
||||
|
||||
/**
|
||||
* Renders the users from `fetchUrl`, loading more on demand, in a popover
|
||||
* against `anchorRef`. It is fixed and portaled to <body> so a card's overflow
|
||||
* can't clip it, and flips above the anchor when there isn't enough room below.
|
||||
*/
|
||||
export function UserListPopover(
|
||||
{ anchorRef, onClose, fetchUrl }: UserListPopoverProps,
|
||||
) {
|
||||
): React.ReactPortal | null {
|
||||
const { token } = useAuth();
|
||||
const [state, setState] = useState<State>({ status: "loading" });
|
||||
const [placement, setPlacement] = useState<Placement | null>(null);
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
/**
|
||||
* A dump's vote button.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import { UserListPopover } from "./UserListPopover.tsx";
|
||||
|
||||
interface VoteButtonProps {
|
||||
/** The props of {@link VoteButton}. */
|
||||
export interface VoteButtonProps {
|
||||
/** The dump's id. */
|
||||
dumpId: string;
|
||||
/** Its vote count. */
|
||||
count: number;
|
||||
/** Whether the user upvoted it. */
|
||||
voted: boolean;
|
||||
/** Whether it is disabled (signed out). */
|
||||
disabled?: boolean;
|
||||
/** Upvotes the dump; `shownCount` is the count on screen. */
|
||||
onCast: (dumpId: string, shownCount?: number) => void;
|
||||
/** Withdraws the vote; `shownCount` is the count on screen. */
|
||||
onRemove: (dumpId: string, shownCount?: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a dump's vote button with its count, which opens the list of voters.
|
||||
*/
|
||||
export function VoteButton(
|
||||
{ dumpId, count, voted, disabled, onCast, onRemove }: VoteButtonProps,
|
||||
) {
|
||||
): React.JSX.Element {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const countRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
// TypeScript triple-slash directive
|
||||
// include type declarations from the package vite/client
|
||||
/**
|
||||
* Where the API and the socket live, and the page sizes the feeds ask for.
|
||||
*
|
||||
* In dev (Vite dev server), the frontend and API run on different ports, so the
|
||||
* URLs are absolute; `VITE_API_*` variables can override the defaults. In prod
|
||||
* (same container), the frontend is served by the API server itself, so both
|
||||
* share the same origin and the URLs are relative (""): no build-time
|
||||
* configuration is needed and the image works on any domain.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// In dev (Vite dev server), the frontend and API run on different ports, so we
|
||||
// need an absolute URL. VITE_API_* vars can override the defaults.
|
||||
//
|
||||
// In prod (same container), the frontend is served by the API server itself, so
|
||||
// both share the same origin. We use relative URLs ("") so no build-time
|
||||
// configuration is needed and the image works on any domain.
|
||||
const apiHostname = import.meta.env.VITE_API_HOSTNAME;
|
||||
|
||||
/** The API's origin, empty when it is the page's own. */
|
||||
export const API_URL: string = apiHostname
|
||||
? `${import.meta.env.VITE_API_PROTOCOL || "http"}://${apiHostname}:${
|
||||
import.meta.env.VITE_API_PORT || "8000"
|
||||
@@ -18,10 +23,14 @@ export const API_URL: string = apiHostname
|
||||
? "http://localhost:8000"
|
||||
: "";
|
||||
|
||||
/** The socket's origin, always absolute. */
|
||||
export const WS_URL: string = API_URL
|
||||
? API_URL.replace(/^http/, "ws")
|
||||
: `${location.protocol.replace("http", "ws")}//${location.host}`;
|
||||
|
||||
/** The search endpoint. */
|
||||
export const SEARCH_URL = `${API_URL}/api/search`;
|
||||
/** How many items a feed page asks for. */
|
||||
export const DEFAULT_PAGE_SIZE = 20;
|
||||
/** How many notifications a page asks for. */
|
||||
export const NOTIFICATIONS_PAGE_SIZE = 30;
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
/**
|
||||
* The index's feed tabs, and the one it opens on.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** The index's feed tabs, in the tab bar's order. */
|
||||
export const FEED_TABS = ["hot", "new", "journal", "followed"] as const;
|
||||
/** One of the {@link FEED_TABS}. */
|
||||
export type FeedTab = (typeof FEED_TABS)[number];
|
||||
|
||||
// Tab the index opens on for visitors and for users who haven't picked one.
|
||||
/**
|
||||
* The tab the index opens on for visitors and for users who haven't picked one.
|
||||
*/
|
||||
export const DEFAULT_FEED_TAB: FeedTab = "journal";
|
||||
|
||||
@@ -7,13 +7,22 @@
|
||||
*
|
||||
* Defaults to "embed" whenever the value is missing, unsubstituted or
|
||||
* unrecognised, so a misconfigured deployment keeps the iframe it has today.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* A Bandcamp playback path: Bandcamp's own iframe, or the site's player on the
|
||||
* resolved streams.
|
||||
*/
|
||||
export type BandcampPlayer = "embed" | "native";
|
||||
|
||||
/** Reads the playback path from the page's `<meta>` tag. */
|
||||
function readBandcampPlayer(): BandcampPlayer {
|
||||
const meta = document.querySelector('meta[name="bandcamp-player"]')
|
||||
?.getAttribute("content")?.trim();
|
||||
return meta === "native" ? "native" : "embed";
|
||||
}
|
||||
|
||||
/** This deployment's Bandcamp playback path. */
|
||||
export const BANDCAMP_PLAYER: BandcampPlayer = readBandcampPlayer();
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
/**
|
||||
* The session context: the signed-in session and its setter, provided by
|
||||
* AuthProvider and read through `useAuth`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { createContext, type Dispatch, type SetStateAction } from "react";
|
||||
|
||||
import type { Session } from "../../utils/auth/storage.ts";
|
||||
|
||||
/** What {@link AuthContext} holds. */
|
||||
export interface AuthContextValue {
|
||||
/** The signed-in session, null when signed out. */
|
||||
authResponse: Session | null;
|
||||
/** Replaces the session; AuthProvider keeps the stored one in step. */
|
||||
setAuthResponse: Dispatch<SetStateAction<Session | null>>;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue>({
|
||||
/** The session context. */
|
||||
export const AuthContext: React.Context<AuthContextValue> = createContext<
|
||||
AuthContextValue
|
||||
>({
|
||||
authResponse: null,
|
||||
setAuthResponse: () => {},
|
||||
});
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* The session's provider: holds it, keeps it in step with the other tabs and
|
||||
* the server.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import { AuthContext, type AuthContextValue } from "./AuthContext.ts";
|
||||
@@ -12,7 +19,22 @@ import {
|
||||
} from "../../utils/auth/storage.ts";
|
||||
import type { Session } from "../../utils/auth/storage.ts";
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
/** The props of {@link AuthProvider}. */
|
||||
export interface AuthProviderProps {
|
||||
/** The app, which reads the session. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides {@link AuthContext}. The session starts as the stored one, follows
|
||||
* the stored one when another tab signs in, out or changes the password, is
|
||||
* checked with the server on mount and whenever the tab regains focus (a 401
|
||||
* ends it; a changed role is picked up), and gets a media token when it
|
||||
* predates them.
|
||||
*/
|
||||
export function AuthProvider(
|
||||
{ children }: AuthProviderProps,
|
||||
): React.JSX.Element {
|
||||
const [authResponse, setAuthResponse] = useState<Session | null>(
|
||||
readStoredSession,
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user