feat: 增强公开笔记本站点体验与发布控制 (#634)

公开笔记本站点新增长文导航、正文检索、逐站点排除和站点配置,保留实时分享以及原有 Token、密码和解锁 Cookie。收录只对无密码的别名页生效,Token 页保持完整 noindex。

迁移 0072 为 notebook_shares 增加站点列,并新增排除关系表。先执行迁移再部署。

Closes #591
This commit is contained in:
LuoQianhong authored and GitHub committed 2026-10-21 23:20:38 +08:00
1 parent 00ddaa0fed
commit 9ae88e3381
46 files changed
+4297 -512

No files matched your search

+1
View File
@@ -39,6 +39,7 @@ export type Bindings = {
};
export type WorkerBindings = Omit<Bindings, "storage" | "publicNetworkFetch" | "deploymentVersionCreatedAt"> & CloudflareStorageBindings & {
ASSETS: Fetcher;
CF_VERSION_METADATA?: { timestamp?: string };
};
+5
View File
@@ -120,6 +120,7 @@ import {
} from "./user-routes";
import { registerNotebookRoutes } from "./notebook-routes";
import { registerNotebookShareRoutes, registerPublicNotebookShareRoutes } from "./notebook-share-routes";
import { isNotebookSitePagePath, renderNotebookSitePage } from "./notebook-site-page";
import { registerMemoShareRoutes, registerPublicShareRoutes } from "./share-routes";
import { registerPublicTableFormRoutes, registerTableFormRoutes } from "./table-form-routes";
import {
@@ -454,6 +455,10 @@ export const fetchEdgeEverApp = async (
const worker = {
async fetch(request: Request, env: WorkerBindings, ctx: ExecutionContext) {
if (isNotebookSitePagePath(new URL(request.url).pathname)) {
return (await renderNotebookSitePage(request, createCloudflareStorageAdapter(env).db,
() => env.ASSETS.fetch(new Request(new URL("/", request.url), { method: "GET" }))))!;
}
return fetchEdgeEverApp(request, {
...env,
storage: createCloudflareStorageAdapter(env),
+242
View File
@@ -9,6 +9,8 @@ import {
} from "./notebook-share-routes";
import { hashPassword } from "./auth-crypto";
import { registerPublicShareRoutes } from "./share-routes";
import { renderNotebookSitePage } from "./notebook-site-page";
import { PUBLIC_PAGE_ROBOTS } from "@edgeever/shared/public-page-headers";
const databases = [];
afterEach(() => databases.splice(0).forEach((db) => db.close()));
@@ -78,6 +80,8 @@ const fixture = (legacyHash) => {
db.exec(readFileSync("migrations/0068_notebook_share_passwords.sql", "utf8"));
if (legacyHash) db.run("UPDATE notebook_shares SET password_hash = ?", [legacyHash]);
db.exec(readFileSync("migrations/0069_share_password_encryption.sql", "utf8"));
for (const file of migrations.filter((file) => file > "migrations/0069_share_password_encryption.sql"))
db.exec(readFileSync(file, "utf8"));
const environment = {
EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY: "test-share-master-key",
storage: createSelfHostedStorageAdapter(
@@ -120,6 +124,244 @@ const fixture = (legacyHash) => {
return { db, app, environment, request, addMemo, blobReads: () => blobReads };
};
const manage = (f, path, body, init = {}) => f.app.request(`/api/v1/notebooks/root/share${path}`, {
method: body === undefined ? "GET" : "PATCH",
...(body === undefined ? {} : { body: JSON.stringify(body), headers: { "content-type": "application/json" } }),
...init,
}, f.environment);
describe("public notebook site controls", () => {
test("old share upgrade preserves scope, token, and password while settings patch is independent", async () => {
const f = fixture(await hashPassword("old-password"));
const before = f.db.query("SELECT token, password_hash FROM notebook_shares").get();
const saved = await manage(f, "/site", { siteSlug: "My-Docs", settings: { footerText: "Example", sortMode: "title" } });
expect(saved.status).toBe(200);
expect((await saved.json()).share.siteSlug).toBe("my-docs");
await manage(f, "/site", { settings: { logoUrl: "https://example.org/logo.png" } });
const { share } = await (await manage(f, "")).json();
expect(share.siteSettings.footerText).toBe("Example");
expect(share.siteSettings.allowIndexing).toBe(false);
expect(f.db.query("SELECT token, password_hash FROM notebook_shares").get()).toEqual(before);
expect((await f.request()).status).toBe(403);
const unlocked = await f.request("/unlock", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ password: "old-password" }) });
const cookie = unlocked.headers.get("set-cookie").split(";")[0];
const directory = await (await f.request("", { headers: { cookie } })).json();
expect(directory.share.memos).toHaveLength(3);
});
test("exclusion gates every read path, keeps independent shares, and restores idempotently", async () => {
const f = fixture();
const child = await f.app.request("/api/v1/notebooks/child/share", { method: "POST" }, f.environment);
const childToken = (await child.json()).share.token;
expect((await manage(f, "/memos/memo_child", { excluded: true })).status).toBe(200);
expect((await manage(f, "/memos/memo_child", { excluded: true })).status).toBe(200);
const directory = await (await f.request()).json();
expect(directory.share.memos.map((m) => m.id)).not.toContain("memo_child");
for (const path of ["/memos/memo_child", "/memos/memo_child/preview", "/resources/res_memo_child/blob"])
expect((await f.request(path)).status).toBe(404);
expect(f.blobReads()).toBe(0);
const search = await (await f.request("/search?q=memo&limit=1")).json();
expect(search.memos).toHaveLength(1);
expect(search.memos[0].id).not.toBe("memo_child");
expect((await f.app.request(`/api/public/notebook-shares/${childToken}/memos/memo_child`, {}, f.environment)).status).toBe(200);
const excluded = await (await manage(f, "/excluded-memos")).json();
expect(excluded.memos.map((m) => m.id)).toEqual(["memo_child"]);
f.db.run("UPDATE memos SET tags_json = '[\"draft\"]' WHERE id = 'memo_child'");
f.db.run("UPDATE memos SET notebook_id = 'private' WHERE id = 'memo_child'");
f.db.run("UPDATE memos SET notebook_id = 'child' WHERE id = 'memo_child'");
expect((await f.request("/memos/memo_child")).status).toBe(404);
await manage(f, "/memos/memo_child", { excluded: false });
await manage(f, "/memos/memo_child", { excluded: false });
expect((await f.request("/memos/memo_child")).status).toBe(200);
expect((await f.request("/memos/memo_child/preview")).status).toBe(200);
});
test("original-note controls list live ancestor sites with independent exclusions and no new shares", async () => {
const f = fixture();
await f.app.request("/api/v1/notebooks/child/share", { method: "POST" }, f.environment);
const sites = (id = "memo_child", init = {}) => f.app.request(`/api/v1/memos/${id}/notebook-sites`, init, f.environment);
const response = await sites();
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("private, no-store");
expect((await response.json()).sites).toEqual([
{ notebookId: "child", notebookName: "child", excluded: false },
{ notebookId: "root", notebookName: "root", excluded: false },
]);
await manage(f, "/memos/memo_child", { excluded: true });
expect((await (await sites()).json()).sites).toEqual([
{ notebookId: "child", notebookName: "child", excluded: false },
{ notebookId: "root", notebookName: "root", excluded: true },
]);
expect((await f.request("/memos/memo_child")).status).toBe(404);
const childToken = f.db.query("SELECT token FROM notebook_shares WHERE notebook_id = 'child'").get().token;
expect((await f.app.request(`/api/public/notebook-shares/${childToken}/memos/memo_child`, {}, f.environment)).status).toBe(200);
expect((await (await sites("memo_grand")).json()).sites).toHaveLength(2);
expect(f.db.query("SELECT COUNT(*) AS count FROM memo_shares").get().count).toBe(1);
expect((await (await sites("memo_private")).json()).sites).toEqual([]);
f.db.run("UPDATE memos SET notebook_id = 'private' WHERE id = 'memo_child'");
f.db.run("UPDATE notebooks SET parent_id = 'foreign' WHERE id = 'private'");
expect((await (await sites()).json()).sites).toEqual([]);
f.db.run("UPDATE notebooks SET is_deleted = 1 WHERE id = 'child'");
expect((await (await sites("memo_grand")).json()).sites).toEqual([]);
for (const kind of ["none", "agent"])
expect((await sites("memo_grand", { headers: { "test-auth": kind } })).status).toBe(403);
for (const id of ["missing", "memo_other"])
expect((await sites(id)).status).toBe(404);
f.db.run("UPDATE memos SET is_deleted = 1 WHERE id = 'memo_grand'");
expect((await sites("memo_grand")).status).toBe(404);
});
test("owner content management filters before paging and reports exclusions beyond the first page", async () => {
const f = fixture();
for (let i = 0; i < 105; i++) f.addMemo(`bulk_${String(i).padStart(3, "0")}`, "child");
await manage(f, "/memos/bulk_075", { excluded: true });
await manage(f, "/memos/memo_child", { excluded: true });
const firstResponse = await manage(f, "/memos?limit=20");
expect(firstResponse.status).toBe(200);
const first = await firstResponse.json();
expect(first.memos).toHaveLength(20);
expect(first.nextCursor).toBe("bulk_019");
expect(first.memos.every((memo) => typeof memo.excluded === "boolean")).toBe(true);
expect(first.memos[0].contentJson).toBeUndefined();
const later = await (await manage(f, "/memos?limit=20&cursor=bulk_074")).json();
expect(later.memos[0]).toMatchObject({ id: "bulk_075", notebookId: "child", notebookName: "child", excluded: true });
const excluded = await (await manage(f, "/memos?visibility=excluded&limit=1")).json();
expect(excluded.memos.map((memo) => memo.id)).toEqual(["bulk_075"]);
const nextExcluded = await (await manage(f, `/memos?visibility=excluded&limit=1&cursor=${excluded.nextCursor}`)).json();
expect(nextExcluded.memos.map((memo) => memo.id)).toEqual(["memo_child"]);
expect(nextExcluded.nextCursor).toBeNull();
f.db.run("UPDATE memo_contents SET content_text = 'OwnerBodyNeedle 中文正文检索' WHERE memo_id = 'bulk_075'");
for (const q of ["OwnerBodyNeedle", "正文检索"]) {
const found = await (await manage(f, `/memos?q=${encodeURIComponent(q)}`)).json();
expect(found.memos.map((memo) => memo.id)).toEqual(["bulk_075"]);
const picker = await (await manage(f, `/memos?visibility=public&q=${encodeURIComponent(q)}`)).json();
expect(picker.memos).toHaveLength(0);
}
expect((await manage(f, "/memos?visibility=invalid")).status).toBe(400);
for (const kind of ["none", "agent"])
expect((await manage(f, "/memos", undefined, { headers: { "test-auth": kind } })).status).toBe(403);
expect((await f.app.request("/api/v1/notebooks/foreign/share/memos", {}, f.environment)).status).toBe(404);
f.db.run("UPDATE notebooks SET is_deleted = 1 WHERE id = 'root'");
expect((await manage(f, "/memos")).status).toBe(404);
});
test("body and Chinese search filter before paging and combine with exact tags", async () => {
const f = fixture();
for (const id of ["memo_root", "memo_child", "memo_grand"]) {
f.db.run("UPDATE memo_contents SET content_text = 'OnlyBodyWord 中文检索内容' WHERE memo_id = ?", [id]);
f.db.run("INSERT OR REPLACE INTO memo_search_documents (memo_id, title, content_text, tags) VALUES (?, ?, 'OnlyBodyWord 中文检索内容', '')", [id, id]);
}
f.db.run(`UPDATE memos SET tags_json = '["guide"]' WHERE id = 'memo_root'`);
await manage(f, "/memos/memo_child", { excluded: true });
for (const q of ["OnlyBodyWord", "检索"]) {
const result = await (await f.request(`/search?q=${encodeURIComponent(q)}&limit=1`)).json();
expect(result.memos).toHaveLength(1);
expect(result.memos[0].excerpt).toContain(q);
expect(result.nextCursor).not.toBeNull();
const page2 = await (await f.request(`/search?q=${encodeURIComponent(q)}&limit=1&cursor=${result.nextCursor}`)).json();
expect(page2.memos).toHaveLength(1);
expect(page2.memos[0].id).not.toBe(result.memos[0].id);
expect(page2.nextCursor).toBeNull();
}
const tagged = await (await f.request("/search?q=OnlyBodyWord&tag=guide")).json();
expect(tagged.memos.map((m) => m.id)).toEqual(["memo_root"]);
const longBody = "prefix ".repeat(90) + "Alpha different words Omega";
f.db.run("UPDATE memo_contents SET content_text = ? WHERE memo_id = 'memo_root'", [longBody]);
f.db.run("UPDATE memo_search_documents SET content_text = ? WHERE memo_id = 'memo_root'", [longBody]);
const multiword = await (await f.request("/search?q=Alpha%20Omega")).json();
expect(multiword.memos[0].excerpt).toContain("Alpha");
expect(multiword.memos[0].excerpt).toContain("Omega");
});
test("rejects invalid targets, unknown settings, non-HTTPS URLs and oversized merged settings", async () => {
const f = fixture();
for (const id of ["memo_private", "memo_other", "missing"])
expect((await manage(f, `/memos/${id}`, { excluded: true })).status).toBe(404);
for (const settings of [{ homeMemoId: "memo_private" }, { excludedTags: ["draft"] }, { logoUrl: "javascript:alert(1)" }, { logoUrl: "broken" }, { headerLinks: [{ label: "x", url: "broken" }] }, { headerLinks: [{ label: "x", url: "http://example.org" }] }, { footerText: "x".repeat(17_000) }])
expect((await manage(f, "/site", { settings })).status).toBe(400);
expect((await manage(f, "/site", { settings: { footerText: "ok" } }, { headers: { "content-type": "application/json", "test-auth": "agent" } })).status).toBe(403);
expect((await manage(f, "/site", { settings: { footerText: "ok" } }, { headers: { "content-type": "application/json", "test-auth": "none" } })).status).toBe(403);
});
test("homepages fall back on exclusion and aliases disclose identity only", async () => {
const f = fixture();
await manage(f, "/site", { siteSlug: "My-Docs", settings: { homeMemoId: "memo_child" } });
expect((await (await f.request()).json()).share.settings.homeMemoId).toBe("memo_child");
await manage(f, "/memos/memo_child", { excluded: true });
expect((await (await f.request()).json()).share.settings.homeMemoId).toBeNull();
expect((await manage(f, "/site", { settings: { homeMemoId: "memo_child" } })).status).toBe(400);
const alias = await f.app.request("/api/public/notebook-sites/my-docs/resolve", {}, f.environment);
expect(await alias.json()).toEqual({ token });
const child = await f.app.request("/api/v1/notebooks/child/share", { method: "POST" }, f.environment);
expect(child.status).toBe(200);
const collision = await f.app.request("/api/v1/notebooks/child/share/site", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ siteSlug: "MY-DOCS" }) }, f.environment);
expect(collision.status).toBe(409);
await f.app.request("/api/v1/notebooks/root/share", { method: "DELETE" }, f.environment);
expect((await f.app.request("/api/public/notebook-sites/my-docs/resolve", {}, f.environment)).status).toBe(404);
expect(f.db.query("SELECT count(*) AS count FROM notebook_share_excluded_memos").get().count).toBe(0);
});
test("enabling or rotating passwords disables indexing and clearing does not re-enable it", async () => {
const f = fixture();
await manage(f, "/site", { settings: { allowIndexing: true } });
expect((await (await f.request()).json()).share.settings.allowIndexing).toBe(true);
await manage(f, "", { passwordProtected: true });
expect((await manage(f, "/site", { settings: { allowIndexing: true } })).status).toBe(400);
await manage(f, "", { passwordProtected: false });
expect((await (await f.request()).json()).share.settings.allowIndexing).toBe(false);
});
test("server HTML escapes metadata, preserves protected privacy, and denies excluded aliases", async () => {
const f = fixture();
f.db.run("UPDATE memos SET title = ?, excerpt = ? WHERE id = 'memo_child'", ["<script>secret()</script> $& $' $$", 'A & B "quote"']);
await manage(f, "/site", { siteSlug: "docs", settings: { allowIndexing: true, homeMemoId: "memo_child" } });
const render = (path) => renderNotebookSitePage(new Request(`https://example.org${path}`), f.environment.storage.db,
async () => new Response('<html><head><title>Old</title><meta name="description" content="old"></head><body><div id="root"></div></body></html>', { headers: { "content-type": "text/html" } }));
const visible = await render("/s/docs/memo_child");
expect(visible.status).toBe(200);
expect(visible.headers.get("X-Robots-Tag")).toBe("index, follow");
const html = await visible.text();
expect(html).toContain("&lt;script&gt;secret()&lt;/script&gt;");
expect(html).toContain("&lt;script&gt;secret()&lt;/script&gt; $&amp; $&#39; $$");
expect(html).not.toContain("<script>secret()");
expect(html).toContain('rel="canonical" href="https://example.org/s/docs/memo_child"');
expect(html).toContain('property="og:description" content="A &amp; B &quot;quote&quot;"');
for (const suffix of ["", "/memo_child"]) {
const tokenPage = await render(`/share/notebook/${token}${suffix}`);
expect(tokenPage.status).toBe(200);
expect(tokenPage.headers.get("X-Robots-Tag")).toBe(PUBLIC_PAGE_ROBOTS);
const tokenHtml = await tokenPage.text();
expect(tokenHtml).toContain(`<meta name="robots" content="${PUBLIC_PAGE_ROBOTS}">`);
expect(tokenHtml).toContain(`rel="canonical" href="https://example.org/s/docs${suffix}"`);
}
await manage(f, "/memos/memo_child", { excluded: true });
const hidden = await render("/s/docs/memo_child");
expect(hidden.status).toBe(404);
expect(hidden.headers.get("X-Robots-Tag")).toBe(PUBLIC_PAGE_ROBOTS);
expect(await hidden.text()).not.toContain("secret()");
await manage(f, "", { passwordProtected: true });
const protectedPage = await render("/s/docs");
expect(protectedPage.headers.get("X-Robots-Tag")).toBe(PUBLIC_PAGE_ROBOTS);
expect(await protectedPage.text()).not.toContain("secret()");
expect(await render("/share/individual-token")).toBeNull();
});
test("Token pages without an alias remain noindex after opting in", async () => {
const f = fixture();
await manage(f, "/site", { settings: { allowIndexing: true } });
for (const suffix of ["", "/memo_child"]) {
const url = `https://example.org/share/notebook/${token}${suffix}`;
const response = await renderNotebookSitePage(new Request(url), f.environment.storage.db,
async () => new Response("<html><head></head><body></body></html>"));
expect(response.status).toBe(200);
expect(response.headers.get("X-Robots-Tag")).toBe(PUBLIC_PAGE_ROBOTS);
const html = await response.text();
expect(html).toContain(`<meta name="robots" content="${PUBLIC_PAGE_ROBOTS}">`);
expect(html).toContain(`rel="canonical" href="${url}"`);
}
});
});
describe("live recursive notebook sharing", () => {
test("upgrades existing data without creating individual shares; exposes only the live workspace subtree", async () => {
const { db, request } = fixture();
+210 -29
View File
@@ -1,5 +1,13 @@
import {
NotebookShareUpdateSchema,
NotebookSiteUpdateSchema,
NotebookSiteExclusionSchema,
readNotebookSiteSettings,
compareNotebookSiteTitles,
type NotebookSiteMemoPreview,
type NotebookSiteSearchResult,
type NotebookSiteContents,
type MemoNotebookSite,
PublicShareUnlockSchema,
collectMemoLinkIds,
accountNoteProseFromRow,
@@ -26,19 +34,13 @@ import { allowPasswordProtectedShare, setShareAccessCookie } from "./share-http-
import { isShareUnlockBlocked, nextShareUnlockFailure, type ShareUnlockState } from "./share-access";
import { encryptSharePassword, readSharePassword } from "./share-password-encryption";
import { generateSharePassword } from "./share-password";
// Every public query starts at the live share, and constrains every level to
// its workspace. Deleted ancestors stop traversal; UNION also tolerates cycles.
const publicTreeSql = `WITH RECURSIVE tree(id, workspace_id) AS (
SELECT n.id, n.workspace_id FROM notebook_shares ns
JOIN notebooks n ON n.id = ns.notebook_id AND n.workspace_id = ns.workspace_id
WHERE ns.token = ? AND n.is_deleted = 0
UNION
SELECT n.id, n.workspace_id FROM notebooks n JOIN tree t ON n.parent_id = t.id
WHERE n.workspace_id = t.workspace_id AND n.is_deleted = 0
)`;
import { publicTreeSql, getNotebookSite, resolveNotebookSiteToken } from "./notebook-site-service";
import { toFtsQuery, escapeLike } from "./memo-list-service";
type ShareRow = {
id: string;
site_slug: string | null;
site_settings_json: string;
password_hash: string | null;
password_encrypted: string | null;
notebook_id: string;
@@ -48,6 +50,8 @@ type ShareRow = {
updated_at: string;
};
const mapShare = (row: ShareRow, password?: string): NotebookShare => ({
siteSlug: row.site_slug,
siteSettings: readNotebookSiteSettings(row.site_settings_json),
passwordProtected: Boolean(row.password_hash),
...(password ? { password } : {}),
notebookId: row.notebook_id,
@@ -56,12 +60,144 @@ const mapShare = (row: ShareRow, password?: string): NotebookShare => ({
createdAt: row.created_at,
updatedAt: row.updated_at,
});
const selectShareSql = `SELECT ns.notebook_id, n.name AS notebook_name, ns.password_encrypted, ns.password_hash, ns.token, ns.created_at, ns.updated_at
const selectShareSql = `SELECT ns.id, ns.site_slug, ns.site_settings_json, ns.notebook_id, n.name AS notebook_name, ns.password_encrypted, ns.password_hash, ns.token, ns.created_at, ns.updated_at
FROM notebook_shares ns JOIN notebooks n ON n.id = ns.notebook_id AND n.workspace_id = ns.workspace_id
WHERE ns.workspace_id = ? AND n.is_deleted = 0`;
const validToken = (token: string) => /^[A-Za-z0-9_-]{43}$/.test(token);
const pageSize = (value?: string) => Math.max(1, Math.min(100, Math.floor(Number(value) || 50)));
export const registerNotebookShareRoutes = (app: Hono<AppEnv>) => {
app.get("/api/v1/memos/:id/notebook-sites", async (c) => {
const denied = requireUser(c);
if (denied) return denied;
const db = c.env.storage.db;
const memoId = c.req.param("id");
const workspaceId = getWorkspaceId(c);
const memo = await db.prepare("SELECT notebook_id FROM memos WHERE id = ? AND workspace_id = ? AND is_deleted = 0")
.bind(memoId, workspaceId).first<{ notebook_id: string | null }>();
if (!memo) return notFound(c, "Memo not found");
const rows = await db.prepare(`WITH RECURSIVE ancestors(id, parent_id, workspace_id, name) AS (
SELECT id, parent_id, workspace_id, name FROM notebooks WHERE id = ? AND workspace_id = ? AND is_deleted = 0
UNION
SELECT n.id, n.parent_id, n.workspace_id, n.name FROM notebooks n JOIN ancestors a
ON n.id = a.parent_id AND n.workspace_id = a.workspace_id WHERE n.is_deleted = 0
)
SELECT n.id AS notebookId, n.name AS notebookName,
EXISTS (SELECT 1 FROM notebook_share_excluded_memos e WHERE e.share_id = ns.id AND e.memo_id = ?) AS excluded
FROM ancestors n JOIN notebook_shares ns ON ns.notebook_id = n.id AND ns.workspace_id = n.workspace_id
ORDER BY n.id`).bind(memo.notebook_id, workspaceId, memoId)
.all<Omit<MemoNotebookSite, "excluded"> & { excluded: number }>();
c.header("Cache-Control", "private, no-store");
return c.json({ sites: rows.results.map((site) => ({ ...site, excluded: Boolean(site.excluded) })) });
});
app.get("/api/v1/notebooks/:id/share/memos", async (c) => {
const denied = requireUser(c);
if (denied) return denied;
const db = c.env.storage.db;
const share = await db.prepare(`${selectShareSql} AND ns.notebook_id = ?`)
.bind(getWorkspaceId(c), c.req.param("id")).first<ShareRow>();
if (!share) return notFound(c, "Active share not found");
const visibility = c.req.query("visibility") ?? "all";
if (!["all", "public", "excluded"].includes(visibility))
return apiError(c, "invalid_site_visibility", "Unknown content visibility filter", 400);
const limit = pageSize(c.req.query("limit"));
const query = c.req.query("q")?.trim() ?? "";
const excludedSql = "EXISTS (SELECT 1 FROM notebook_share_excluded_memos e WHERE e.share_id = t.share_id AND e.memo_id = m.id)";
const conditions = ["m.is_deleted = 0", "m.id > ?"];
const binds: unknown[] = [share.token, c.req.query("cursor") ?? ""];
if (visibility !== "all") conditions.push(`${visibility === "public" ? "NOT " : ""}${excludedSql}`);
if (query) {
const fts = toFtsQuery(query);
const like = `%${escapeLike(query)}%`;
conditions.push(`(m.title LIKE ? ESCAPE '\\' OR mc.content_text LIKE ? ESCAPE '\\' OR mc.content_markdown LIKE ? ESCAPE '\\'${fts ? " OR m.id IN (SELECT memo_id FROM memos_fts WHERE memos_fts MATCH ?)" : ""})`);
binds.push(like, like, like, ...(fts ? [fts] : []));
}
const rows = await db.prepare(`${publicTreeSql}
SELECT m.id, m.title, m.notebook_id AS notebookId, n.name AS notebookName, ${excludedSql} AS excluded
FROM memos m JOIN tree t ON m.notebook_id = t.id AND m.workspace_id = t.workspace_id
JOIN notebooks n ON n.id = m.notebook_id
${query ? "LEFT JOIN memo_contents mc ON mc.memo_id = m.id" : ""}
WHERE ${conditions.join(" AND ")} ORDER BY m.id LIMIT ?`)
.bind(...binds, limit + 1).all<Omit<NotebookSiteContents["memos"][number], "excluded"> & { excluded: number }>();
const memos = rows.results.slice(0, limit).map((memo) => ({ ...memo, excluded: Boolean(memo.excluded) }));
c.header("Cache-Control", "private, no-store");
return c.json({ memos, nextCursor: rows.results.length > limit ? memos.at(-1)!.id : null } satisfies NotebookSiteContents);
});
app.patch("/api/v1/notebooks/:id/share/site", zValidator("json", NotebookSiteUpdateSchema), async (c) => {
const denied = requireUser(c);
if (denied) return denied;
const db = c.env.storage.db;
const existing = await db.prepare(`${selectShareSql} AND ns.notebook_id = ?`)
.bind(getWorkspaceId(c), c.req.param("id")).first<ShareRow>();
if (!existing) return notFound(c, "Active share not found");
const input = c.req.valid("json");
const settings = { ...readNotebookSiteSettings(existing.site_settings_json), ...input.settings };
if (new TextEncoder().encode(JSON.stringify(settings)).length > 16 * 1024)
return apiError(c, "invalid_site_settings", "Site settings exceed 16 KiB", 400);
if (input.settings?.allowIndexing && existing.password_hash)
return apiError(c, "invalid_site_settings", "Password-protected sites cannot enable indexing", 400);
if (input.settings?.homeMemoId && !await db.prepare(`${publicTreeSql} SELECT id FROM visible_memos WHERE id = ?`)
.bind(existing.token, input.settings.homeMemoId).first())
return apiError(c, "invalid_site_homepage", "Homepage must be a visible note in this site", 400);
const patch = JSON.stringify(input.settings ?? {});
try {
const updated = await db.prepare(`UPDATE notebook_shares SET
site_slug = CASE WHEN ? THEN ? ELSE site_slug END,
site_settings_json = json_patch(site_settings_json, ?), updated_at = ?
WHERE id = ? AND workspace_id = ?
AND length(CAST(json_patch(site_settings_json, ?) AS BLOB)) <= 16384
AND (? = 0 OR password_hash IS NULL) RETURNING id`)
.bind(input.siteSlug !== undefined ? 1 : 0, input.siteSlug || null, patch, isoNow(), existing.id,
getWorkspaceId(c), patch, input.settings?.allowIndexing ? 1 : 0).first();
if (!updated) return apiError(c, "invalid_site_settings", "Settings could not be saved; refresh and try again", 400);
} catch (error) {
if (String(error).includes("UNIQUE constraint failed"))
return apiError(c, "site_slug_conflict", "This site alias is already in use", 409);
throw error;
}
const row = await db.prepare(`${selectShareSql} AND ns.notebook_id = ?`)
.bind(getWorkspaceId(c), c.req.param("id")).first<ShareRow>();
c.header("Cache-Control", "private, no-store");
return c.json({ share: row ? mapShare(row, row.password_hash ? await readSharePassword(row.password_encrypted, row.token, c.env, row.password_hash) : undefined) : null });
});
app.get("/api/v1/notebooks/:id/share/excluded-memos", async (c) => {
const denied = requireUser(c);
if (denied) return denied;
const limit = pageSize(c.req.query("limit"));
const rows = await c.env.storage.db.prepare(`SELECT m.id, m.title FROM notebook_share_excluded_memos e
JOIN notebook_shares ns ON ns.id = e.share_id
JOIN notebooks n ON n.id = ns.notebook_id AND n.workspace_id = ns.workspace_id
JOIN memos m ON m.id = e.memo_id AND m.workspace_id = ns.workspace_id
WHERE ns.workspace_id = ? AND ns.notebook_id = ? AND n.is_deleted = 0 AND m.is_deleted = 0
AND m.id > ? ORDER BY m.id LIMIT ?`)
.bind(getWorkspaceId(c), c.req.param("id"), c.req.query("cursor") ?? "", limit + 1)
.all<{ id: string; title: string | null }>();
const memos = rows.results.slice(0, limit);
c.header("Cache-Control", "private, no-store");
return c.json({ memos, nextCursor: rows.results.length > limit ? memos.at(-1)!.id : null });
});
app.patch("/api/v1/notebooks/:id/share/memos/:memoId", zValidator("json", NotebookSiteExclusionSchema), async (c) => {
const denied = requireUser(c);
if (denied) return denied;
const db = c.env.storage.db;
const share = await db.prepare(`${selectShareSql} AND ns.notebook_id = ?`)
.bind(getWorkspaceId(c), c.req.param("id")).first<ShareRow>();
if (!share) return notFound(c, "Active share not found");
const memoId = c.req.param("memoId");
const { excluded } = c.req.valid("json");
if (excluded) {
// The tree query deliberately ignores exclusions when selecting a target.
const target = await db.prepare(`${publicTreeSql} SELECT m.id FROM memos m JOIN tree t
ON m.notebook_id = t.id AND m.workspace_id = t.workspace_id WHERE m.id = ? AND m.is_deleted = 0`)
.bind(share.token, memoId).first();
if (!target) return notFound(c, "Note not found in this notebook share");
await db.prepare("INSERT OR IGNORE INTO notebook_share_excluded_memos (share_id, memo_id) VALUES (?, ?)").bind(share.id, memoId).run();
} else {
await db.prepare("DELETE FROM notebook_share_excluded_memos WHERE share_id = ? AND memo_id = ?").bind(share.id, memoId).run();
}
c.header("Cache-Control", "private, no-store");
return c.json({ memoId, excluded });
});
app.get("/api/v1/notebook-shares", async (c) => {
const denied = requireUser(c);
if (denied) return denied;
@@ -142,14 +278,15 @@ export const registerNotebookShareRoutes = (app: Hono<AppEnv>) => {
const encrypted = password ? await encryptSharePassword(password, existing.token, c.env) : null;
const now = isoNow();
await c.env.storage.db.prepare(`UPDATE notebook_shares SET password_hash = ?, password_encrypted = ?, unlock_failed_count = 0,
unlock_window_started_at = NULL, unlock_blocked_until = NULL, updated_at = ?
WHERE notebook_id = ? AND workspace_id = ?`).bind(passwordHash, encrypted, now, notebookId, workspaceId).run();
unlock_window_started_at = NULL, unlock_blocked_until = NULL,
site_settings_json = CASE WHEN ? IS NOT NULL THEN json_set(site_settings_json, '$.allowIndexing', json('false')) ELSE site_settings_json END, updated_at = ?
WHERE notebook_id = ? AND workspace_id = ?`).bind(passwordHash, encrypted, passwordHash, now, notebookId, workspaceId).run();
const actor = getAuditActor(c);
await audit(c.env.storage.db, actor.actorType, actor.actorId,
password ? (existing.password_hash ? "notebook.share_password_rotate" : "notebook.share_password_set") : "notebook.share_password_clear",
"notebook", notebookId, {});
c.header("Cache-Control", "private, no-store");
return c.json({ share: mapShare({ ...existing, password_hash: passwordHash, updated_at: now }, password) });
return c.json({ share: mapShare({ ...existing, password_hash: passwordHash, site_settings_json: passwordHash ? JSON.stringify({ ...readNotebookSiteSettings(existing.site_settings_json), allowIndexing: false }) : existing.site_settings_json, updated_at: now }, password) });
});
app.delete("/api/v1/notebooks/:id/share", async (c) => {
const denied = requireUser(c);
@@ -175,6 +312,12 @@ export const registerNotebookShareRoutes = (app: Hono<AppEnv>) => {
};
export const registerPublicNotebookShareRoutes = (app: Hono<AppEnv>) => {
app.get("/api/public/notebook-sites/:slug/resolve", async (c) => {
c.header("Cache-Control", "private, no-store");
c.header("X-Robots-Tag", "noindex, nofollow, noarchive");
const identity = await resolveNotebookSiteToken(c.env.storage.db, c.req.param("slug"));
return identity ? c.json(identity) : notFound(c, "Shared notebook not found");
});
app.use("/api/public/notebook-shares/*", async (c, next) => {
c.header("Cache-Control", "private, no-store");
c.header("X-Robots-Tag", "noindex, nofollow, noarchive");
@@ -211,17 +354,49 @@ export const registerPublicNotebookShareRoutes = (app: Hono<AppEnv>) => {
await setShareAccessCookie(c, token, gate.password_hash, `/api/public/notebook-shares/${token}`);
return c.json({ ok: true });
});
app.get("/api/public/notebook-shares/:token/search", async (c) => {
const token = c.req.param("token");
const query = c.req.query("q")?.trim() ?? "";
const tag = c.req.query("tag")?.trim() ?? "";
const limit = pageSize(c.req.query("limit"));
const cursor = c.req.query("cursor") ?? "0";
const offset = /^\d+$/.test(cursor) && Number.isSafeInteger(Number(cursor)) ? Number(cursor) : 0;
const fts = toFtsQuery(query);
const like = `%${escapeLike(query)}%`;
const terms = query ? [...new Set([query, ...(query.match(/[\p{L}\p{N}_]+/gu) ?? []).slice(0, 8)])] : [];
const bodySql = "COALESCE(NULLIF(mc.content_text, ''), mc.content_markdown, '')";
const positions = terms.map(() => `coalesce(nullif(instr(lower(${bodySql}), lower(?)), 0), 2147483647)`);
const position = positions.length > 1 ? `min(${positions.join(",")})` : positions[0] ?? "1";
const conditions: string[] = [];
const binds: unknown[] = [token];
if (query) {
conditions.push(`(m.title LIKE ? ESCAPE '\\' OR mc.content_text LIKE ? ESCAPE '\\' OR mc.content_markdown LIKE ? ESCAPE '\\'${fts ? " OR m.id IN (SELECT memo_id FROM memos_fts WHERE memos_fts MATCH ?)" : ""})`);
binds.push(like, like, like, ...(fts ? [fts] : []));
}
if (tag) {
conditions.push("EXISTS (SELECT 1 FROM memo_tags mt WHERE mt.memo_id = m.id AND mt.workspace_id = m.workspace_id AND mt.normalized_name = lower(?))");
binds.push(tag);
}
const rows = await c.env.storage.db.prepare(`${publicTreeSql}
SELECT m.id, m.notebook_id AS notebookId, m.title, m.excerpt,
substr(${bodySql}, max(1, ${position} - 60), 240) AS snippet
FROM visible_memos m LEFT JOIN memo_contents mc ON mc.memo_id = m.id
${conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""}
ORDER BY m.updated_at DESC, m.id LIMIT ? OFFSET ?`)
.bind(token, ...terms, ...binds.slice(1), limit + 1, offset)
.all<NotebookSiteSearchResult["memos"][number] & { snippet: string }>();
const memos = rows.results.slice(0, limit).map(({ snippet, ...row }) => ({ ...row, excerpt: snippet || row.excerpt || "", highlights: query ? [query, ...(query.match(/[\p{L}\p{N}_]+/gu) ?? [])] : [] }));
return c.json({ memos, nextCursor: rows.results.length > limit ? String(offset + limit) : null } satisfies NotebookSiteSearchResult);
});
app.get("/api/public/notebook-shares/:token/memos/:memoId/preview", async (c) => {
const row = await c.env.storage.db.prepare(`${publicTreeSql} SELECT id, title, excerpt FROM visible_memos WHERE id = ?`)
.bind(c.req.param("token"), c.req.param("memoId")).first<NotebookSiteMemoPreview>();
return row ? c.json({ memo: row }) : notFound(c, "Shared note not found");
});
app.get("/api/public/notebook-shares/:token", async (c) => {
const token = c.req.param("token");
if (!validToken(token)) return notFound(c, "Shared notebook not found");
const root = await c.env.storage.db
.prepare(
`SELECT n.id, n.name FROM notebook_shares ns
JOIN notebooks n ON n.id = ns.notebook_id AND n.workspace_id = ns.workspace_id
WHERE ns.token = ? AND n.is_deleted = 0`,
)
.bind(token)
.first<{ id: string; name: string }>();
const root = await getNotebookSite(c.env.storage.db, token);
if (!root) return notFound(c, "Shared notebook not found");
const notebooks = await c.env.storage.db
.prepare(
@@ -236,17 +411,23 @@ export const registerPublicNotebookShareRoutes = (app: Hono<AppEnv>) => {
const memos = await c.env.storage.db
.prepare(
`${publicTreeSql}
SELECT m.id, m.notebook_id AS notebookId, m.title FROM memos m JOIN tree t ON m.notebook_id = t.id AND m.workspace_id = t.workspace_id
SELECT m.id, m.notebook_id AS notebookId, m.title FROM visible_memos m
WHERE m.is_deleted = 0 ORDER BY m.is_pinned DESC, m.updated_at DESC, m.id`,
)
.bind(token)
.all<PublicNotebookShare["memos"][number]>();
if (root.settings.sortMode === "title") {
notebooks.results.sort(compareNotebookSiteTitles);
memos.results.sort(compareNotebookSiteTitles);
}
const share: PublicNotebookShare = {
notebookId: root.id,
notebookId: root.notebook_id,
siteSlug: root.site_slug,
settings: root.settings,
name: root.name,
notebooks: notebooks.results.map((n) => ({
...n,
parentId: n.id === root.id ? null : n.parentId,
parentId: n.id === root.notebook_id ? null : n.parentId,
})),
memos: memos.results,
};
@@ -260,7 +441,7 @@ export const registerPublicNotebookShareRoutes = (app: Hono<AppEnv>) => {
`${publicTreeSql}
SELECT m.title, mc.content_json, mc.content_markdown, m.tags_json, m.updated_at, u.note_body_font,
u.note_prose_font_size, u.note_prose_line_height, u.note_prose_palette, u.note_prose_custom_css, u.note_prose_custom_colors
FROM memos m JOIN tree t ON m.notebook_id = t.id AND m.workspace_id = t.workspace_id
FROM visible_memos m
JOIN memo_contents mc ON mc.memo_id = m.id
JOIN notebook_shares ns ON ns.token = ?
LEFT JOIN users u ON u.id = ns.created_by
@@ -288,7 +469,7 @@ export const registerPublicNotebookShareRoutes = (app: Hono<AppEnv>) => {
const linked = await c.env.storage.db
.prepare(
`${publicTreeSql}
SELECT m.id FROM memos m JOIN tree t ON m.notebook_id = t.id AND m.workspace_id = t.workspace_id
SELECT m.id FROM visible_memos m
WHERE m.is_deleted = 0 AND m.id IN (${batch.map(() => "?").join(",")})`,
)
.bind(token, ...batch)
@@ -327,7 +508,7 @@ export const registerPublicNotebookShareRoutes = (app: Hono<AppEnv>) => {
SELECT r.object_key, r.storage_config_id, r.kind, r.mime_type, r.filename, r.byte_size,
m.workspace_id, NULL AS password_hash
FROM resources r JOIN memos m ON r.memo_id = m.id
JOIN tree t ON m.notebook_id = t.id AND m.workspace_id = t.workspace_id
JOIN visible_memos visible ON visible.id = m.id
WHERE r.id = ? AND r.is_deleted = 0 AND m.is_deleted = 0`,
)
.bind(token, c.req.param("resourceId"))
+74
View File
@@ -0,0 +1,74 @@
import { PUBLIC_PAGE_HEADERS, PUBLIC_PAGE_ROBOTS } from "@edgeever/shared/public-page-headers";
import type { DatabaseAdapter } from "./storage-contract";
import { getNotebookSite, publicTreeSql, resolveNotebookSiteToken } from "./notebook-site-service";
export const isNotebookSitePagePath = (path: string) => /^\/(?:share\/notebook|s)\/[^/]+(?:\/[^/]+)?\/?$/.test(path);
const escapeHtml = (text: string) => text.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]!);
/** Shared business logic; runtime adapters supply only their app-shell loader. */
export const renderNotebookSitePage = async (
request: Request,
db: DatabaseAdapter,
loadTemplate: () => Promise<Response>,
): Promise<Response | null> => {
const url = new URL(request.url);
if (!isNotebookSitePagePath(url.pathname)) return null;
const alias = url.pathname.startsWith("/s/");
const parts = url.pathname.split("/");
const identity = parts[alias ? 2 : 3];
const memoId = parts[alias ? 3 : 4];
const resolved = alias ? await resolveNotebookSiteToken(db, identity) : { token: identity };
const site = resolved ? await getNotebookSite(db, resolved.token) : null;
let status = site ? 200 : 404;
let title = "Shared notebook · EdgeEver";
let description = "Read a shared notebook on EdgeEver.";
let indexable = false;
let canonical = `${url.origin}${url.pathname}`;
let image = new URL("/apple-touch-icon.png", url).href;
if (site && !site.password_hash) {
const selected = memoId || site.settings.homeMemoId;
const note = selected ? await db.prepare(`${publicTreeSql} SELECT title, excerpt FROM visible_memos WHERE id = ?`)
.bind(site.token, selected).first<{ title: string | null; excerpt: string }>() : null;
if (memoId && !note) status = 404;
if (status === 200) {
title = `${note?.title?.trim() || site.name} · EdgeEver`;
description = note?.excerpt || site.name;
indexable = alias && site.settings.allowIndexing;
const base = site.site_slug ? `/s/${encodeURIComponent(site.site_slug)}` : `/share/notebook/${encodeURIComponent(site.token)}`;
canonical = `${url.origin}${base}${memoId ? `/${encodeURIComponent(memoId)}` : ""}`;
image = site.settings.logoUrl || image;
}
}
const robots = indexable ? "index, follow" : PUBLIC_PAGE_ROBOTS;
const metadata = `<title>${escapeHtml(title)}</title>
<meta name="description" content="${escapeHtml(description)}">
<meta name="robots" content="${robots}">
<link rel="canonical" href="${escapeHtml(canonical)}">
<meta property="og:type" content="${memoId ? "article" : "website"}">
<meta property="og:title" content="${escapeHtml(title)}">
<meta property="og:description" content="${escapeHtml(description)}">
<meta property="og:url" content="${escapeHtml(canonical)}">
<meta property="og:image" content="${escapeHtml(image)}">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="${escapeHtml(title)}">
<meta name="twitter:description" content="${escapeHtml(description)}">
<meta name="twitter:image" content="${escapeHtml(image)}">`;
const template = await loadTemplate();
if (!template.ok) return template;
const html = (await template.text())
.replace(/<title\b[^>]*>[\s\S]*?<\/title>/gi, "")
.replace(/<meta\b[^>]*(?:name|property)=["'](?:description|robots|og:[^"']+|twitter:[^"']+)["'][^>]*>/gi, "")
.replace(/<link\b[^>]*rel=["']canonical["'][^>]*>/gi, "")
.replace(/<\/head>/i, () => `${metadata}</head>`);
const headers = new Headers(template.headers);
headers.delete("Content-Length");
headers.delete("ETag");
headers.delete("Content-Encoding");
headers.delete("Last-Modified");
headers.delete("Age");
headers.delete("CF-Cache-Status");
for (const [key, value] of Object.entries(PUBLIC_PAGE_HEADERS)) headers.set(key, value);
headers.set("Content-Type", "text/html; charset=utf-8");
headers.set("X-Robots-Tag", robots);
return new Response(request.method === "HEAD" ? null : html, { status, headers });
};
+39
View File
@@ -0,0 +1,39 @@
import { readNotebookSiteSettings, type NotebookSiteSettings } from "@edgeever/shared";
import type { DatabaseAdapter } from "./storage-contract";
// Every consumer starts at the live share. Deleted ancestors and inconsistent
// workspace references stop recursion; exclusions are scoped to that share.
export const publicTreeSql = `WITH RECURSIVE tree(id, workspace_id, share_id) AS (
SELECT n.id, n.workspace_id, ns.id FROM notebook_shares ns
JOIN notebooks n ON n.id = ns.notebook_id AND n.workspace_id = ns.workspace_id
WHERE ns.token = ? AND n.is_deleted = 0
UNION
SELECT n.id, n.workspace_id, t.share_id FROM notebooks n JOIN tree t ON n.parent_id = t.id
WHERE n.workspace_id = t.workspace_id AND n.is_deleted = 0
), visible_memos AS (
SELECT m.* FROM memos m JOIN tree t ON m.notebook_id = t.id AND m.workspace_id = t.workspace_id
WHERE m.is_deleted = 0 AND NOT EXISTS (
SELECT 1 FROM notebook_share_excluded_memos e WHERE e.share_id = t.share_id AND e.memo_id = m.id
)
)`;
export const getNotebookSite = async (db: DatabaseAdapter, token: string) => {
const row = await db.prepare(`SELECT ns.id, ns.token, ns.site_slug, ns.site_settings_json, ns.password_hash,
n.id AS notebook_id, n.name FROM notebook_shares ns
JOIN notebooks n ON n.id = ns.notebook_id AND n.workspace_id = ns.workspace_id
WHERE ns.token = ? AND n.is_deleted = 0`).bind(token).first<{
id: string; token: string; site_slug: string | null; site_settings_json: string;
password_hash: string | null; notebook_id: string; name: string;
}>();
if (!row) return null;
const settings: NotebookSiteSettings = readNotebookSiteSettings(row.site_settings_json);
settings.allowIndexing = settings.allowIndexing && !row.password_hash;
if (settings.homeMemoId && !await db.prepare(`${publicTreeSql} SELECT id FROM visible_memos WHERE id = ?`)
.bind(token, settings.homeMemoId).first()) settings.homeMemoId = null;
return { ...row, settings };
};
export const resolveNotebookSiteToken = async (db: DatabaseAdapter, slug: string) =>
db.prepare(`SELECT ns.token FROM notebook_shares ns JOIN notebooks n
ON n.id = ns.notebook_id AND n.workspace_id = ns.workspace_id
WHERE lower(ns.site_slug) = lower(?) AND n.is_deleted = 0`).bind(slug).first<{ token: string }>();
+6 -3
View File
@@ -29,6 +29,7 @@ const WorkspaceApp = lazy(() => import("@/components/WorkspaceApp").then((module
const PublicNotebookSharePage = lazy(() => import("@/components/PublicNotebookSharePage").then(module => ({ default: module.PublicNotebookSharePage })));
const PublicSharePage = lazy(() => import("@/components/PublicSharePage").then((module) => ({ default: module.PublicSharePage })));
const PublicTableFormPage = lazy(() => import("@/components/PublicTableFormPage").then((module) => ({ default: module.PublicTableFormPage })));
const NotebookSiteManagementPage = lazy(() => import("@/components/NotebookSiteManagementPage").then((module) => ({ default: module.NotebookSiteManagementPage })));
const AuthLoadingScreen = ({ title = "EdgeEver", detail }: { title?: string; detail?: string }) => (
<div className="flex h-[100dvh] items-center justify-center bg-slate-50 px-6 text-center text-slate-700">
@@ -58,7 +59,7 @@ const EvernoteMigrationRoute = () => {
);
};
const AuthenticatedWorkspace = () => {
const AuthenticatedWorkspace = ({ siteManagement = false }: { siteManagement?: boolean }) => {
const queryClient = useQueryClient();
const { t, i18n } = useTranslation();
const desktopBridge = window.edgeeverDesktop;
@@ -211,13 +212,13 @@ const AuthenticatedWorkspace = () => {
return (
<Suspense fallback={<AuthLoadingScreen />}>
<WorkspaceApp
{siteManagement ? <NotebookSiteManagementPage /> : <WorkspaceApp
authRequired={session.authRequired}
demoMode={session.demoMode}
isLoggingOut={logoutMutation.isPending}
user={session.user}
onLogout={() => logoutMutation.mutate()}
/>
/>}
</Suspense>
);
};
@@ -234,11 +235,13 @@ export const App = () => {
<PwaInstallProvider>
<Routes>
<Route path="/share/notebook/:token/:memoId?" element={<Suspense fallback={<AuthLoadingScreen />}><PublicNotebookSharePage /></Suspense>} />
<Route path="/s/:slug/:memoId?" element={<Suspense fallback={<AuthLoadingScreen />}><PublicNotebookSharePage /></Suspense>} />
<Route path="/share/:token" element={<Suspense fallback={<AuthLoadingScreen />}><PublicSharePage /></Suspense>} />
<Route path="/form/:token" element={<Suspense fallback={<AuthLoadingScreen />}><PublicTableFormPage /></Suspense>} />
<Route path={EVERNOTE_MIGRATION_PATH} element={<EvernoteMigrationRoute />} />
<Route path="/" element={<AuthenticatedWorkspace />} />
<Route path="/settings" element={<AuthenticatedWorkspace />} />
<Route path="/notebooks/:notebookId/site" element={<AuthenticatedWorkspace siteManagement />} />
<Route path="/plugins" element={<AuthenticatedWorkspace />} />
<Route path="/plugins/:pluginId" element={<AuthenticatedWorkspace />} />
<Route path="/templates" element={<AuthenticatedWorkspace />} />
+2
View File
@@ -63,6 +63,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
import { MemoCard } from "./MemoCard";
import { MemoNotebookSites } from "./MemoNotebookSites";
import { ClipboardCopyNotice } from "./ClipboardCopyNotice";
import { WeChatCopyProgress } from "./WeChatCopyProgress";
import { cn } from "@/lib/utils";
@@ -1656,6 +1657,7 @@ export const MemoListPane = ({
{contextShareQuery.data?.share ? <Globe className="h-4 w-4 text-slate-950" /> : <Share2 className="h-4 w-4 text-slate-500" />}
{t(isLocalMemoId(memoContextMenu.memo.id) ? "sharing.afterSync" : contextShareQuery.data?.share ? "sharing.manage" : "sharing.action")}
</DropdownMenuItem>
{!isLocalMemoId(memoContextMenu.memo.id) && <MemoNotebookSites key={memoContextMenu.memo.id} memoId={memoContextMenu.memo.id} presentation="menu" />}
{!memoContextMenu.memo.diagramKind && !memoContextMenu.memo.structuredTable && !memoContextMenu.memo.infographic && !memoContextMenu.memo.poster && (
<DropdownMenuItem
className="flex h-9 w-full items-center gap-2 px-3 text-left text-xs text-slate-700 hover:bg-slate-50 cursor-pointer outline-none"
@@ -0,0 +1,71 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Globe } from "lucide-react";
import { useTranslation } from "react-i18next";
import { api, getConfiguredDesktopApiBaseUrl } from "@/lib/api";
import { Button } from "./ui/button";
import { Switch } from "./ui/switch";
import {
DropdownMenuCheckboxItem, DropdownMenuItem, DropdownMenuLabel,
DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger,
} from "./ui/dropdown-menu";
export const memoNotebookSitesQueryKey = (memoId: string) => ["memo-notebook-sites", getConfiguredDesktopApiBaseUrl(), memoId] as const;
export const MemoNotebookSites = ({ memoId, presentation = "dialog" }: {
memoId: string;
presentation?: "dialog" | "menu";
}) => {
const { t } = useTranslation();
const client = useQueryClient();
const baseUrl = getConfiguredDesktopApiBaseUrl();
const queryKey = memoNotebookSitesQueryKey(memoId);
const query = useQuery({
queryKey,
queryFn: () => api.getMemoNotebookSites(memoId),
retry: false,
staleTime: 0,
refetchOnMount: "always",
});
const change = useMutation({
mutationFn: ({ notebookId, excluded }: { notebookId: string; excluded: boolean }) => api.setNotebookSiteExclusion(notebookId, memoId, excluded),
onSuccess: async (_, { notebookId }) => {
await Promise.all([
client.invalidateQueries({ queryKey }),
client.invalidateQueries({ queryKey: ["notebook-site-memos"] }),
client.invalidateQueries({ queryKey: ["notebook-share", baseUrl, notebookId] }),
]);
},
});
const sites = query.data?.sites ?? [];
if (query.isSuccess && sites.length === 0) return null;
const busy = change.isPending || query.isFetching;
if (presentation === "menu") return <DropdownMenuSub>
<DropdownMenuSubTrigger className="gap-2 px-3"><Globe className="size-4 shrink-0" />{t("notebookSite.noteSites")}</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-[min(20rem,var(--radix-dropdown-menu-content-available-height))] w-64 max-w-[calc(100vw-1.5rem)] overflow-y-auto" collisionPadding={12}>
{query.isPending ? <DropdownMenuItem disabled>{t("sharing.publicLoading")}</DropdownMenuItem>
: query.isError ? <>
<DropdownMenuLabel className="font-normal text-destructive">{t("sharing.error")}</DropdownMenuLabel>
<DropdownMenuItem onSelect={(event) => { event.preventDefault(); void query.refetch(); }}>{t("sharing.retry")}</DropdownMenuItem>
</> : sites.map((site) => <DropdownMenuCheckboxItem key={site.notebookId} checked={!site.excluded} disabled={busy}
aria-label={t("notebookSite.showInSite", { name: site.notebookName })}
className="break-words whitespace-normal"
onSelect={(event) => event.preventDefault()}
onCheckedChange={(checked) => change.mutate({ notebookId: site.notebookId, excluded: !checked })}>
{site.notebookName}
</DropdownMenuCheckboxItem>)}
{change.isError && <DropdownMenuLabel role="alert" className="break-words font-normal text-destructive">{change.error.message}</DropdownMenuLabel>}
</DropdownMenuSubContent>
</DropdownMenuSub>;
return <section className="space-y-3 rounded-lg border p-3" aria-label={t("notebookSite.noteSites")}>
<h2 className="text-sm font-semibold">{t("notebookSite.noteSites")}</h2>
<p className="text-xs text-muted-foreground">{t("notebookSite.noteSitesHint")}</p>
{query.isPending ? <p role="status" className="text-sm text-muted-foreground">{t("sharing.publicLoading")}</p>
: query.isError ? <div className="space-y-2"><p role="alert" className="text-xs text-destructive">{t("sharing.error")}</p><Button type="button" size="sm" variant="outline" onClick={() => void query.refetch()}>{t("sharing.retry")}</Button></div>
: sites.map((site) => <label key={site.notebookId} className="flex items-center justify-between gap-3 text-sm">
<span className="min-w-0 break-words">{t("notebookSite.showInSite", { name: site.notebookName })}</span>
<Switch aria-label={t("notebookSite.showInSite", { name: site.notebookName })} checked={!site.excluded} disabled={busy} onCheckedChange={(checked) => change.mutate({ notebookId: site.notebookId, excluded: !checked })} />
</label>)}
{change.isSuccess && <p role="status" className="text-xs text-muted-foreground">{t("notebookSite.contentSaved")}</p>}
{change.isError && <p role="alert" className="break-words text-xs text-destructive">{change.error.message}</p>}
</section>;
};
@@ -0,0 +1,128 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect, useState, type RefObject } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
type Heading = { id: string; text: string; level: number };
type Preview = { id: string; href: string; left: number; top: number };
export const NotebookSiteArticleTools = ({ containerRef, token, memoUrls }: {
containerRef: RefObject<HTMLDivElement | null>; token: string; memoUrls: Readonly<Record<string, string>>;
}) => {
const { t } = useTranslation();
const [headings, setHeadings] = useState<Heading[]>([]);
const [active, setActive] = useState("");
const [outlineOpen, setOutlineOpen] = useState(false);
const [preview, setPreview] = useState<Preview | null>(null);
const [cardHovered, setCardHovered] = useState(false);
useEffect(() => { if (preview && !memoUrls[preview.id]) setPreview(null); }, [memoUrls, preview]);
const query = useQuery({
queryKey: ["notebook-site-preview", token, preview?.id],
queryFn: () => api.previewPublicNotebookMemo(token, preview!.id),
enabled: Boolean(preview), retry: false, staleTime: 0,
});
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const update = () => {
const counts = new Map<string, number>();
const result = [...container.querySelectorAll<HTMLElement>(".tiptap h1, .tiptap h2, .tiptap h3, .tiptap h4")].map((node) => {
const text = node.textContent?.trim() ?? "";
const slug = text.toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-|-$/g, "") || "section";
const count = (counts.get(slug) ?? 0) + 1;
counts.set(slug, count);
node.id = `article-${slug}-${count}`;
node.style.scrollMarginTop = "6rem";
return { id: node.id, text, level: Number(node.tagName.slice(1)) };
});
setHeadings((previous) => JSON.stringify(previous) === JSON.stringify(result) ? previous : result);
};
update();
const observer = new MutationObserver(update);
observer.observe(container, { childList: true, subtree: true, characterData: true });
return () => observer.disconnect();
}, [containerRef]);
useEffect(() => {
if (!headings.length) return;
const update = () => {
let id = headings[0].id;
for (const heading of headings) {
if ((document.getElementById(heading.id)?.getBoundingClientRect().top ?? Infinity) <= 130) id = heading.id;
}
if (window.scrollY > 0 && window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 4)
id = headings.at(-1)!.id;
setActive(id);
};
let frame = 0;
const onScroll = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(update); };
window.addEventListener("scroll", onScroll, { passive: true });
let restoring = true;
let target: HTMLElement | null = null;
try { target = document.getElementById(decodeURIComponent(window.location.hash.slice(1))); } catch { /* Ignore malformed fragments. */ }
const stopRestoring = () => { restoring = false; };
const relayout = () => {
// Fonts and images may change height after TipTap has created headings.
// Stop restoring the initial fragment as soon as the reader interacts.
if (restoring && target && containerRef.current?.contains(target)) target.scrollIntoView({ block: "start", behavior: "instant" });
onScroll();
};
const observer = new ResizeObserver(relayout);
if (containerRef.current) observer.observe(containerRef.current);
for (const event of ["wheel", "touchstart", "pointerdown", "keydown"]) window.addEventListener(event, stopRestoring, { passive: true });
relayout();
return () => {
window.removeEventListener("scroll", onScroll); cancelAnimationFrame(frame); observer.disconnect();
for (const event of ["wheel", "touchstart", "pointerdown", "keydown"]) window.removeEventListener(event, stopRestoring);
};
}, [headings]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let timer: ReturnType<typeof setTimeout> | undefined;
const show = (event: Event) => {
const link = event.target instanceof Element ? event.target.closest("a") : null;
if (!link) return;
const id = Object.keys(memoUrls).find((key) => new URL(memoUrls[key], location.origin).href === link.href);
if (!id) return;
clearTimeout(timer);
const rect = link.getBoundingClientRect();
setPreview({ id, href: memoUrls[id], left: rect.left, top: rect.bottom });
};
const hide = () => { timer = setTimeout(() => setPreview(null), 180); };
container.addEventListener("mouseover", show);
container.addEventListener("focusin", show);
if (!cardHovered) {
container.addEventListener("mouseout", hide);
container.addEventListener("focusout", hide);
}
return () => {
clearTimeout(timer);
container.removeEventListener("mouseover", show); container.removeEventListener("focusin", show);
container.removeEventListener("mouseout", hide); container.removeEventListener("focusout", hide);
};
}, [containerRef, memoUrls, cardHovered]);
const outline = <nav aria-label={t("notebookSite.outline")} className="max-h-[70dvh] space-y-1 overflow-y-auto text-xs">
{headings.map((heading) => <a href={`#${heading.id}`} key={heading.id} aria-current={active === heading.id ? "location" : undefined} className={`block rounded py-1.5 pr-2 ${active === heading.id ? "bg-muted font-semibold" : "text-muted-foreground"}`} style={{ paddingLeft: (heading.level - 1) * 12 + 8 }} onClick={(event) => {
event.preventDefault();
history.replaceState(history.state, "", `#${heading.id}`);
setOutlineOpen(false);
document.getElementById(heading.id)?.scrollIntoView({ block: "start", behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth" });
}}>{heading.text}</a>)}
</nav>;
return <>
{headings.length > 0 && <>
<aside className="absolute bottom-0 right-3 top-8 hidden w-48 xl:block"><div className="sticky top-24"><h2 className="mb-3 text-xs font-semibold">{t("notebookSite.outline")}</h2>{outline}</div></aside>
<Drawer open={outlineOpen} onOpenChange={setOutlineOpen} shouldScaleBackground={false}><DrawerTrigger asChild><Button variant="outline" className="fixed bottom-5 right-5 z-20 shadow-md xl:hidden">{t("notebookSite.outline")}</Button></DrawerTrigger><DrawerContent><DrawerHeader><DrawerTitle>{t("notebookSite.outline")}</DrawerTitle><DrawerDescription>{t("sharing.readOnly")}</DrawerDescription></DrawerHeader><div className="px-6 pb-8">{outline}</div></DrawerContent></Drawer>
</>}
<HoverCard open={Boolean(preview)} onOpenChange={(open) => { if (!open) setPreview(null); }}>
<HoverCardTrigger asChild><span aria-hidden="true" className="pointer-events-none fixed" style={{ left: preview?.left ?? 0, top: preview?.top ?? 0 }} /></HoverCardTrigger>
{preview && <HoverCardContent data-notebook-preview="true" side="bottom" align="start" onMouseEnter={() => setCardHovered(true)} onMouseLeave={() => { setCardHovered(false); setPreview(null); }} onEscapeKeyDown={() => setPreview(null)}>
{query.isError ? <p>{t("notebookSite.previewUnavailable")}</p> : query.isPending ? <p>{t("sharing.publicLoading")}</p> : <Link to={preview.href} onClick={() => setPreview(null)}><strong className="block">{query.data.memo.title || t("common.untitledMemo")}</strong><span className="mt-2 line-clamp-4 block text-sm text-muted-foreground">{query.data.memo.excerpt}</span></Link>}
</HoverCardContent>}
</HoverCard>
</>;
};
@@ -0,0 +1,27 @@
import { NodeViewContent, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
import { FRONT_MATTER_LANGUAGE } from "@edgeever/shared";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { copyTextToClipboard } from "@/lib/clipboard";
import { Button } from "./ui/button";
import { ButtonTooltip } from "./ui/button-tooltip";
import { MermaidCodeBlock } from "./MermaidCodeBlock";
import { NoteProperties } from "./editor/NoteProperties";
export const NotebookSiteCodeBlock = (props: NodeViewProps) => {
const { t } = useTranslation();
const [collapsed, setCollapsed] = useState(false);
const [copied, setCopied] = useState<"idle" | "ok" | "error">("idle");
const language = String(props.node.attrs.language || "plaintext");
if (language === FRONT_MATTER_LANGUAGE) return <NoteProperties {...props} />;
if (language === "mermaid") return <MermaidCodeBlock {...props} />;
const copyLabel = t(copied === "ok" ? "editorToolbar.codeCopied" : copied === "error" ? "editorToolbar.codeCopyFailed" : "editorToolbar.copyCode");
const collapseLabel = t(collapsed ? "notebookSite.expand" : "notebookSite.collapse");
return <NodeViewWrapper className="edgeever-notebook-code not-prose my-5 overflow-hidden rounded-lg border">
<div contentEditable={false} className="flex items-center justify-between gap-3 border-b bg-muted px-3 py-1.5 text-xs">
<span aria-label={t("notebookSite.language")}>{language}</span>
<div className="flex gap-2"><ButtonTooltip title={copyLabel}><Button variant="ghost" size="sm" onClick={() => { void copyTextToClipboard(props.node.textContent).then((ok) => setCopied(ok ? "ok" : "error")); }}>{copyLabel}</Button></ButtonTooltip><ButtonTooltip title={collapseLabel}><Button variant="ghost" size="sm" aria-expanded={!collapsed} onClick={() => setCollapsed((value) => !value)}>{collapseLabel}</Button></ButtonTooltip></div>
</div>
<pre style={{ display: collapsed ? "none" : undefined }}><code><NodeViewContent /></code></pre>
</NodeViewWrapper>;
};
@@ -0,0 +1,42 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Share2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useParams } from "react-router";
import { api, getConfiguredDesktopApiBaseUrl } from "@/lib/api";
import { Button } from "./ui/button";
import { NotebookSiteSettings } from "./NotebookSiteSettings";
import { ShareNotebookDialog } from "./dialogs/ShareNotebookDialog";
export const NotebookSiteManagementPage = () => {
const { notebookId = "" } = useParams();
const { t } = useTranslation();
const client = useQueryClient();
const [sharing, setSharing] = useState(false);
const queryKey = ["notebook-share", getConfiguredDesktopApiBaseUrl(), notebookId];
const query = useQuery({
queryKey,
queryFn: () => api.getNotebookShare(notebookId),
retry: false,
staleTime: 0,
refetchOnMount: "always",
});
const share = query.isError ? null : query.data?.share;
return <div className="flex h-[100dvh] flex-col bg-background text-foreground">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b bg-card px-4 py-3 sm:px-6">
<div className="flex min-w-0 items-center gap-3">
<Button asChild size="sm" variant="ghost"><Link to="/"><ArrowLeft className="size-4" />{t("notebookSite.backToNotes")}</Link></Button>
<div className="min-w-0"><h1 className="text-base font-semibold">{t("notebookSite.manage")}</h1><p className="max-w-[45vw] truncate text-xs text-muted-foreground">{share?.notebookName}</p></div>
</div>
{share && <Button size="sm" variant="outline" onClick={() => setSharing(true)}><Share2 className="size-4" />{t("sharing.notebookTitle")}</Button>}
</header>
<main className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-5xl px-4 py-6 sm:px-6 sm:py-8">
{query.isPending ? <p role="status" className="py-12 text-center">{t("sharing.publicLoading")}</p>
: !share ? <section className="space-y-4 py-12 text-center"><p>{t(query.isError ? "sharing.error" : "notebookSite.noActiveShare")}</p>{query.isError && <Button variant="outline" onClick={() => void query.refetch()}>{t("sharing.retry")}</Button>}</section>
: <NotebookSiteSettings key={`${share.token}-${share.passwordProtected}`} notebookId={notebookId} share={share} onSaved={(data) => { client.setQueryData(queryKey, data); void client.invalidateQueries({ queryKey: ["notebook-shares"] }); }} />}
</div>
</main>
{share && <ShareNotebookDialog notebookId={notebookId} notebookName={share.notebookName} open={sharing} onOpenChange={setSharing} />}
</div>;
};
@@ -0,0 +1,58 @@
import { useQuery } from "@tanstack/react-query";
import { getMemoDisplayTitle, type NotebookSiteMemo } from "@edgeever/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, getConfiguredDesktopApiBaseUrl } from "@/lib/api";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
export const NotebookSiteMemoBrowser = ({ notebookId, token, selectedId, onSelect }: {
notebookId: string;
token: string;
selectedId?: string | null;
onSelect: (memo: NotebookSiteMemo) => void;
}) => {
const { t } = useTranslation();
const [input, setInput] = useState("");
const [search, setSearch] = useState("");
const [cursors, setCursors] = useState<Array<string | null>>([null]);
const prefix = ["notebook-site-memos", getConfiguredDesktopApiBaseUrl(), token];
useEffect(() => {
const timer = window.setTimeout(() => { setSearch(input.trim()); setCursors([null]); }, 200);
return () => window.clearTimeout(timer);
}, [input]);
const cursor = cursors.at(-1)!;
const query = useQuery({
queryKey: [...prefix, notebookId, search, "public", cursor],
queryFn: () => api.listNotebookSiteMemos(notebookId, { q: search, visibility: "public", cursor, limit: 20 }),
retry: false,
staleTime: 0,
refetchOnMount: "always",
});
const pagination = <div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs text-muted-foreground" aria-live="polite">{t("notebookSite.page", { page: cursors.length })}</span>
<div className="flex gap-2">
<Button type="button" size="sm" variant="outline" disabled={cursors.length === 1 || query.isFetching} onClick={() => setCursors((previous) => previous.slice(0, -1))}>{t("notebookSite.previousPage")}</Button>
<Button type="button" size="sm" variant="outline" disabled={!query.data?.nextCursor || query.isFetching} onClick={() => { if (query.data?.nextCursor) setCursors((previous) => [...previous, query.data!.nextCursor]); }}>{t("notebookSite.nextPage")}</Button>
</div>
</div>;
return <section className="space-y-4" aria-label={t("notebookSite.chooseHomepage")}>
<Input type="search" value={input} onChange={(event) => setInput(event.target.value)} placeholder={t("notebookSite.findNotes")} aria-label={t("notebookSite.findNotes")} />
{pagination}
{query.isError ? <div role="alert" className="space-y-2 text-sm"><p>{t("sharing.error")}</p><Button type="button" variant="outline" onClick={() => void query.refetch()}>{t("sharing.retry")}</Button></div>
: query.isPending ? <p role="status" className="py-8 text-center text-sm text-muted-foreground">{t("sharing.publicLoading")}</p>
: !query.data?.memos.length ? <p className="py-8 text-center text-sm text-muted-foreground">{t("notebookSite.noNotes")}</p>
: <ul className="divide-y rounded-lg border bg-card">{query.data.memos.map((memo) => {
const title = getMemoDisplayTitle(memo.title, t("common.untitledMemo"));
return <li key={memo.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 p-4">
<div className="min-w-0">
<Tooltip><TooltipTrigger asChild><span tabIndex={0} className="line-clamp-2 break-words rounded text-sm font-medium focus-visible:outline-none focus-visible:ring-2">{title}</span></TooltipTrigger><TooltipContent className="max-w-[80vw] break-words sm:max-w-lg">{title}</TooltipContent></Tooltip>
<p className="mt-1 truncate text-xs text-muted-foreground">{memo.notebookName}</p>
</div>
<Button type="button" size="sm" variant="outline" onClick={() => onSelect(memo)}>{t(selectedId === memo.id ? "notebookSite.homepageSelected" : "notebookSite.selectNote")}</Button>
</li>;
})}</ul>}
{query.data && query.data.memos.length > 0 && pagination}
</section>;
};
@@ -0,0 +1,60 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { getMemoDisplayTitle, type PublicNotebookShare } from "@edgeever/shared";
import { createContext, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
export const NotebookSiteSearchContext = createContext<(tag?: string) => void>(() => {});
const Highlight = ({ text, terms }: { text: string; terms: string[] }) => {
const words = [...new Set(terms.filter(Boolean))].sort((a, b) => b.length - a.length);
if (!words.length) return <>{text}</>;
const pattern = new RegExp(`(${words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`, "giu");
return <>{text.split(pattern).map((part, index) => index % 2 ? <mark key={index}>{part}</mark> : part)}</>;
};
export const NotebookSiteSearch = ({ token, share, root, open, setOpen, tag, setTag }: {
token: string; share: PublicNotebookShare; root: string; open: boolean; setOpen: (value: boolean) => void;
tag: string; setTag: (value: string) => void;
}) => {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [debounced, setDebounced] = useState("");
const returnFocus = useRef<HTMLElement | null>(null);
useEffect(() => {
const timer = window.setTimeout(() => setDebounced(query), 200);
return () => window.clearTimeout(timer);
}, [query]);
const result = useInfiniteQuery({
queryKey: ["notebook-site-search", token, debounced, tag],
queryFn: ({ pageParam }) => api.searchPublicNotebook(token, { q: debounced, tag, cursor: pageParam }),
initialPageParam: null as string | null,
getNextPageParam: (page) => page.nextCursor,
enabled: open,
retry: false,
staleTime: 0,
refetchOnMount: "always",
});
const visible = useMemo(() => new Set(share.memos.map((memo) => memo.id)), [share.memos]);
const matches = result.isError ? [] : (result.data?.pages.flatMap((page) => page.memos).filter((memo) => visible.has(memo.id)) ?? []);
return <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[85dvh] overflow-y-auto sm:max-w-2xl"
onOpenAutoFocus={() => { returnFocus.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; }}
onCloseAutoFocus={(event) => { event.preventDefault(); returnFocus.current?.focus(); }}>
<DialogHeader><DialogTitle>{t("notebookSite.search")}</DialogTitle><DialogDescription>{t("notebookSite.searchHint")}</DialogDescription></DialogHeader>
<Input type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t("sharing.siteSearch")} aria-label={t("sharing.siteSearch")} />
{tag && <Button variant="outline" className="justify-start" onClick={() => setTag("")}>#{tag} · {t("notebookSite.allTags")}</Button>}
<div role="status" className="text-xs text-muted-foreground">{result.isFetching ? t("sharing.publicLoading") : t("sharing.siteResults", { count: matches.length })}</div>
<ul className="space-y-2">{matches.map((memo) => <li key={memo.id}><Link className="block rounded-md border p-3 hover:bg-muted focus-visible:ring-2" to={`${root}/${encodeURIComponent(memo.id)}`} onClick={() => setOpen(false)}>
<span className="block font-medium"><Highlight text={getMemoDisplayTitle(memo.title, t("common.untitledMemo"))} terms={memo.highlights} /></span>
<span className="mt-1 line-clamp-3 block whitespace-pre-wrap text-sm text-muted-foreground"><Highlight text={memo.excerpt} terms={memo.highlights} /></span>
</Link></li>)}</ul>
{result.isError ? <div role="alert">{t("sharing.error")} <Button variant="outline" onClick={() => void result.refetch()}>{t("sharing.retry")}</Button></div> : !result.isPending && !matches.length ? <p>{t("sharing.siteNoResults")}</p> : null}
{result.hasNextPage && <Button variant="outline" disabled={result.isFetchingNextPage} onClick={() => void result.fetchNextPage()}>{t("notebookSite.more")}</Button>}
</DialogContent>
</Dialog>;
};
@@ -0,0 +1,90 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { getMemoDisplayTitle, readNotebookSiteSettings, type NotebookShare } from "@edgeever/shared";
import { useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { api, ApiRequestError, getConfiguredDesktopApiBaseUrl } from "@/lib/api";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Textarea } from "./ui/textarea";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { NotebookSiteMemoBrowser } from "./NotebookSiteMemoBrowser";
import { memoNotebookSitesQueryKey } from "./MemoNotebookSites";
export const NotebookSiteSettings = ({ notebookId, share, onSaved }: {
notebookId: string; share: NotebookShare; onSaved: (data: { share: NotebookShare }) => void;
}) => {
const { t } = useTranslation();
const [settings, setSettings] = useState(() => share.siteSettings ?? readNotebookSiteSettings());
const [slug, setSlug] = useState(share.siteSlug ?? "");
const [choosingHome, setChoosingHome] = useState(false);
const selectedHome = useQuery({
queryKey: ["notebook-site-home-title", getConfiguredDesktopApiBaseUrl(), notebookId, settings.homeMemoId],
queryFn: () => api.getMemo(settings.homeMemoId!),
enabled: Boolean(settings.homeMemoId),
retry: false,
});
const homeSites = useQuery({
queryKey: memoNotebookSitesQueryKey(settings.homeMemoId ?? ""),
queryFn: () => api.getMemoNotebookSites(settings.homeMemoId!),
enabled: Boolean(settings.homeMemoId),
retry: false,
staleTime: 0,
});
const homeUnavailable = Boolean(settings.homeMemoId) && (
[selectedHome.error, homeSites.error].some((error) => error instanceof ApiRequestError && error.status === 404)
|| (homeSites.isSuccess && !homeSites.data.sites.some((site) => site.notebookId === notebookId && !site.excluded))
);
const homeError = Boolean(settings.homeMemoId) && !homeUnavailable && (selectedHome.isError || homeSites.isError);
const save = useMutation({
mutationFn: () => {
const { homeMemoId, ...otherSettings } = settings;
return api.updateNotebookSite(notebookId, { siteSlug: slug || null, settings: {
...otherSettings,
...(homeMemoId !== share.siteSettings?.homeMemoId ? { homeMemoId } : {}),
} });
},
onSuccess: onSaved,
});
const label = (key: string) => t(`notebookSite.${key}`);
const submit = (event: FormEvent) => { event.preventDefault(); save.mutate(); };
const edit = (patch: Partial<typeof settings>) => { setSettings((previous) => ({ ...previous, ...patch })); save.reset(); };
return <Dialog open={choosingHome} onOpenChange={setChoosingHome}>
<form onSubmit={submit} className="space-y-6">
<section className="space-y-4 rounded-xl border bg-card p-4 sm:p-6">
<h2 className="font-semibold">{label("homepage")}</h2>
<p className="break-words text-sm text-muted-foreground">{settings.homeMemoId && !homeUnavailable ? (selectedHome.data?.memo ? getMemoDisplayTitle(selectedHome.data.memo.title, t("common.untitledMemo")) : label("homepageSelected")) : label("directory")}</p>
{homeUnavailable && <p role="status" className="text-xs text-muted-foreground">{label("homepageFallback")}</p>}
{homeError && <div className="space-y-2"><p role="alert" className="text-xs text-destructive">{t("sharing.error")}</p><Button type="button" size="sm" variant="outline" onClick={() => { void selectedHome.refetch(); void homeSites.refetch(); }}>{t("sharing.retry")}</Button></div>}
<div className="flex flex-wrap gap-2">
<DialogTrigger asChild><Button type="button" variant="outline">{label("chooseHomepage")}</Button></DialogTrigger>
<Button type="button" variant="ghost" disabled={!settings.homeMemoId} onClick={() => edit({ homeMemoId: null })}>{label("useDirectory")}</Button>
</div>
</section>
<section className="grid gap-5 rounded-xl border bg-card p-4 sm:grid-cols-2 sm:p-6">
<label className="space-y-2 text-sm">{label("slug")}<Input value={slug} onChange={(event) => { setSlug(event.target.value); save.reset(); }} autoCapitalize="none" /></label>
<label className="space-y-2 text-sm">{label("logo")}<Input type="url" value={settings.logoUrl ?? ""} onChange={(event) => edit({ logoUrl: event.target.value || null })} /></label>
<div className="space-y-2 text-sm"><label htmlFor="notebook-site-order">{label("sort")}</label><Select value={settings.sortMode} onValueChange={(value) => edit({ sortMode: value as "existing" | "title" })}><SelectTrigger id="notebook-site-order"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="existing">{label("existingOrder")}</SelectItem><SelectItem value="title">{label("titleOrder")}</SelectItem></SelectContent></Select></div>
<div className="space-y-2 text-sm"><label className="flex items-center gap-2"><input type="checkbox" disabled={share.passwordProtected} checked={!share.passwordProtected && settings.allowIndexing} onChange={(event) => edit({ allowIndexing: event.target.checked })} />{label("indexing")}</label><p className="text-xs text-muted-foreground">{label("indexingHint")}</p></div>
<label className="space-y-2 text-sm sm:col-span-2">{label("footer")}<Textarea value={settings.footerText} onChange={(event) => edit({ footerText: event.target.value })} /></label>
<fieldset className="space-y-3 sm:col-span-2"><legend className="mb-2 text-sm">{label("headerLinks")}</legend>
{settings.headerLinks.map((link, index) => <div className="grid gap-2 rounded-lg border p-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto]" key={index}>
<Input aria-label={label("label")} placeholder={label("label")} value={link.label} required onChange={(event) => edit({ headerLinks: settings.headerLinks.map((item, i) => i === index ? { ...item, label: event.target.value } : item) })} />
<Input aria-label={label("url")} placeholder={label("url")} type="url" required value={link.url} onChange={(event) => edit({ headerLinks: settings.headerLinks.map((item, i) => i === index ? { ...item, url: event.target.value } : item) })} />
<Button type="button" variant="outline" onClick={() => edit({ headerLinks: settings.headerLinks.filter((_, i) => i !== index) })}>{label("remove")}</Button>
</div>)}
<Button type="button" variant="outline" onClick={() => edit({ headerLinks: [...settings.headerLinks, { label: "", url: "" }] })}>{label("addLink")}</Button>
</fieldset>
</section>
<div className="flex flex-wrap items-center gap-4 pb-2">
<Button type="submit" variant="solid" disabled={save.isPending}>{label("save")}</Button><span className="text-xs text-muted-foreground">{label("saveHint")}</span>
{save.isSuccess && <p role="status" className="text-sm">{label("saved")}</p>}
{save.isError && <p role="alert" className="text-sm text-destructive">{save.error.message}</p>}
</div>
</form>
<DialogContent className="flex max-h-[85dvh] flex-col sm:max-w-2xl">
<DialogHeader><DialogTitle>{label("chooseHomepage")}</DialogTitle><DialogDescription>{label("homepageHint")}</DialogDescription></DialogHeader>
<div className="min-h-0 overflow-y-auto"><NotebookSiteMemoBrowser notebookId={notebookId} token={share.token} selectedId={settings.homeMemoId} onSelect={(memo) => { edit({ homeMemoId: memo.id }); setChoosingHome(false); }} /></div>
</DialogContent>
</Dialog>;
};
+3 -8
View File
@@ -69,13 +69,7 @@ export const NotebookTreeItem = ({
const [shareOpen, setShareOpen] = useState(false);
const directlyShared = Boolean(sharedNotebookIds?.has(node.id));
const publicNotebook = inheritedPublic || directlyShared;
const shareActionLabel = t(
directlyShared
? "sharing.manage"
: inheritedPublic
? "sharing.notebookInheritedAction"
: "sharing.notebookTitle",
);
const shareActionLabel = t("sharing.notebookTitle");
const open = !collapsedNotebookIds.has(node.id);
const hasChildren = node.children.length > 0;
const showsSplitCount = !showDescendantNotes && hasChildren;
@@ -314,7 +308,8 @@ export const NotebookTreeItem = ({
<TooltipProvider><Tooltip><TooltipTrigger asChild>
<button
className={cn(
"edgeever-reveal-on-touch hidden h-6 w-6 items-center justify-center rounded-md group-focus-within:flex group-hover:flex transition-colors duration-150",
"edgeever-reveal-on-touch h-6 w-6 items-center justify-center rounded-md transition-colors duration-150",
directlyShared ? "flex" : "hidden group-focus-within:flex group-hover:flex",
selected ? "hover:bg-slate-200" : "hover:bg-slate-100"
)}
type="button"
@@ -2,24 +2,35 @@ import { useQuery } from "@tanstack/react-query";
import { LoaderCircle } from "lucide-react";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router";
import { useLocation, useParams } from "react-router";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { isSharePasswordError, PublicSharePasswordForm, PublicShareArticle } from "./PublicSharePage";
import { PublicNotebookSite } from "./PublicNotebookSite";
import { withEnvironmentTitlePrefix } from "@/lib/environment-title";
import { PUBLIC_PAGE_ROBOTS } from "@edgeever/shared/public-page-headers";
export const PublicNotebookSharePage = () => {
const { t } = useTranslation();
const { token = "", memoId } = useParams();
const { token: routeToken = "", memoId: routeMemoId, slug } = useParams();
const location = useLocation();
const identity = useQuery({
queryKey: ["notebook-site-alias", slug],
queryFn: () => api.resolveNotebookSite(slug!),
enabled: Boolean(slug), retry: false, staleTime: 0, refetchOnMount: "always",
});
const token = slug ? (identity.isError ? "" : identity.data?.token ?? "") : routeToken;
const directory = useQuery({
queryKey: ["public-notebook-share", token],
queryFn: () => api.getPublicNotebookShare(token),
enabled: Boolean(token),
retry: false,
staleTime: 0,
refetchOnMount: "always",
refetchInterval: 30_000,
});
const share = directory.isError || identity.isError ? undefined : directory.data?.share;
const memoId = routeMemoId || (new URLSearchParams(location.search).has("contents") ? undefined : share?.settings?.homeMemoId ?? undefined);
const memo = useQuery({
queryKey: ["public-notebook-memo", token, memoId],
queryFn: () => api.getPublicNotebookMemo(token, memoId!),
@@ -29,7 +40,6 @@ export const PublicNotebookSharePage = () => {
refetchOnMount: "always",
refetchInterval: 30_000,
});
const share = directory.isError ? undefined : directory.data?.share;
const note = memo.isError ? undefined : memo.data?.share;
const access = useMemo(
() => ({
@@ -39,9 +49,16 @@ export const PublicNotebookSharePage = () => {
[token, memo.data?.memoUrls],
);
useEffect(() => {
// The server head already applies the visibility/indexing policy. Keep it
// until the identity, directory and selected article have resolved.
if ((slug && identity.isPending) || (token && directory.isPending) ||
(share && memoId && (memo.isPending || memo.isFetching))) return;
const robots = document.createElement("meta");
robots.name = "robots";
robots.content = "noindex,nofollow,noarchive";
robots.content = slug && share?.settings?.allowIndexing && !directory.isError && !memo.isError ? "index,follow" : PUBLIC_PAGE_ROBOTS;
const existingRobots = [...document.head.querySelectorAll<HTMLMetaElement>('meta[name="robots"]')];
const existingContents = existingRobots.map((meta) => meta.content);
existingRobots.forEach((meta) => { meta.content = robots.content; });
document.head.appendChild(robots);
const previous = document.title;
document.title = withEnvironmentTitlePrefix(
@@ -53,10 +70,11 @@ export const PublicNotebookSharePage = () => {
);
return () => {
robots.remove();
existingRobots.forEach((meta, index) => { meta.content = existingContents[index]; });
document.title = previous;
};
}, [note?.title, share?.name, t]);
if (directory.isPending)
}, [note?.title, share, slug, token, memoId, identity.isPending, directory.isPending, directory.isError, memo.isPending, memo.isFetching, memo.isError, t]);
if ((slug && identity.isPending) || (Boolean(token) && directory.isPending))
return (
<main className="flex min-h-[100dvh] items-center justify-center">
<LoaderCircle
@@ -82,7 +100,7 @@ export const PublicNotebookSharePage = () => {
{t("sharing.notebookUnavailable")}
</h1>
<p>{t("sharing.notebookUnavailableHint")}</p>
<Button variant="outline" onClick={() => void directory.refetch()}>
<Button variant="outline" onClick={() => void (slug && identity.isError ? identity.refetch() : directory.refetch())}>
{t("sharing.retry")}
</Button>
</main>
+70 -415
View File
@@ -1,4 +1,8 @@
import type { PublicNotebookShare } from "@edgeever/shared";
import { NotebookSiteSearch, NotebookSiteSearchContext } from "./NotebookSiteSearch";
import { useAppearanceTheme, type ThemePreference } from "./ThemeProvider";
import { ButtonTooltip } from "./ui/button-tooltip";
import "./notebook-site.css";
import { getMemoDisplayTitle } from "@edgeever/shared";
import {
ArrowLeft,
@@ -9,7 +13,6 @@ import {
PanelLeftClose,
PanelLeftOpen,
Search,
X,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
@@ -29,7 +32,6 @@ import {
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
@@ -152,21 +154,16 @@ const NoteTitle = ({ memo }: { memo: Memo }) => {
const HomepageContents = ({
sections,
matchingIds,
filtering,
href,
onNavigate,
}: {
sections: Section[];
matchingIds: ReadonlySet<string>;
filtering: boolean;
href: (id: string) => string;
onNavigate: () => void;
}) => {
const { t } = useTranslation();
const { children, visibleSections } = useMemo(() => {
const children = useMemo(() => {
const children = new Map<string, Section[]>();
const visibleSections = new Set<string>();
for (const section of sections) {
const parent = section.path.at(-2);
if (parent) {
@@ -174,15 +171,9 @@ const HomepageContents = ({
siblings.push(section);
children.set(parent.id, siblings);
}
if (
!filtering ||
section.memos.some((memo) => matchingIds.has(memo.id))
) {
for (const ancestor of section.path) visibleSections.add(ancestor.id);
}
}
return { children, visibleSections };
}, [sections, matchingIds, filtering]);
return children;
}, [sections]);
const renderContents = (section: Section, nested: boolean): ReactNode => (
<ul
className={cn(
@@ -191,7 +182,6 @@ const HomepageContents = ({
)}
>
{section.memos
.filter((memo) => matchingIds.has(memo.id))
.map((memo) => (
<li key={memo.id} className="pl-1 marker:text-slate-400">
<Link
@@ -207,7 +197,6 @@ const HomepageContents = ({
</li>
))}
{(children.get(section.notebook.id) ?? [])
.filter((child) => visibleSections.has(child.notebook.id))
.map((child) => (
<li
key={child.notebook.id}
@@ -217,10 +206,7 @@ const HomepageContents = ({
<span className="break-words font-semibold text-slate-900">
{child.notebook.name}
</span>
{(child.memos.some((memo) => matchingIds.has(memo.id)) ||
children
.get(child.notebook.id)
?.some((item) => visibleSections.has(item.notebook.id))) &&
{(child.memos.length > 0 || children.has(child.notebook.id)) &&
renderContents(child, true)}
</li>
))}
@@ -240,23 +226,23 @@ const HomepageContents = ({
);
};
export const PublicNotebookSite = ({
share,
token,
selectedId,
children,
}: {
share: PublicNotebookShare;
token: string;
selectedId?: string;
children?: ReactNode;
const SiteLogo = ({ src }: { src?: string | null }) => {
const [failed, setFailed] = useState(false);
return src && !failed ? <img src={src} alt="" className="size-8 shrink-0 rounded-lg object-contain" onError={() => setFailed(true)} /> : <BookOpen className="size-6 shrink-0" />;
};
export const PublicNotebookSite = ({ share, token, selectedId, children }: {
share: PublicNotebookShare; token: string; selectedId?: string; children?: ReactNode;
}) => {
const { t } = useTranslation();
const { hash } = useLocation();
const [search, setSearch] = useState("");
const { preference, setPreference } = useAppearanceTheme();
const [mobileOpen, setMobileOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true);
const root = `/share/notebook/${encodeURIComponent(token)}`;
const [searchOpen, setSearchOpen] = useState(false);
const [tag, setTag] = useState("");
const root = share.siteSlug ? `/s/${encodeURIComponent(share.siteSlug)}` : `/share/notebook/${encodeURIComponent(token)}`;
const directory = `${root}?contents=1`;
const href = (id: string) => `${root}/${encodeURIComponent(id)}`;
const sections = useMemo(() => {
const result: Section[] = [];
@@ -266,408 +252,77 @@ export const PublicNotebookSite = ({
for (const notebook of share.notebooks) {
if (!notebook.parentId) continue;
const siblings = children.get(notebook.parentId) ?? [];
siblings.push(notebook);
children.set(notebook.parentId, siblings);
siblings.push(notebook); children.set(notebook.parentId, siblings);
}
for (const memo of share.memos) {
const siblings = memos.get(memo.notebookId) ?? [];
siblings.push(memo);
memos.set(memo.notebookId, siblings);
siblings.push(memo); memos.set(memo.notebookId, siblings);
}
const visited = new Set<string>();
const visit = (notebook: Notebook, path: Notebook[]) => {
if (visited.has(notebook.id)) return;
visited.add(notebook.id);
const nextPath = [...path, notebook];
result.push({
notebook,
path: nextPath,
memos: memos.get(notebook.id) ?? [],
});
for (const child of children.get(notebook.id) ?? [])
visit(child, nextPath);
result.push({ notebook, path: nextPath, memos: memos.get(notebook.id) ?? [] });
for (const child of children.get(notebook.id) ?? []) visit(child, nextPath);
};
visit(
notebooks.get(share.notebookId) ?? {
id: share.notebookId,
parentId: null,
name: share.name,
},
[],
);
visit(notebooks.get(share.notebookId) ?? { id: share.notebookId, parentId: null, name: share.name }, []);
return result;
}, [share]);
const notes = useMemo(
() =>
sections.flatMap((section) =>
section.memos.map((memo) => ({ memo, section })),
),
[sections],
);
const query = search.trim().toLocaleLowerCase();
const matches = useMemo(
() =>
notes.filter(({ memo }) =>
getMemoDisplayTitle(memo.title, t("common.untitledMemo"))
.toLocaleLowerCase()
.includes(query),
),
[notes, query, t],
);
const matchingIds = useMemo(
() => new Set(matches.map(({ memo }) => memo.id)),
[matches],
);
const notes = useMemo(() => sections.flatMap((section) => section.memos.map((memo) => ({ memo, section }))), [sections]);
const index = notes.findIndex(({ memo }) => memo.id === selectedId);
const current = notes[index];
const navigate = () => {
setMobileOpen(false);
setSearch("");
};
const navigate = () => setMobileOpen(false);
useEffect(() => {
// Reset reading position only on navigation, never on the periodic content refresh.
if (!selectedId && hash) {
try {
document
.getElementById(decodeURIComponent(hash.slice(1)))
?.scrollIntoView({ block: "start", behavior: "instant" });
} catch {
// A malformed fragment must not prevent the site from rendering.
const shortcut = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault(); setSearchOpen((open) => !open);
}
} else {
window.scrollTo({ top: 0, behavior: "instant" });
}
};
window.addEventListener("keydown", shortcut);
return () => window.removeEventListener("keydown", shortcut);
}, []);
useEffect(() => {
if (hash && !selectedId) {
try { document.getElementById(decodeURIComponent(hash.slice(1)))?.scrollIntoView({ block: "start", behavior: "instant" }); } catch { /* Ignore malformed fragments. */ }
} else if (!hash) window.scrollTo({ top: 0, behavior: "instant" });
}, [selectedId, token, hash]);
const contents = (
<TooltipProvider delayDuration={400}>
<div className="flex min-h-0 flex-1 flex-col">
<div className="px-6 pb-4">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-2.5 size-3.5 text-slate-400" />
<Input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("sharing.siteSearch")}
aria-label={t("sharing.siteSearch")}
className="h-9 border-transparent bg-slate-100/70 pl-9 pr-9 text-xs focus-visible:border-slate-300"
/>
{search && (
<Button
variant="ghost"
size="icon-sm"
className="absolute right-1.5 top-1"
aria-label={t("sharing.siteClearSearch")}
onClick={() => setSearch("")}
>
<X className="size-3.5" />
</Button>
)}
</div>
</div>
<nav
aria-label={t("sharing.notebookContents")}
className="min-h-0 flex-1 overflow-y-auto px-5 pb-6"
>
<Link
to={root}
onClick={navigate}
aria-current={!selectedId ? "page" : undefined}
className={cn(
linkClass,
"block px-3 py-2 text-[13px] font-medium",
!selectedId
? "text-slate-900"
: "text-slate-600 hover:bg-slate-100",
)}
>
{t("sharing.siteHome")}
</Link>
{query ? (
<>
<p className="px-2 py-2 text-xs text-slate-500" role="status">
{t("sharing.siteResults", { count: matches.length })}
</p>
<ul>
{matches.map(({ memo, section }) => (
<li key={memo.id}>
<Link
to={href(memo.id)}
onClick={navigate}
className={cn(
linkClass,
"block px-3 py-2 text-[13px] leading-5 text-slate-600 hover:bg-slate-100/70",
)}
>
<NoteTitle memo={memo} />
<span className="mt-1 block text-xs text-slate-400">
{section.path.map((item) => item.name).join(" / ")}
</span>
</Link>
</li>
))}
</ul>
{!matches.length && (
<p className="px-2 py-4 text-sm text-slate-500">
{t("sharing.siteNoResults")}
</p>
)}
</>
) : (
<>
{sections[0] && (
<NavigationNotes
memos={sections[0].memos}
selectedId={selectedId}
href={href}
onNavigate={navigate}
/>
)}
{sections
.slice(1)
.filter((section) => section.memos.length)
.map((section) => (
<NavigationSection
key={section.notebook.id}
section={section}
selectedId={selectedId}
containsSelection={
current?.section.notebook.id === section.notebook.id
}
href={href}
onNavigate={navigate}
/>
))}
</>
)}
</nav>
<div className="px-8 py-5 text-[11px] text-slate-400">
{t("sharing.sitePoweredBy")}
</div>
</div>
</TooltipProvider>
);
return (
<div
data-site-theme="default"
className="min-h-[100dvh] bg-card text-slate-900"
>
<a
href="#notebook-site-content"
className="sr-only z-[60] rounded-md bg-card p-3 focus:not-sr-only focus:fixed focus:left-4 focus:top-4"
>
{t("sharing.siteSkipToContent")}
</a>
<header className="sticky top-0 z-30 flex h-16 items-center justify-between gap-4 border-b border-slate-200 bg-card/95 px-4 backdrop-blur sm:px-6">
<Link
to={root}
onClick={navigate}
className={cn(
linkClass,
"flex min-w-0 items-center gap-3 font-semibold",
)}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-700">
<BookOpen className="size-5" />
</span>
<span className="truncate">{share.name}</span>
</Link>
<div className="flex shrink-0 items-center gap-3">
<Button
variant="ghost"
size="icon"
className="hidden lg:inline-flex"
aria-label={t(
sidebarOpen
? "sharing.siteHideContents"
: "sharing.siteShowContents",
)}
title={t(
sidebarOpen
? "sharing.siteHideContents"
: "sharing.siteShowContents",
)}
aria-expanded={sidebarOpen}
aria-controls="notebook-site-sidebar"
onClick={() => setSidebarOpen((open) => !open)}
>
{sidebarOpen ? (
<PanelLeftClose className="size-4" />
) : (
<PanelLeftOpen className="size-4" />
)}
</Button>
<span className="hidden text-xs text-slate-500 sm:block">
{t("sharing.readOnly")}
</span>
<Drawer
open={mobileOpen}
onOpenChange={setMobileOpen}
shouldScaleBackground={false}
>
<DrawerTrigger asChild>
<Button variant="outline" size="sm" className="lg:hidden">
<Menu className="size-4" />
{t("sharing.notebookContents")}
</Button>
</DrawerTrigger>
<DrawerContent className="h-[85dvh] bg-card">
<DrawerHeader className="relative pr-12">
<DrawerClose asChild>
<Button
variant="ghost"
size="icon"
aria-label={t("common.close")}
className="absolute right-3 top-2"
>
<X className="size-4" />
</Button>
</DrawerClose>
<DrawerTitle>{share.name}</DrawerTitle>
<DrawerDescription>
{t("sharing.siteNoteCount", { count: notes.length })}
</DrawerDescription>
</DrawerHeader>
{contents}
</DrawerContent>
</Drawer>
const contents = <div className="flex min-h-0 flex-1 flex-col">
<div className="px-6 pb-4"><Button variant="outline" className="w-full justify-start text-xs" onClick={() => setSearchOpen(true)}><Search className="size-4" />{t("sharing.siteSearch")}</Button></div>
<nav aria-label={t("sharing.notebookContents")} className="min-h-0 flex-1 overflow-y-auto px-5 pb-6">
<Link to={root} onClick={navigate} className={`${linkClass} block px-3 py-2 text-sm font-medium`}>{t("sharing.siteHome")}</Link>
{share.settings?.homeMemoId && <Link to={directory} onClick={navigate} className={`${linkClass} block px-3 py-2 text-sm`}>{t("notebookSite.directory")}</Link>}
{sections[0] && <NavigationNotes memos={sections[0].memos} selectedId={selectedId} href={href} onNavigate={navigate} />}
{sections.slice(1).filter((section) => section.memos.length).map((section) => <NavigationSection key={section.notebook.id} section={section} selectedId={selectedId} containsSelection={section.memos.some((memo) => memo.id === selectedId)} href={href} onNavigate={navigate} />)}
</nav><div className="px-8 py-5 text-[11px] text-muted-foreground">{t("sharing.sitePoweredBy")}</div>
</div>;
return <NotebookSiteSearchContext.Provider value={(selectedTag) => { setTag(selectedTag ?? ""); setSearchOpen(true); }}><TooltipProvider delayDuration={400}>
<div data-site-theme="default" className="edgeever-notebook-site min-h-[100dvh] bg-card text-foreground">
<a href="#notebook-site-content" className="sr-only z-[60] rounded-md bg-card p-3 focus:not-sr-only focus:fixed focus:left-4 focus:top-4">{t("sharing.siteSkipToContent")}</a>
<header className="sticky top-0 z-30 flex min-h-16 flex-wrap items-center justify-between gap-3 border-b bg-card/95 px-4 py-3 backdrop-blur sm:px-6">
<Link to={root} onClick={navigate} className={`${linkClass} flex min-w-0 items-center gap-3 font-semibold`}><SiteLogo key={share.settings?.logoUrl} src={share.settings?.logoUrl} /><span className="max-w-[40vw] truncate">{share.name}</span></Link>
<div className="flex flex-wrap items-center gap-3">
<nav className="flex flex-wrap gap-3 text-xs">{share.settings?.headerLinks.map((link, i) => <a key={i} href={link.url} target="_blank" rel="noopener noreferrer" className="underline underline-offset-4">{link.label}</a>)}</nav>
<select aria-label={t("notebookSite.theme")} value={preference} onChange={(event) => setPreference(event.target.value as ThemePreference)} className="max-w-28 rounded border bg-background px-1 py-1 text-xs">{(["light", "dark", "system"] as const).map((value) => <option key={value} value={value}>{t(`notebookSite.${value}`)}</option>)}</select>
<ButtonTooltip title={t(sidebarOpen ? "sharing.siteHideContents" : "sharing.siteShowContents")}><Button variant="ghost" size="icon" className="hidden lg:inline-flex" aria-label={t(sidebarOpen ? "sharing.siteHideContents" : "sharing.siteShowContents")} aria-expanded={sidebarOpen} aria-controls="notebook-site-sidebar" onClick={() => setSidebarOpen((open) => !open)}>{sidebarOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}</Button></ButtonTooltip>
<Drawer open={mobileOpen} onOpenChange={setMobileOpen} shouldScaleBackground={false}><DrawerTrigger asChild><Button variant="outline" size="sm" className="lg:hidden"><Menu className="size-4" />{t("sharing.notebookContents")}</Button></DrawerTrigger><DrawerContent className="h-[85dvh] bg-card"><DrawerHeader><DrawerTitle>{share.name}</DrawerTitle><DrawerDescription>{t("sharing.siteNoteCount", { count: notes.length })}</DrawerDescription><DrawerClose asChild><Button variant="outline" size="sm">{t("common.close")}</Button></DrawerClose></DrawerHeader>{contents}</DrawerContent></Drawer>
</div>
</header>
<div className="mx-auto flex max-w-[1600px] items-start">
<aside
id="notebook-site-sidebar"
className={cn(
"sticky top-16 hidden h-[calc(100dvh-4rem)] w-72 shrink-0 flex-col border-r border-slate-200/70 bg-card pt-6",
sidebarOpen && "lg:flex",
)}
>
{contents}
</aside>
<main
id="notebook-site-content"
tabIndex={-1}
className="min-w-0 flex-1 outline-none"
>
{!selectedId ? (
<div className="mx-auto max-w-5xl px-5 py-10 sm:px-10 sm:py-14">
<p className="mb-4 text-sm font-medium text-slate-500">
{t("sharing.siteNoteCount", { count: notes.length })}
</p>
<h1 className="break-words text-3xl font-bold tracking-tight sm:text-4xl">
{share.name}
</h1>
{!notes.length && (
<p className="mt-4 text-sm text-slate-500">
{t("sharing.notebookEmpty")}
</p>
)}
{query && (
<p className="mt-6 text-sm text-slate-500" role="status">
{t("sharing.siteResults", { count: matches.length })}
</p>
)}
{(!query || matches.length > 0) && (
<HomepageContents
sections={sections}
matchingIds={matchingIds}
filtering={Boolean(query)}
href={href}
onNavigate={navigate}
/>
)}
{query && !matches.length && (
<p className="py-10 text-sm text-slate-500">
{t("sharing.siteNoResults")}
</p>
)}
</div>
) : (
<>
<nav
aria-label={t("sharing.siteBreadcrumb")}
className="mx-auto flex max-w-4xl flex-wrap items-center gap-2 px-5 pt-7 text-xs leading-6 text-slate-500 sm:px-10"
>
<Link
to={root}
onClick={navigate}
className={cn(linkClass, "hover:text-slate-900")}
>
{t("sharing.siteHome")}
</Link>
{current?.section.path.map((notebook) => (
<span key={notebook.id} className="contents">
<ChevronRight className="size-3 shrink-0" />
<Link
to={`${root}#section-${notebook.id}`}
onClick={navigate}
className={cn(
linkClass,
"break-words hover:text-slate-900",
)}
>
{notebook.name}
</Link>
</span>
))}
{current && (
<>
<ChevronRight className="size-3 shrink-0" />
<span
aria-current="page"
className="break-words text-slate-700"
>
<NoteTitle memo={current.memo} />
</span>
</>
)}
</nav>
{children}
{current && (
<nav
aria-label={t("sharing.siteReadingNavigation")}
className="mx-auto grid max-w-4xl grid-cols-2 gap-4 border-t border-slate-200 px-5 py-8 sm:px-10"
>
{notes[index - 1] ? (
<Link
to={href(notes[index - 1].memo.id)}
onClick={navigate}
className={cn(linkClass, "min-w-0 p-3 hover:bg-slate-50")}
>
<span className="mb-2 flex items-center gap-2 text-xs text-slate-500">
<ArrowLeft className="size-3.5" />
{t("sharing.sitePrevious")}
</span>
<span className="text-sm font-medium">
<NoteTitle memo={notes[index - 1].memo} />
</span>
</Link>
) : (
<span />
)}
{notes[index + 1] && (
<Link
to={href(notes[index + 1].memo.id)}
onClick={navigate}
className={cn(
linkClass,
"min-w-0 p-3 text-right hover:bg-slate-50",
)}
>
<span className="mb-2 flex items-center justify-end gap-2 text-xs text-slate-500">
{t("sharing.siteNext")}
<ArrowRight className="size-3.5" />
</span>
<span className="text-sm font-medium">
<NoteTitle memo={notes[index + 1].memo} />
</span>
</Link>
)}
</nav>
)}
</>
)}
<aside id="notebook-site-sidebar" className={cn("sticky top-16 hidden h-[calc(100dvh-4rem)] w-72 shrink-0 flex-col border-r bg-card pt-6", sidebarOpen && "lg:flex")}>{contents}</aside>
<main id="notebook-site-content" tabIndex={-1} className="min-w-0 flex-1 outline-none">
{!selectedId ? <div className="mx-auto max-w-5xl px-5 py-10 sm:px-10 sm:py-14"><p className="mb-4 text-sm text-muted-foreground">{t("sharing.siteNoteCount", { count: notes.length })}</p><h1 className="break-words text-3xl font-bold">{share.name}</h1>{!notes.length && <p className="mt-4">{t("sharing.notebookEmpty")}</p>}<HomepageContents sections={sections} href={href} onNavigate={navigate} /></div> : <>
<nav aria-label={t("sharing.siteBreadcrumb")} className="flex flex-wrap items-center gap-2 border-b px-5 py-3 text-xs text-muted-foreground"><Link to={root} onClick={navigate}>{t("sharing.siteHome")}</Link>{current?.section.path.map((notebook) => <span key={notebook.id} className="contents"><ChevronRight className="size-3" /><Link to={`${directory}#section-${notebook.id}`} onClick={navigate}>{notebook.name}</Link></span>)}{current && <span aria-current="page"><NoteTitle memo={current.memo} /></span>}</nav>
{children}
{current && <nav aria-label={t("sharing.siteReadingNavigation")} className="mx-auto grid max-w-4xl grid-cols-2 gap-4 border-t px-5 py-8 sm:px-10">
{notes[index - 1] ? <Link to={href(notes[index - 1].memo.id)} onClick={navigate} className={`${linkClass} min-w-0 p-3 hover:bg-muted`}><span className="mb-2 flex items-center gap-2 text-xs text-muted-foreground"><ArrowLeft className="size-3.5" />{t("sharing.sitePrevious")}</span><NoteTitle memo={notes[index - 1].memo} /></Link> : <span />}
{notes[index + 1] && <Link to={href(notes[index + 1].memo.id)} onClick={navigate} className={`${linkClass} min-w-0 p-3 text-right hover:bg-muted`}><span className="mb-2 flex items-center justify-end gap-2 text-xs text-muted-foreground">{t("sharing.siteNext")}<ArrowRight className="size-3.5" /></span><NoteTitle memo={notes[index + 1].memo} /></Link>}
</nav>}
</>}
{share.settings?.footerText && <footer className="whitespace-pre-wrap border-t px-5 py-6 text-sm text-muted-foreground">{share.settings.footerText}</footer>}
</main>
</div>
</div>
);
<NotebookSiteSearch token={token} share={share} root={root} open={searchOpen} setOpen={setSearchOpen} tag={tag} setTag={setTag} />
</TooltipProvider></NotebookSiteSearchContext.Provider>;
};
+38 -12
View File
@@ -1,10 +1,10 @@
import "katex/dist/katex.min.css";
import { Node, mergeAttributes } from "@tiptap/core";
import Image from "@tiptap/extension-image";
import { EditorContent, useEditor } from "@tiptap/react";
import { EditorContent, useEditor, ReactNodeViewRenderer } from "@tiptap/react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Clock3, FileText, LoaderCircle, LockKeyhole, ShieldCheck } from "lucide-react";
import { lazy, Suspense, useEffect, useMemo, useState, type CSSProperties, type FormEvent, type MouseEvent } from "react";
import { lazy, Suspense, useContext, useEffect, useMemo, useRef, useState, type CSSProperties, type FormEvent, type MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router";
import { cn } from "@/lib/utils";
@@ -32,6 +32,10 @@ import { applyEditorBodyFontPreference } from "@/lib/editor-body-font";
import { createEdgeEverMathematics } from "@edgeever/shared/mathematics";
import { PdfAttachment } from "@/components/editor/PdfAttachment";
import { FileAttachment } from "@/components/editor/FileAttachment";
import { NotebookSiteCodeBlock } from "./NotebookSiteCodeBlock";
import { NotebookSiteArticleTools } from "./NotebookSiteArticleTools";
import { NotebookSiteSearchContext } from "./NotebookSiteSearch";
import { useAppearanceTheme } from "./ThemeProvider";
const ReadOnlyX6Diagram = lazy(() => import("@/components/ReadOnlyX6Diagram"));
const ImageViewer = lazy(() => import("@/components/editor/ImageViewer").then((module) => ({ default: module.ImageViewer })));
@@ -86,9 +90,11 @@ const SharedThemeBlock = Node.create({
},
});
const SharedRichText = ({ content }: { content: PublicMemoShare["contentJson"] }) => {
const SiteCodeBlock = EdgeEverCodeBlock.extend({ addNodeView() { return ReactNodeViewRenderer(NotebookSiteCodeBlock); } });
const SharedRichText = ({ content, site = false }: { content: PublicMemoShare["contentJson"]; site?: boolean }) => {
const { t } = useTranslation();
const [imagePreview, setImagePreview] = useState<{ alt: string; url: string } | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const editor = useEditor({
extensions: [
...createEdgeEverDocumentExtensions({
@@ -99,7 +105,7 @@ const SharedRichText = ({ content }: { content: PublicMemoShare["contentJson"] }
file: FileAttachment,
table: { table: { renderWrapper: true } },
}),
EdgeEverCodeBlock.configure({ lowlight: codeBlockLowlight, defaultLanguage: "plaintext" }),
(site ? SiteCodeBlock : EdgeEverCodeBlock).configure({ lowlight: codeBlockLowlight, defaultLanguage: "plaintext" }),
SharedThemeBlock,
],
content,
@@ -111,6 +117,13 @@ const SharedRichText = ({ content }: { content: PublicMemoShare["contentJson"] }
},
},
}, [content]);
useEffect(() => {
if (!site || !editor) return;
containerRef.current?.querySelectorAll("img").forEach((img) => {
img.tabIndex = 0; img.setAttribute("role", "button");
img.setAttribute("aria-label", img.alt || t("editor.imageViewer"));
});
}, [editor, site, content, t]);
const previewImage = (event: MouseEvent<HTMLDivElement>) => {
const target = event.target;
@@ -124,7 +137,11 @@ const SharedRichText = ({ content }: { content: PublicMemoShare["contentJson"] }
};
return (
<div onClickCapture={jumpToHeading} onDoubleClick={previewImage}>
<div ref={containerRef} onClickCapture={jumpToHeading} onClick={site ? previewImage : undefined} onDoubleClick={site ? undefined : previewImage} onKeyDown={site ? (event) => {
if ((event.key === "Enter" || event.key === " ") && event.target instanceof HTMLImageElement) {
event.preventDefault(); setImagePreview({ alt: event.target.alt, url: event.target.currentSrc });
}
} : undefined}>
<EditorContent editor={editor} />
{imagePreview ? (
<Suspense fallback={null}>
@@ -149,12 +166,15 @@ const SharedDocument = ({
share,
token,
access,
site = false,
}: {
locale: "zh-CN" | "en-US" | "ja" | "pl";
share: PublicMemoShare;
token: string;
access?: ShareBodyAccess;
site?: boolean;
}) => {
const { resolvedTheme } = useAppearanceTheme();
const body = useMemo(() => resolvePublicShareBody(share, token, access), [share, token, access]);
if (body.type === "diagram") {
return (
@@ -165,11 +185,11 @@ const SharedDocument = ({
</div>
}
>
<ReadOnlyX6Diagram diagram={body.diagram} locale={locale} theme="light" />
<ReadOnlyX6Diagram diagram={body.diagram} locale={locale} theme={site ? resolvedTheme : "light"} controls={site} />
</Suspense>
);
}
return <SharedRichText content={body.content} />;
return <SharedRichText content={body.content} site={site} />;
};
export const isSharePasswordError = (error: unknown, code: string) =>
@@ -300,6 +320,8 @@ export const PublicShareArticle = ({
layout?: "standalone" | "site";
}) => {
const { t, i18n } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const openSearch = useContext(NotebookSiteSearchContext);
const publishedBodyFont = parsePublishedNoteBodyFont(share.bodyFont);
useEffect(() => {
@@ -323,9 +345,10 @@ export const PublicShareArticle = ({
const prose = resolveNoteProse(share.prose);
return (
<div
ref={containerRef}
className={cn(
"edgeever-public-share",
layout === "site" ? "py-6 sm:py-8" : "px-4 py-6 sm:px-8 sm:py-10",
layout === "site" ? "py-6 sm:py-8 xl:pr-56" : "px-4 py-6 sm:px-8 sm:py-10",
)}
>
<article
@@ -380,14 +403,15 @@ export const PublicShareArticle = ({
layout === "site" && "order-3",
)}
>
{share.tags.map((tag) => (
<span
{share.tags.map((tag) => layout === "site" ? (
<button type="button"
key={tag}
onClick={() => openSearch(tag)}
className="rounded-full bg-slate-100 px-2.5 py-1 text-xs text-slate-600"
>
#{tag}
</span>
))}
</button>
) : <span key={tag} className="rounded-full bg-slate-100 px-2.5 py-1 text-xs text-slate-600">#{tag}</span>)}
</div>
) : null}
</header>
@@ -412,9 +436,11 @@ export const PublicShareArticle = ({
share={share}
token={token}
access={access}
site={layout === "site"}
/>
</div>
</article>
{layout === "site" && <NotebookSiteArticleTools containerRef={containerRef} token={token} memoUrls={access?.memoUrls ?? {}} />}
</div>
);
};
+28 -2
View File
@@ -1,4 +1,7 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "./ui/button";
import { ButtonTooltip } from "./ui/button-tooltip";
import { Graph } from "@antv/x6";
import {
attachDiagramReader,
@@ -26,12 +29,25 @@ export const ReadOnlyX6Diagram = ({
diagram,
locale,
theme,
controls = false,
}: {
diagram: DiagramDocument;
locale: "zh-CN" | "en-US" | "ja" | "pl";
theme: "light" | "dark";
controls?: boolean;
}) => {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const graphRef = useRef<Graph | null>(null);
const [fullscreen, setFullscreen] = useState(false);
useEffect(() => {
if (!fullscreen) return;
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
const escape = (event: KeyboardEvent) => { if (event.key === "Escape") setFullscreen(false); };
window.addEventListener("keydown", escape);
return () => { document.body.style.overflow = previous; window.removeEventListener("keydown", escape); };
}, [fullscreen]);
useEffect(() => {
const container = containerRef.current;
@@ -65,6 +81,7 @@ export const ReadOnlyX6Diagram = ({
panning: { enabled: true },
mousewheel: { enabled: true, minScale: 0.1, maxScale: 2.5 },
});
graphRef.current = graph;
graph.addNodes(cells.nodes);
graph.addEdges(cells.edges);
const sized = {
@@ -85,11 +102,12 @@ export const ReadOnlyX6Diagram = ({
observer.disconnect();
reader.dispose();
graph.dispose();
graphRef.current = null;
};
}, [diagram, locale, theme]);
return (
<section className="edgeever-x6-document">
<section className={`edgeever-x6-document${fullscreen ? " edgeever-x6-fullscreen" : ""}`}>
<style>{`
.edgeever-x6-document, .edgeever-diagram-reader-host {
display: flex;
@@ -99,6 +117,10 @@ export const ReadOnlyX6Diagram = ({
padding: 8px 12px 16px;
}
.edgeever-diagram-reader-controls { flex: 0 0 auto; }
.edgeever-x6-document.edgeever-x6-fullscreen {
position: fixed; inset: 0; z-index: 60; height: 100dvh; min-height: 100dvh;
background: ${theme === "dark" ? DIAGRAM_CANVAS_DARK : DIAGRAM_CANVAS_LIGHT};
}
.edgeever-x6-diagram {
flex: 1 1 auto;
width: 100%;
@@ -113,6 +135,10 @@ export const ReadOnlyX6Diagram = ({
.edgeever-x6-diagram .x6-graph-svg { overflow: hidden; }
.edgeever-x6-diagram .x6-node { cursor: pointer; }
`}</style>
{controls && <div className="flex shrink-0 justify-end gap-2 pb-2">
<ButtonTooltip title={t("notebookSite.fit")}><Button variant="outline" size="sm" onClick={() => { graphRef.current?.zoomToFit({ maxScale: 1, padding: 28 }); graphRef.current?.centerContent(); }}>{t("notebookSite.fit")}</Button></ButtonTooltip>
<ButtonTooltip title={t(fullscreen ? "notebookSite.exitFullscreen" : "notebookSite.fullscreen")}><Button variant="outline" size="sm" aria-pressed={fullscreen} onClick={() => setFullscreen((value) => !value)}>{t(fullscreen ? "notebookSite.exitFullscreen" : "notebookSite.fullscreen")}</Button></ButtonTooltip>
</div>}
<div
aria-label={diagramTitle(diagram, locale)}
className="edgeever-x6-diagram"
@@ -11,6 +11,7 @@ import { ShareQrCode } from "./ShareQrCode";
import { SHARE_DIALOG_PRIMARY_BUTTON_CLASS_NAME, SHARE_DIALOG_WIDTH_CLASS_NAME } from "./share-dialog-layout";
import { api, getConfiguredDesktopApiBaseUrl } from "@/lib/api";
import { copyTextToClipboard } from "@/lib/clipboard";
import { MemoNotebookSites } from "../MemoNotebookSites";
export const getPublicShareUrl = (token: string) => {
const baseUrl = getConfiguredDesktopApiBaseUrl() || window.location.origin;
@@ -35,7 +36,6 @@ export const ShareMemoDialog = ({
const [copyTarget, setCopyTarget] = useState<"link" | "password" | "both">("link");
const copyResetTimerRef = useRef<number | null>(null);
const dialogContentRef = useRef<HTMLDivElement>(null);
const autoCreateAttemptRef = useRef<string | null>(null);
const queryKey = memoShareQueryKey(memoId);
const shareQuery = useQuery({
queryKey,
@@ -60,11 +60,10 @@ export const ShareMemoDialog = ({
},
});
const revokeMutation = useMutation({
mutationFn: () => api.revokeMemoShare(memoId),
onSuccess: () => {
queryClient.setQueryData(queryKey, { share: null });
mutationFn: (targetMemoId: string) => api.revokeMemoShare(targetMemoId),
onSuccess: (_, targetMemoId) => {
queryClient.setQueryData(memoShareQueryKey(targetMemoId), { share: null });
void queryClient.invalidateQueries({ queryKey: ["memo-shares"] });
onOpenChange(false);
},
});
useEffect(() => {
@@ -85,20 +84,6 @@ export const ShareMemoDialog = ({
const isWorking = shareQuery.isLoading || createMutation.isPending || passwordMutation.isPending || revokeMutation.isPending;
const error = shareQuery.error || createMutation.error || passwordMutation.error || revokeMutation.error;
useEffect(() => {
if (!open) {
autoCreateAttemptRef.current = null;
return;
}
// Wait for a successful read: a failed lookup must not enable public access.
// Attempt once per opening, including under StrictMode and after refetches.
if (shareQuery.isSuccess && !shareQuery.isFetching && !share &&
!revokeMutation.isSuccess && autoCreateAttemptRef.current !== memoId) {
autoCreateAttemptRef.current = memoId;
createMutation.mutate();
}
}, [open, memoId, shareQuery.isSuccess, shareQuery.isFetching, share, revokeMutation.isSuccess, createMutation.mutate]);
const markCopied = (target: "link" | "password" | "both", copied: boolean) => {
setCopyTarget(target);
setCopyState(copied ? "copied" : "error");
@@ -118,7 +103,7 @@ export const ShareMemoDialog = ({
<DialogContent
ref={dialogContentRef}
aria-describedby={undefined}
className={`${SHARE_DIALOG_WIDTH_CLASS_NAME} space-y-4`}
className={`${SHARE_DIALOG_WIDTH_CLASS_NAME} max-h-[85dvh] space-y-4 overflow-y-auto`}
onOpenAutoFocus={(event) => {
event.preventDefault();
dialogContentRef.current?.focus({ preventScroll: true });
@@ -131,7 +116,9 @@ export const ShareMemoDialog = ({
</DialogTitle>
</DialogHeader>
{shareQuery.isLoading || (!share && !error) ? (
<MemoNotebookSites key={memoId} memoId={memoId} />
<h2 className="text-sm font-semibold">{t("sharing.individual")}</h2>
{shareQuery.isLoading ? (
<div className="flex min-h-20 items-center justify-center text-slate-500" role="status">
<LoaderCircle className="h-5 w-5 animate-spin" aria-label={t("sharing.publicLoading")} />
</div>
@@ -180,7 +167,7 @@ export const ShareMemoDialog = ({
)}
<div className="flex items-center justify-between pt-2">
<Button variant="danger" disabled={isWorking} onClick={() => revokeMutation.mutate()}>
<Button variant="danger" disabled={isWorking} onClick={() => revokeMutation.mutate(memoId, { onSuccess: () => onOpenChange(false) })}>
<Trash2 className="h-4 w-4" />
{t("sharing.revoke")}
</Button>
@@ -201,7 +188,7 @@ export const ShareMemoDialog = ({
}}
>
{createMutation.isPending ? <LoaderCircle className="h-4 w-4 animate-spin" /> : null}
{t("sharing.retry")}
{t(shareQuery.isError ? "sharing.retry" : "sharing.createIndividual")}
</Button>
)}
{error ? <p className="text-xs leading-5 text-rose-600" role="alert">{t("sharing.error")}</p> : null}
@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, ExternalLink, LoaderCircle, Share2, Trash2 } from "lucide-react";
import { Check, ChevronDown, Copy, ExternalLink, LoaderCircle, Settings2, Share2, Trash2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router";
@@ -15,11 +15,13 @@ import { SharePasswordControls } from "./SharePasswordControls";
import { ShareQrCode } from "./ShareQrCode";
import { SHARE_DIALOG_PRIMARY_BUTTON_CLASS_NAME, SHARE_DIALOG_WIDTH_CLASS_NAME } from "./share-dialog-layout";
import { Input } from "@/components/ui/input";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { NotebookSiteSettings } from "../NotebookSiteSettings";
import { api, getConfiguredDesktopApiBaseUrl } from "@/lib/api";
import { copyTextToClipboard } from "@/lib/clipboard";
export const getPublicNotebookShareUrl = (token: string) =>
`${(getConfiguredDesktopApiBaseUrl() || window.location.origin).replace(/\/$/, "")}/share/notebook/${encodeURIComponent(token)}`;
export const getPublicNotebookShareUrl = (token: string, slug?: string | null) =>
`${(getConfiguredDesktopApiBaseUrl() || window.location.origin).replace(/\/$/, "")}${slug ? `/s/${encodeURIComponent(slug)}` : `/share/notebook/${encodeURIComponent(token)}`}`;
export const ShareNotebookDialog = ({
notebookId,
@@ -100,7 +102,8 @@ export const ShareNotebookDialog = ({
const revealedPassword = share?.passwordProtected ? share.password ?? "" : "";
const shareUrl = share ? getPublicNotebookShareUrl(share.token) : "";
const shareUrl = share ? getPublicNotebookShareUrl(share.token, share.siteSlug) : "";
const sitePath = `/notebooks/${encodeURIComponent(notebookId)}/site`;
const busy = query.isFetching || create.isPending || revoke.isPending || passwordMutation.isPending;
const failed = query.isError || create.isError || revoke.isError || passwordMutation.isError;
const hasPassword = Boolean(share?.passwordProtected && revealedPassword);
@@ -122,7 +125,7 @@ export const ShareNotebookDialog = ({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
ref={dialogContentRef}
className={`${SHARE_DIALOG_WIDTH_CLASS_NAME} space-y-4`}
className={`${SHARE_DIALOG_WIDTH_CLASS_NAME} max-h-[90dvh] overflow-y-auto space-y-4`}
onOpenAutoFocus={(event) => {
event.preventDefault();
dialogContentRef.current?.focus({ preventScroll: true });
@@ -131,7 +134,7 @@ export const ShareNotebookDialog = ({
<DialogHeader className="space-y-1.5 text-left">
<DialogTitle className="flex items-center gap-2 text-base font-semibold">
<Share2 className="h-5 w-5 text-slate-700" />
{t(share ? "sharing.manage" : inherited ? "sharing.notebookInheritedAction" : "sharing.notebookTitle")}
{t("sharing.notebookTitle")}
</DialogTitle>
<DialogDescription className="text-xs leading-5 text-slate-500">
<span className="font-medium text-slate-800">{notebookName}</span>
@@ -189,6 +192,22 @@ export const ShareNotebookDialog = ({
{share.passwordProtected ? null : (
<ShareQrCode key={`qr-${share.token}`} url={shareUrl} hint={t("sharing.qrCodeNotebookHint")} />
)}
{location.pathname !== sitePath && <Collapsible key={`site-settings-${share.token}`}>
<CollapsibleTrigger asChild>
<Button type="button" variant="outline" className="group w-full justify-between">
<span className="flex items-center gap-2"><Settings2 className="h-4 w-4" />{t("notebookSite.advancedSettings")}</span>
<ChevronDown className="h-4 w-4 transition-transform group-data-[state=open]:rotate-180" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent forceMount className="pt-4 data-[state=closed]:hidden">
<NotebookSiteSettings
key={`${share.token}-${share.passwordProtected}`}
notebookId={notebookId}
share={share}
onSaved={(data) => { queryClient.setQueryData(queryKey, data); void invalidate(); }}
/>
</CollapsibleContent>
</Collapsible>}
<div className="flex items-center justify-between pt-2">
<Button
@@ -0,0 +1,7 @@
.edgeever-notebook-site .edgeever-public-share { position: relative; }
.edgeever-notebook-site .edgeever-notebook-code pre { margin: 0; border: 0; border-radius: 0; overflow-x: auto; padding: 1rem; }
.edgeever-notebook-site .tiptap img { cursor: zoom-in; }
.edgeever-notebook-site .tiptap img:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
html:has(.edgeever-notebook-site) body,
html:has(.edgeever-notebook-site) #root { overflow: visible; }
html:has(.edgeever-notebook-site) body.yarl__no_scroll { overflow: hidden; }
@@ -135,7 +135,7 @@ export const NotebookShareManagement = ({
{share.notebookName}
</span>
<ShareManagementActions
onOpen={() => window.open(getPublicNotebookShareUrl(share.token), "_blank", "noopener,noreferrer")}
onOpen={() => window.open(getPublicNotebookShareUrl(share.token, share.siteSlug), "_blank", "noopener,noreferrer")}
onManage={() => setManaged(share)}
/>
</div>
+1
View File
@@ -197,6 +197,7 @@ export default defineConfig({
{
urlPattern: ({ request, url }) =>
request.mode === "navigate" &&
!/^\/(?:share\/notebook|s)\//.test(url.pathname) &&
!["/mobile-edit.html", "/note-print.html", "/tiptap-ime-test.html"].includes(url.pathname),
handler: "NetworkFirst",
options: {