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