mirror of
https://github.com/dsec-hub/dsec-notebook.git
synced 2026-09-22 15:43:58 +00:00
added link bubbles to redirect to other parts of the site
This commit is contained in:
parent
b6dc5e22b5
commit
bac91562ec
14 changed files with 497 additions and 89 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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("&");
|
expect(rendered).toContain("&");
|
||||||
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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
102
src/lib/paths.ts
102
src/lib/paths.ts
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue