added link bubbles to redirect to other parts of the site

This commit is contained in:
liyunze 2026-09-04 21:44:01 +08:00
parent b6dc5e22b5
commit bac91562ec
14 changed files with 497 additions and 89 deletions

View file

@ -1,8 +1,36 @@
<script lang="ts"> <script lang="ts">
import { renderMarkdown } from "$lib/markdown"; import { page } from "$app/state";
import { query } from "$lib/api";
import { collectInternalPostIds, renderMarkdown } from "$lib/markdown";
let { content }: { content: string } = $props(); let { content }: { content: string } = $props();
let titles = $state<Record<string, string>>({});
const origin = $derived(page.url.origin);
const postIdsKey = $derived(collectInternalPostIds(content, origin).join("\0"));
const html = $derived(renderMarkdown(content, { origin, titles }));
$effect(() => {
const ids = postIdsKey ? postIdsKey.split("\0") : [];
if (ids.length === 0) {
titles = {};
return;
}
let cancelled = false;
void query("posts:getTitles", { ids })
.then((result: Record<string, string>) => {
if (!cancelled) titles = result ?? {};
})
.catch(() => {
if (!cancelled) titles = {};
});
return () => {
cancelled = true;
};
});
const copiedTimers = new WeakMap<HTMLButtonElement, ReturnType<typeof setTimeout>>(); const copiedTimers = new WeakMap<HTMLButtonElement, ReturnType<typeof setTimeout>>();
function codeCopy(node: HTMLElement) { function codeCopy(node: HTMLElement) {
@ -45,4 +73,4 @@
} }
</script> </script>
<div class="markdown" use:codeCopy>{@html renderMarkdown(content)}</div> <div class="markdown" use:codeCopy>{@html html}</div>

View file

@ -2,7 +2,6 @@
import { query, mutation } from "$lib/api"; import { query, mutation } from "$lib/api";
import { getToken } from "$lib/stores/auth"; import { getToken } from "$lib/stores/auth";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { onMount } from "svelte";
let { let {
count = 0, count = 0,
@ -19,14 +18,26 @@
let busy = $state(false); let busy = $state(false);
const voteCount = $derived(localCount ?? count); const voteCount = $derived(localCount ?? count);
onMount(async () => { $effect(() => {
const type = targetType;
const id = targetId;
localCount = null;
userVote = 0;
const token = getToken(); const token = getToken();
if (!token) return; if (!token) return;
try {
userVote = (await query("votes:getMyVote", { token, targetType, targetId })) ?? 0; let cancelled = false;
} catch { void query("votes:getMyVote", { token, targetType: type, targetId: id })
.then((vote) => {
if (!cancelled) userVote = vote ?? 0;
})
.catch(() => {
/* ignore */ /* ignore */
} });
return () => {
cancelled = true;
};
}); });
async function vote(value: 1 | -1, e: MouseEvent) { async function vote(value: 1 | -1, e: MouseEvent) {

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { previewMarkdown, renderMarkdown } from "./markdown"; import { collectInternalPostIds, previewMarkdown, renderMarkdown } from "./markdown";
describe("renderMarkdown", () => { describe("renderMarkdown", () => {
it("renders inline and display LaTeX with KaTeX", () => { it("renders inline and display LaTeX with KaTeX", () => {
@ -34,6 +34,61 @@ describe("renderMarkdown", () => {
expect(rendered).toContain("&amp;"); expect(rendered).toContain("&amp;");
expect(rendered).not.toContain("<div>"); expect(rendered).not.toContain("<div>");
}); });
it("styles same-origin and relative links as internal bubbles", () => {
const origin = "https://notebook.example";
const relative = renderMarkdown("[details](/SIT223/abc)", { origin });
const absolute = renderMarkdown(`[details](${origin}/notes/abc)`, { origin });
const external = renderMarkdown("[site](https://example.com/notes/abc)", { origin });
expect(relative).toContain('class="md-internal"');
expect(relative).toContain('href="/SIT223/abc"');
expect(absolute).toContain('class="md-internal"');
expect(absolute).toContain('href="/notes/abc"');
expect(external).not.toContain("md-internal");
expect(external).toContain('href="https://example.com/notes/abc"');
});
it("uses a short label for autolinked internal urls", () => {
const origin = "https://notebook.example";
const rendered = renderMarkdown(`${origin}/questions/abc`, { origin });
expect(rendered).toContain('class="md-internal"');
expect(rendered).toContain("Question");
expect(rendered).not.toContain(`${origin}/questions/abc<`);
});
it("labels autolinked unit posts with the post title when provided", () => {
const rendered = renderMarkdown("[/SIT223/abc-id](/SIT223/abc-id)", {
titles: { "abc-id": "Week 3 notes" },
});
expect(rendered).toContain("Week 3 notes");
expect(rendered).not.toContain("SIT223 post");
});
it("falls back to a unit post label without a title", () => {
expect(renderMarkdown("[/SIT223/abc-id](/SIT223/abc-id)")).toContain("SIT223 post");
});
it("keeps custom link text on internal bubbles", () => {
const rendered = renderMarkdown("[Week 3 notes](/notes/abc)", {
titles: { abc: "Actual title" },
});
expect(rendered).toContain("Week 3 notes");
expect(rendered).toContain('class="md-internal"');
expect(rendered).not.toContain("Actual title");
});
it("collects autolinked post ids for title lookup", () => {
const origin = "https://notebook.example";
expect(
collectInternalPostIds(
`${origin}/SIT223/unit-post\n${origin}/notes/note-id\n[custom](/questions/q-id)`,
origin,
),
).toEqual(["unit-post", "note-id"]);
});
}); });
describe("previewMarkdown", () => { describe("previewMarkdown", () => {

View file

@ -2,6 +2,7 @@ import MarkdownIt from "markdown-it";
import texmath from "markdown-it-texmath"; import texmath from "markdown-it-texmath";
import katex from "katex"; import katex from "katex";
import hljs from "highlight.js/lib/common"; import hljs from "highlight.js/lib/common";
import { appPathLabel, isAutolinkText, postIdFromAppPath, toAppPath } from "$lib/paths";
const md = new MarkdownIt({ const md = new MarkdownIt({
html: false, html: false,
@ -19,6 +20,8 @@ md.use(texmath, {
}, },
}); });
md.use(internalLinksPlugin);
function highlightCode(source: string, lang: string): string { function highlightCode(source: string, lang: string): string {
const language = lang.trim().toLowerCase(); const language = lang.trim().toLowerCase();
const escapedLang = language ? md.utils.escapeHtml(language) : ""; const escapedLang = language ? md.utils.escapeHtml(language) : "";
@ -43,8 +46,74 @@ const COPY_BUTTON = `<button type="button" class="code-copy" aria-label="Copy co
<svg class="code-copy-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6L9 17l-5-5"></path></svg> <svg class="code-copy-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6L9 17l-5-5"></path></svg>
</button>`; </button>`;
export function renderMarkdown(source: string): string { function internalLinksPlugin(markdown: typeof md) {
return md.render(source ?? ""); markdown.core.ruler.after("inline", "internal_links", (state) => {
const origin = typeof state.env?.origin === "string" ? state.env.origin : undefined;
const titles =
state.env?.titles && typeof state.env.titles === "object"
? (state.env.titles as Record<string, string>)
: undefined;
const collected =
state.env?.postIds instanceof Set ? (state.env.postIds as Set<string>) : undefined;
for (const block of state.tokens) {
if (block.type !== "inline" || !block.children) continue;
const children = block.children;
for (let i = 0; i < children.length; i++) {
const token = children[i];
if (token.type !== "link_open") continue;
const href = String(token.attrGet("href") ?? "");
const appPath = toAppPath(href, origin);
if (!appPath) continue;
const close = findLinkClose(children, i);
if (close === -1) continue;
const inner = children.slice(i + 1, close);
if (inner.some((child) => child.type === "image")) continue;
token.attrSet("href", appPath);
token.attrJoin("class", "md-internal");
if (
inner.length === 1 &&
inner[0].type === "text" &&
isAutolinkText(String(inner[0].content), href, appPath)
) {
const postId = postIdFromAppPath(appPath);
if (postId) collected?.add(postId);
inner[0].content = appPathLabel(appPath, titles);
}
}
}
});
}
function findLinkClose(children: { type: string }[], openIdx: number): number {
let depth = 1;
for (let i = openIdx + 1; i < children.length; i++) {
if (children[i].type === "link_open") depth += 1;
if (children[i].type === "link_close") {
depth -= 1;
if (depth === 0) return i;
}
}
return -1;
}
export function renderMarkdown(
source: string,
options?: { origin?: string; titles?: Record<string, string> },
): string {
return md.render(source ?? "", { origin: options?.origin, titles: options?.titles });
}
export function collectInternalPostIds(source: string, origin?: string): string[] {
const postIds = new Set<string>();
md.parse(source ?? "", { origin, postIds });
return [...postIds];
} }
export function previewMarkdown(source: string, maxLength = 180): string { export function previewMarkdown(source: string, maxLength = 180): string {

View file

@ -5,3 +5,105 @@ export function unitPath(code: string): string {
export function postPath(code: string, postId: string): string { export function postPath(code: string, postId: string): string {
return `${unitPath(code)}/${encodeURIComponent(postId)}`; return `${unitPath(code)}/${encodeURIComponent(postId)}`;
} }
const SKIP_APP_PREFIXES = ["/uploads/", "/api/"];
const APP_SECTIONS = new Set([
"notes",
"questions",
"units",
"users",
"topics",
"search",
"account",
"admin",
"auth",
"post",
]);
export function toAppPath(href: string, origin?: string): string | null {
const trimmed = href.trim();
if (!trimmed || trimmed.startsWith("#") || /^(?:mailto|javascript|data|blob):/i.test(trimmed)) {
return null;
}
let url: URL;
try {
if (origin) {
url = new URL(trimmed, origin);
if (url.origin !== new URL(origin).origin) return null;
} else if (trimmed.startsWith("/")) {
url = new URL(trimmed, "https://internal.invalid");
} else {
return null;
}
} catch {
return null;
}
if (SKIP_APP_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) return null;
return `${url.pathname}${url.search}${url.hash}`;
}
export function postIdFromAppPath(appPath: string): string | null {
const url = new URL(appPath, "https://internal.invalid");
const parts = url.pathname.split("/").filter(Boolean).map(decodeURIComponent);
if (parts.length !== 2) return null;
const [head, rest] = parts;
if (!rest) return null;
if (head === "notes" || head === "questions") return rest;
if (APP_SECTIONS.has(head)) return null;
return rest;
}
export function appPathLabel(appPath: string, titles?: Record<string, string>): string {
const postId = postIdFromAppPath(appPath);
const title = postId ? titles?.[postId]?.trim() : undefined;
if (title) return title;
const url = new URL(appPath, "https://internal.invalid");
const parts = url.pathname.split("/").filter(Boolean).map(decodeURIComponent);
if (parts.length === 0) return "Home";
const [head, rest] = parts;
switch (head) {
case "notes":
return rest ? "Note" : "Notes";
case "questions":
return rest ? "Question" : "Questions";
case "units":
return rest ? rest.toUpperCase() : "Units";
case "users":
return "Profile";
case "topics":
return rest ?? "Topics";
case "search":
return "Search";
case "account":
return "Account";
case "admin":
return "Admin";
case "auth":
return "Sign in";
case "post":
return rest === "question" ? "New question" : "New note";
default:
return rest ? `${head} post` : head;
}
}
export function isAutolinkText(text: string, href: string, appPath: string): boolean {
const value = text.trim();
if (!value) return false;
const candidates = [href, appPath];
try {
candidates.push(decodeURI(href), decodeURI(appPath));
} catch {
/* ignore malformed percent-encoding */
}
return candidates.some(
(candidate) => candidate === value || candidate.replace(/\/$/, "") === value,
);
}

View file

@ -906,6 +906,38 @@ function votesGetMyVote(
// ---- details ---- // ---- details ----
const MAX_POST_TITLES = 50;
function postsGetTitles(db: Db, args: { ids?: string[] }) {
const ids = [
...new Set(
(Array.isArray(args.ids) ? args.ids : [])
.filter((id): id is string => typeof id === "string")
.map((id) => id.trim())
.filter(Boolean),
),
].slice(0, MAX_POST_TITLES);
if (ids.length === 0) return {};
const placeholders = ids.map(() => "?").join(",");
const titles: Record<string, string> = {};
for (const row of db
.prepare(`SELECT id, title FROM notes WHERE id IN (${placeholders})`)
.all(...ids) as { id: string; title: string }[]) {
titles[row.id] = row.title;
}
for (const row of db
.prepare(`SELECT id, title FROM questions WHERE id IN (${placeholders})`)
.all(...ids) as { id: string; title: string }[]) {
if (titles[row.id] === undefined) titles[row.id] = row.title;
}
return titles;
}
function getNoteWithDetails(db: Db, args: { id: string }) { function getNoteWithDetails(db: Db, args: { id: string }) {
const note = db.prepare(`SELECT ${NOTE_COLUMNS} FROM notes WHERE id = ?`).get(args.id) as const note = db.prepare(`SELECT ${NOTE_COLUMNS} FROM notes WHERE id = ?`).get(args.id) as
| Record<string, any> | Record<string, any>
@ -1311,6 +1343,7 @@ const handlers: Record<string, Handler> = {
"comments:update": commentsUpdate, "comments:update": commentsUpdate,
"votes:cast": votesCast, "votes:cast": votesCast,
"votes:getMyVote": votesGetMyVote, "votes:getMyVote": votesGetMyVote,
"posts:getTitles": postsGetTitles,
"details:getNoteWithDetails": getNoteWithDetails, "details:getNoteWithDetails": getNoteWithDetails,
"details:getQuestionWithDetails": getQuestionWithDetails, "details:getQuestionWithDetails": getQuestionWithDetails,
"admin:getState": adminGetState, "admin:getState": adminGetState,

View file

@ -1,5 +1,8 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/state";
import UnitPage from "../units/[code]/+page.svelte"; import UnitPage from "../units/[code]/+page.svelte";
</script> </script>
{#key page.params.code}
<UnitPage /> <UnitPage />
{/key}

View file

@ -1,20 +1,24 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/state"; import { page } from "$app/state";
import { query } from "$lib/api"; import { query } from "$lib/api";
import { onMount } from "svelte";
import NotePage from "../../notes/[id]/+page.svelte"; import NotePage from "../../notes/[id]/+page.svelte";
import QuestionPage from "../../questions/[id]/+page.svelte"; import QuestionPage from "../../questions/[id]/+page.svelte";
let postType: "note" | "question" | null = $state(null); let postType: "note" | "question" | null = $state(null);
let loading = $state(true); let loading = $state(true);
onMount(async () => { $effect(() => {
const note = await query("details:getNoteWithDetails", { id: page.params.id }); const id = page.params.id;
const result =
note ?? (await query("details:getQuestionWithDetails", { id: page.params.id }));
const unit = result?.unit;
const requestedCode = (page.params.code ?? "").toLowerCase(); const requestedCode = (page.params.code ?? "").toLowerCase();
let cancelled = false;
postType = null;
loading = true;
void (async () => {
const note = await query("details:getNoteWithDetails", { id });
const result = note ?? (await query("details:getQuestionWithDetails", { id }));
if (cancelled) return;
const unit = result?.unit;
if ( if (
result && result &&
unit && unit &&
@ -23,6 +27,11 @@
postType = note ? "note" : "question"; postType = note ? "note" : "question";
} }
loading = false; loading = false;
})();
return () => {
cancelled = true;
};
}); });
</script> </script>
@ -31,9 +40,13 @@
<p class="kicker py-16">Loading</p> <p class="kicker py-16">Loading</p>
</div> </div>
{:else if postType === "note"} {:else if postType === "note"}
{#key page.params.id}
<NotePage /> <NotePage />
{/key}
{:else if postType === "question"} {:else if postType === "question"}
{#key page.params.id}
<QuestionPage /> <QuestionPage />
{/key}
{:else} {:else}
<div class="page"> <div class="page">
<h1 class="text-ink font-serif text-3xl">Post not found</h1> <h1 class="text-ink font-serif text-3xl">Post not found</h1>

View file

@ -231,6 +231,37 @@ button {
color: var(--color-secondary-dark); color: var(--color-secondary-dark);
} }
.markdown a.md-internal {
display: inline;
color: var(--color-secondary);
background: color-mix(in srgb, var(--color-secondary) 16%, transparent);
text-decoration: none;
border-radius: 6px;
padding: 0.12em 0.45em;
font-weight: 500;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.markdown a.md-internal::before {
content: "";
display: inline-block;
width: 0.8em;
height: 0.8em;
margin-right: 0.28em;
vertical-align: -0.08em;
background-color: currentColor;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l1.07-1.07a5 5 0 0 0-7.07-7.07L10.4 6.54'/%3E%3Cpath d='M14 11a5 5 0 0 0-7.54-.54L5.4 11.54a5 5 0 0 0 7.07 7.07l1.13-1.14'/%3E%3C/svg%3E")
center / contain no-repeat;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l1.07-1.07a5 5 0 0 0-7.07-7.07L10.4 6.54'/%3E%3Cpath d='M14 11a5 5 0 0 0-7.54-.54L5.4 11.54a5 5 0 0 0 7.07 7.07l1.13-1.14'/%3E%3C/svg%3E")
center / contain no-repeat;
}
.markdown a.md-internal:hover {
color: var(--color-secondary-dark);
background: color-mix(in srgb, var(--color-secondary) 26%, transparent);
}
.markdown code { .markdown code {
background: var(--color-rule); background: var(--color-rule);
color: var(--color-ink); color: var(--color-ink);

View file

@ -2,7 +2,6 @@
import { query, mutation } from "$lib/api"; import { query, mutation } from "$lib/api";
import { isAuthenticated, getToken, currentUser } from "$lib/stores/auth"; import { isAuthenticated, getToken, currentUser } from "$lib/stores/auth";
import { page } from "$app/state"; import { page } from "$app/state";
import { onMount } from "svelte";
import { get } from "svelte/store"; import { get } from "svelte/store";
import VoteStack from "$lib/components/VoteStack.svelte"; import VoteStack from "$lib/components/VoteStack.svelte";
import Markdown from "$lib/components/Markdown.svelte"; import Markdown from "$lib/components/Markdown.svelte";
@ -29,9 +28,19 @@
let editError = $state(""); let editError = $state("");
let editLoading = $state(false); let editLoading = $state(false);
onMount(async () => { $effect(() => {
const id = page.params.id; const id = page.params.id;
let cancelled = false;
loading = true;
note = null;
editMode = false;
commentText = "";
commentError = "";
isAuthor = false;
void (async () => {
const result = await query("details:getNoteWithDetails", { id }); const result = await query("details:getNoteWithDetails", { id });
if (cancelled) return;
if (result) { if (result) {
note = result as NoteDoc; note = result as NoteDoc;
topic = (result as any).topic ?? null; topic = (result as any).topic ?? null;
@ -41,6 +50,11 @@
if (cu) isAuthor = note.authorId === cu._id; if (cu) isAuthor = note.authorId === cu._id;
} }
loading = false; loading = false;
})();
return () => {
cancelled = true;
};
}); });
async function reloadComments() { async function reloadComments() {

View file

@ -2,7 +2,6 @@
import { query, mutation } from "$lib/api"; import { query, mutation } from "$lib/api";
import { isAuthenticated, getToken, currentUser } from "$lib/stores/auth"; import { isAuthenticated, getToken, currentUser } from "$lib/stores/auth";
import { page } from "$app/state"; import { page } from "$app/state";
import { onMount } from "svelte";
import { get } from "svelte/store"; import { get } from "svelte/store";
import VoteStack from "$lib/components/VoteStack.svelte"; import VoteStack from "$lib/components/VoteStack.svelte";
import Markdown from "$lib/components/Markdown.svelte"; import Markdown from "$lib/components/Markdown.svelte";
@ -28,9 +27,19 @@
let editError = $state(""); let editError = $state("");
let editLoading = $state(false); let editLoading = $state(false);
onMount(async () => { $effect(() => {
const id = page.params.id; const id = page.params.id;
let cancelled = false;
loading = true;
question = null;
editMode = false;
answerText = "";
answerError = "";
isAuthor = false;
void (async () => {
const result = await query("details:getQuestionWithDetails", { id }); const result = await query("details:getQuestionWithDetails", { id });
if (cancelled) return;
if (result) { if (result) {
question = result as QuestionDoc; question = result as QuestionDoc;
topic = (result as any).topic ?? null; topic = (result as any).topic ?? null;
@ -40,6 +49,11 @@
if (cu) isAuthor = question.authorId === cu._id; if (cu) isAuthor = question.authorId === cu._id;
} }
loading = false; loading = false;
})();
return () => {
cancelled = true;
};
}); });
async function reloadAnswers() { async function reloadAnswers() {

View file

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { query } from "$lib/api"; import { query } from "$lib/api";
import { page } from "$app/state"; import { page } from "$app/state";
import { onMount } from "svelte";
import FeedRow from "$lib/components/FeedRow.svelte"; import FeedRow from "$lib/components/FeedRow.svelte";
import { postPath } from "$lib/paths"; import { postPath } from "$lib/paths";
import { timeAgo } from "$lib/time"; import { timeAgo } from "$lib/time";
@ -12,19 +11,31 @@
let questions: QuestionDoc[] = $state([]); let questions: QuestionDoc[] = $state([]);
let loading = $state(true); let loading = $state(true);
onMount(async () => { $effect(() => {
const slug = page.params.slug; const slug = page.params.slug;
let cancelled = false;
loading = true;
topic = null;
void (async () => {
const t = await query("topics:getBySlug", { slug }); const t = await query("topics:getBySlug", { slug });
if (cancelled) return;
topic = t as TopicDoc; topic = t as TopicDoc;
if (topic) { if (topic) {
const [n, q] = await Promise.all([ const [n, q] = await Promise.all([
query("notes:list", { topicId: topic._id }), query("notes:list", { topicId: topic._id }),
query("questions:list", { topicId: topic._id }), query("questions:list", { topicId: topic._id }),
]); ]);
if (cancelled) return;
notes = n as NoteDoc[]; notes = n as NoteDoc[];
questions = q as QuestionDoc[]; questions = q as QuestionDoc[];
} }
loading = false; loading = false;
})();
return () => {
cancelled = true;
};
}); });
</script> </script>

View file

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { mutation, query } from "$lib/api"; import { mutation, query } from "$lib/api";
import { page } from "$app/state"; import { page } from "$app/state";
import { onMount } from "svelte";
import { get } from "svelte/store"; import { get } from "svelte/store";
import FeedRow from "$lib/components/FeedRow.svelte"; import FeedRow from "$lib/components/FeedRow.svelte";
import { postPath } from "$lib/paths"; import { postPath } from "$lib/paths";
@ -42,13 +41,19 @@
); );
}); });
onMount(async () => { $effect(() => {
const code = page.params.code;
let cancelled = false;
loading = true;
unit = null;
void (async () => {
await initAuth(); await initAuth();
if (cancelled) return;
authed = get(isAuthenticated); authed = get(isAuthenticated);
const token = getToken(); const token = getToken();
const code = page.params.code;
const u = await query("units:getByCode", { code }); const u = await query("units:getByCode", { code });
if (cancelled) return;
unit = u as UnitDoc; unit = u as UnitDoc;
if (unit) { if (unit) {
const [n, q, pins] = await Promise.all([ const [n, q, pins] = await Promise.all([
@ -56,11 +61,17 @@
query("questions:list", { unitId: unit._id }), query("questions:list", { unitId: unit._id }),
token ? query("units:getPinned", { token }) : Promise.resolve([]), token ? query("units:getPinned", { token }) : Promise.resolve([]),
]); ]);
if (cancelled) return;
notes = n as NoteDoc[]; notes = n as NoteDoc[];
questions = q as QuestionDoc[]; questions = q as QuestionDoc[];
pinnedIds = pins as string[]; pinnedIds = pins as string[];
} }
loading = false; loading = false;
})();
return () => {
cancelled = true;
};
}); });
async function togglePin() { async function togglePin() {

View file

@ -4,7 +4,6 @@
import Avatar from "$lib/components/Avatar.svelte"; import Avatar from "$lib/components/Avatar.svelte";
import { previewMarkdown } from "$lib/markdown"; import { previewMarkdown } from "$lib/markdown";
import { timeAgo } from "$lib/time"; import { timeAgo } from "$lib/time";
import { onMount } from "svelte";
type Profile = { type Profile = {
_id: string; _id: string;
@ -36,9 +35,23 @@
let loading = $state(true); let loading = $state(true);
let tab: "posts" | "comments" = $state("posts"); let tab: "posts" | "comments" = $state("posts");
onMount(async () => { $effect(() => {
profile = (await query("users:getPublicProfile", { id: page.params.id })) as Profile | null; const id = page.params.id;
let cancelled = false;
loading = true;
profile = null;
tab = "posts";
void (async () => {
const result = (await query("users:getPublicProfile", { id })) as Profile | null;
if (cancelled) return;
profile = result;
loading = false; loading = false;
})();
return () => {
cancelled = true;
};
}); });
function contentPath(type: "note" | "question", id: string) { function contentPath(type: "note" | "question", id: string) {