feat: 增强公开笔记本站点体验与发布控制 (#634)
公开笔记本站点新增长文导航、正文检索、逐站点排除和站点配置,保留实时分享以及原有 Token、密码和解锁 Cookie。收录只对无密码的别名页生效,Token 页保持完整 noindex。 迁移 0072 为 notebook_shares 增加站点列,并新增排除关系表。先执行迁移再部署。 Closes #591
This commit is contained in:
1 parent
00ddaa0fed
commit
9ae88e3381
46 files changed
+4297
-512
No files matched your search
@@ -39,6 +39,7 @@ export type Bindings = {
|
||||
};
|
||||
|
||||
export type WorkerBindings = Omit<Bindings, "storage" | "publicNetworkFetch" | "deploymentVersionCreatedAt"> & CloudflareStorageBindings & {
|
||||
ASSETS: Fetcher;
|
||||
CF_VERSION_METADATA?: { timestamp?: string };
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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("<script>secret()</script>");
|
||||
expect(html).toContain("<script>secret()</script> $& $' $$");
|
||||
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 & B "quote""');
|
||||
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();
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 });
|
||||
};
|
||||
@@ -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 }>();
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user