wiki-viewer 1.13.2 → 1.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/app-path-routes-manifest.json +1 -0
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/routes-manifest.json +6 -0
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/agent/activity/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/events/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/files/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/fs/file/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/fs/ls/[[...path]]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/fs/move/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/fs/search/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/internal/span/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/settings/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/sidecar/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js +1 -1
- package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/assets/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/share/[token]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/share/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/browse/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/reveal/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/set-root/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/workspaces/[id]/open/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/workspaces/[id]/refresh/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/workspaces/[id]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/workspaces/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/upload/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/app/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/backlinks/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/content/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/download/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/file/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/folder/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/git-branches/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/git-checkout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/git-diff/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/git-file-info/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/git-history/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/git-pull/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/move/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/new-file/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/outlinks/route/app-paths-manifest.json +3 -0
- package/.next/standalone/.next/server/app/api/wiki/outlinks/route/build-manifest.json +9 -0
- package/.next/standalone/.next/server/app/api/wiki/outlinks/route/server-reference-manifest.json +4 -0
- package/.next/standalone/.next/server/app/api/wiki/outlinks/route.js +16 -0
- package/.next/standalone/.next/server/app/api/wiki/outlinks/route.js.map +5 -0
- package/.next/standalone/.next/server/app/api/wiki/outlinks/route.js.nft.json +1 -0
- package/.next/standalone/.next/server/app/api/wiki/outlinks/route_client-reference-manifest.js +3 -0
- package/.next/standalone/.next/server/app/api/wiki/page/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/presence/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/search/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/slugs/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/upload/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/watch/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +3 -3
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/s/[token]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/s/[token]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/signin/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app-paths-manifest.json +1 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__01h996g._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0oruwos._.js → [root-of-the-server]__0stkifk._.js} +2 -2
- package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_wiki_outlinks_route_actions_0v-5x5r.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{0~w0_katex_dist_katex_mjs_0100dfi._.js → 0~w0_katex_dist_katex_mjs_029cdbg._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0~9qfcs._.js → [root-of-the-server]__0z41nrr._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0osa2xb._.js → [root-of-the-server]__10m6c40._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0yevh-d._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_0~a9dyk._.js +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_01df~jf._.js → node_modules__pnpm_0-_58b8._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0u0.e4r._.js → node_modules__pnpm_0026mgr._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0uo7x8s._.js → node_modules__pnpm_02bje_g._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0z~fyt8._.js → node_modules__pnpm_04v1_ty._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_04l9bx6._.js → node_modules__pnpm_0607~kg._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0lf1dtw._.js → node_modules__pnpm_08edl1n._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0i8hw7q._.js → node_modules__pnpm_08xpp3w._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0nt8do2._.js → node_modules__pnpm_09o_spj._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0a2d3t0._.js → node_modules__pnpm_0aha1~u._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0zgqorw._.js → node_modules__pnpm_0ajqqlq._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0pu-2sk._.js → node_modules__pnpm_0b8odbc._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0hrj.2x._.js → node_modules__pnpm_0e0r_qk._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0a71te0._.js → node_modules__pnpm_0f_n_zp._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0mg7y.1._.js → node_modules__pnpm_0gos._c._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_02_h794._.js → node_modules__pnpm_0hzzx9d._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0m-sqwd._.js → node_modules__pnpm_0i2m0b.._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0k.5pst._.js → node_modules__pnpm_0i6_kfe._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0suw78t._.js → node_modules__pnpm_0k-7uzm._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0mmji-1._.js → node_modules__pnpm_0ru1i2-._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0_4caj_._.js → node_modules__pnpm_0t4l89_._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0wqzfz7._.js → node_modules__pnpm_0w.kwue._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0gqcxiq._.js → node_modules__pnpm_0xk042~._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0ca5pep._.js → node_modules__pnpm_0xr.ztu._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0w_zv~3._.js → node_modules__pnpm_0y2h_cz._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0o.ke1-._.js → node_modules__pnpm_0~i3a.i._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_109~spe._.js → node_modules__pnpm_11ede0p._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0mn3jk_._.js → node_modules__pnpm_13p348f._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_app_page_tsx_0ss2.w7._.js +2 -2
- package/.next/standalone/.next/server/functions-config-manifest.json +1 -0
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/middleware-manifest.json +1 -1
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/static/chunks/02kg.lbkfi1yc.css +5 -0
- package/.next/standalone/.next/static/chunks/{0._793qr3oswh.js → 0ey6h_rkpuzxs.js} +8 -8
- package/.next/standalone/.next/static/chunks/{08bdsfl84~3l..js → 0jt1z17mjmjde.js} +7 -7
- package/.next/standalone/.next/static/chunks/{0wnjskl5t63nx.js → 0rcwd6-.iu~nd.js} +3 -3
- package/.next/standalone/.pi/ownership.json +3 -0
- package/.next/standalone/TODO.md +16 -0
- package/.next/standalone/package.json +2 -1
- package/.next/standalone/src/app/api/wiki/outlinks/route.ts +76 -0
- package/.next/standalone/src/app/globals.css +13 -15
- package/.next/standalone/src/components/ai-panel/activity-row.tsx +268 -53
- package/.next/standalone/src/components/ai-panel/ai-panel.tsx +3 -9
- package/.next/standalone/src/components/editor/backlinks-panel.tsx +47 -17
- package/.next/standalone/src/components/editor/bubble-menu.tsx +22 -1
- package/.next/standalone/src/components/editor/comment-thread.tsx +3 -2
- package/.next/standalone/src/components/editor/editor.tsx +9 -1
- package/.next/standalone/src/components/editor/suggestion-card.tsx +4 -0
- package/.next/standalone/src/components/editor/view-mode-comment-button.tsx +77 -0
- package/.next/standalone/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -1
- package/.next/standalone/.next/static/chunks/0v9z3r.aiug_u.css +0 -5
- /package/.next/standalone/.next/static/{Fe9EDoNlINdV6yroHKsnE → WMOTgfV82apmdovxzgbMx}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{Fe9EDoNlINdV6yroHKsnE → WMOTgfV82apmdovxzgbMx}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{Fe9EDoNlINdV6yroHKsnE → WMOTgfV82apmdovxzgbMx}/_ssgManifest.js +0 -0
package/.next/standalone/TODO.md
CHANGED
|
@@ -2,3 +2,19 @@
|
|
|
2
2
|
<!-- pi-todo-md:schema=1 -->
|
|
3
3
|
|
|
4
4
|
## Tasks
|
|
5
|
+
|
|
6
|
+
## Agent Presence (Theme 2)
|
|
7
|
+
- [ ] Live agent orb — drifting pastel orb in editor margin when agent holds Tier-2 write lock on current doc. Poll /api/agent/files/{path} collab-state or reuse presence heartbeat; render orb anchored to the active block position. Use gradient-orb pastel tokens (mint/peach/lavender). Hover = agent id + op type + elapsed time. <!-- pi-todo-md:id=1 -->
|
|
8
|
+
- note: Source of truth for "is agent active here": X-Collab-State header from /api/agent/files/{path} (active/tracked/untracked). Owner UI should poll this for currently-open file. No new backend needed, just a frontend polling hook + orb component. Wow ★★★★ / effort M.
|
|
9
|
+
- [ ] Watch-mode — when another agent is editing the open doc, auto-poll the event-stream (SSE or poll /api/agent/activity?file=) and apply incoming proof-span diffs to the TipTap view in near-real-time. Toggle: <!-- pi-todo-md:id=2 -->
|
|
10
|
+
- note: Toggle button in editor toolbar: "Watch live" on/off. SSE from /api/agent/events or poll /api/agent/activity?file=&since=. Render incoming proof-spans as fading highlights before they settle. Prereq: verify event-log completeness (not GC'd mid-session). Wow ★★★★ / effort M.
|
|
11
|
+
- [x] Activity newsfeed — replace the current raw activity panel with an editorial changelog. Each entry: agent avatar chip + human-readable summary ("Claude appended 3 blocks to notes.md · 2 pending your review") + relative timestamp. Group by file. Link to pending proof-spans. Data already exists in /api/agent/activity. Wow ★★★ / effort S. <!-- pi-todo-md:id=3 -->
|
|
12
|
+
|
|
13
|
+
## Local Wiki Graph
|
|
14
|
+
- [ ] Mini force-graph in backlinks panel — add a "Graph" tab alongside the existing backlinks list. Render a small D3/vis-network force graph: current doc = center node, backlinkers + outgoing [[links]] = surrounding nodes, edges = link direction. Click node = navigate. Backlinks panel + wikilink parser both already exist; missing piece = graph renderer + outgoing-links API endpoint. Wow ★★★★ / effort M. <!-- pi-todo-md:id=4 -->
|
|
15
|
+
- [x] Outgoing links API — new /api/wiki/outlinks?path= endpoint: parse [[wikilinks]] from a file using remark-wikilinks, return list of {slug, resolved_path, exists} entries. Needed by the graph tab. remark-wikilinks.ts already parses them; need to expose via HTTP + resolve slugs against the file index. Effort S. <!-- pi-todo-md:id=5 -->
|
|
16
|
+
|
|
17
|
+
## Comment on Share
|
|
18
|
+
- [ ] Share comment DB schema — add share_comments table to shared.db: id, share_token, body (text), author_name (nullable, visitor-supplied), created_at, status (pending|approved|rejected). Owner-only read/approve/reject via /api/share/[token]/comments (auth-gated). Visitors POST via public /api/share/[token]/comment (rate-limited, no auth). Wow ★★★★ / effort M. <!-- pi-todo-md:id=6 -->
|
|
19
|
+
- [ ] Share page comment UI — comment box at the bottom of the share viewer page. Name field (optional) + body textarea + submit. Pending state: 'Sent for review'. Rate-limit feedback. No JS framework gymnastics — simple form submit or fetch POST. <!-- pi-todo-md:id=7 -->
|
|
20
|
+
- [ ] Owner comment inbox — notification badge on shared-links panel when there are pending comments. Inline approve/reject with the comment body shown. On approve: comment becomes visible on the share page to all future visitors. On reject: silently dropped. <!-- pi-todo-md:id=8 -->
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-viewer",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.1",
|
|
4
4
|
"description": "Local file viewer and wiki — view markdown, HTML, PDFs, notebooks, office docs, and more from any directory",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"wiki": "node bin/wiki-viewer.js",
|
|
20
20
|
"postbuild": "node bin/postbuild.js",
|
|
21
21
|
"prepublishOnly": "npm run build",
|
|
22
|
+
"prepack": "node -e \"require('fs').existsSync('.next/standalone/server.js')||(console.error('ERROR: .next/standalone/server.js missing, run pnpm build before publishing'),process.exit(1))\"",
|
|
22
23
|
"test": "tsx --import ./src/tests/proof/preload.ts --test src/tests/proof/*.test.ts"
|
|
23
24
|
},
|
|
24
25
|
"engines": {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export const runtime = "nodejs";
|
|
2
|
+
|
|
3
|
+
import { NextResponse } from "next/server";
|
|
4
|
+
import { resolveWorkspaceForUser } from "@/lib/workspace-context";
|
|
5
|
+
import { getSearchDb } from "@/lib/search/search-db";
|
|
6
|
+
import { ensureIndexer } from "@/lib/search/indexer";
|
|
7
|
+
|
|
8
|
+
// Matches [[slug]], [[slug|alias]], [[slug#anchor]] — slug is [a-z0-9-]+
|
|
9
|
+
const WIKILINK_RE = /\[\[([a-z0-9-]+)(?:\|[^\]#|]+|#[a-z0-9-]+)?\]\]/gi;
|
|
10
|
+
|
|
11
|
+
function slugFromPath(filePath: string): string {
|
|
12
|
+
const base = filePath.split("/").pop() ?? filePath;
|
|
13
|
+
return base.replace(/\.md$/i, "").toLowerCase();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface OutlinkEntry {
|
|
17
|
+
slug: string;
|
|
18
|
+
resolved_path: string | null;
|
|
19
|
+
exists: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function GET(request: Request) {
|
|
23
|
+
const ctx = await resolveWorkspaceForUser(request);
|
|
24
|
+
if (!ctx.ok) return NextResponse.json({ error: ctx.code }, { status: ctx.status });
|
|
25
|
+
|
|
26
|
+
const url = new URL(request.url);
|
|
27
|
+
const filePath = url.searchParams.get("path") ?? "";
|
|
28
|
+
if (!filePath) {
|
|
29
|
+
return NextResponse.json({ error: "path required" }, { status: 400 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Warm indexer in background (same pattern as backlinks route)
|
|
33
|
+
ensureIndexer(ctx.ws.id, ctx.rootDir).catch((e) =>
|
|
34
|
+
console.error("[outlinks] ensureIndexer failed", e),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const db = getSearchDb();
|
|
38
|
+
|
|
39
|
+
// Read body from search index (avoids a file-system read)
|
|
40
|
+
const row = db
|
|
41
|
+
.prepare(`SELECT body FROM docs WHERE ws = ? AND path = ?`)
|
|
42
|
+
.get(ctx.ws.id, filePath) as { body: string } | undefined;
|
|
43
|
+
|
|
44
|
+
if (!row) {
|
|
45
|
+
return NextResponse.json({ error: "file not indexed" }, { status: 404 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Extract unique slugs from [[wikilinks]]
|
|
49
|
+
const slugs = new Set<string>();
|
|
50
|
+
const re = new RegExp(WIKILINK_RE.source, "gi");
|
|
51
|
+
let match: RegExpExecArray | null;
|
|
52
|
+
while ((match = re.exec(row.body)) !== null) {
|
|
53
|
+
if (match[1]) slugs.add(match[1].toLowerCase());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (slugs.size === 0) {
|
|
57
|
+
return NextResponse.json({ links: [] });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Build slug → path map from indexed .md files (single DB pass)
|
|
61
|
+
const allMdPaths = db
|
|
62
|
+
.prepare(`SELECT path FROM docs WHERE ws = ? AND path LIKE '%.md'`)
|
|
63
|
+
.all(ctx.ws.id) as { path: string }[];
|
|
64
|
+
|
|
65
|
+
const pathBySlug = new Map<string, string>();
|
|
66
|
+
for (const { path } of allMdPaths) {
|
|
67
|
+
pathBySlug.set(slugFromPath(path), path);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const links: OutlinkEntry[] = Array.from(slugs).map((slug) => {
|
|
71
|
+
const resolved_path = pathBySlug.get(slug) ?? null;
|
|
72
|
+
return { slug, resolved_path, exists: resolved_path !== null };
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return NextResponse.json({ links });
|
|
76
|
+
}
|
|
@@ -743,12 +743,21 @@ nextjs-portal {
|
|
|
743
743
|
background: color-mix(in srgb, var(--accent-foreground) 10%, transparent);
|
|
744
744
|
border-bottom-style: solid;
|
|
745
745
|
}
|
|
746
|
-
.wiki-link[data-broken="true"]
|
|
746
|
+
.wiki-link[data-broken="true"],
|
|
747
|
+
.wiki-link:has([data-broken="true"]) {
|
|
748
|
+
color: var(--destructive);
|
|
749
|
+
opacity: 0.6;
|
|
750
|
+
border-bottom: none;
|
|
751
|
+
text-decoration: line-through;
|
|
752
|
+
}
|
|
753
|
+
/* ProseMirror wraps text in a span — also target it directly */
|
|
754
|
+
.wiki-link [data-broken="true"] {
|
|
755
|
+
text-decoration: line-through;
|
|
747
756
|
color: var(--destructive);
|
|
748
|
-
border-bottom-color: var(--destructive);
|
|
749
|
-
border-bottom-style: dashed;
|
|
750
757
|
}
|
|
751
|
-
.wiki-link[data-broken="true"]:hover
|
|
758
|
+
.wiki-link[data-broken="true"]:hover,
|
|
759
|
+
.wiki-link:has([data-broken="true"]):hover {
|
|
760
|
+
opacity: 1;
|
|
752
761
|
background: color-mix(in srgb, var(--destructive) 10%, transparent);
|
|
753
762
|
}
|
|
754
763
|
|
|
@@ -967,17 +976,6 @@ proof-span.proof-span:hover,
|
|
|
967
976
|
margin: 1.4em 0 0.35rem;
|
|
968
977
|
}
|
|
969
978
|
|
|
970
|
-
/* Drop cap on first paragraph */
|
|
971
|
-
[data-skin="editorial"] .tiptap > p:first-child::first-letter {
|
|
972
|
-
float: left;
|
|
973
|
-
font-family: var(--font-fraunces), Georgia, serif;
|
|
974
|
-
font-size: 3.4em;
|
|
975
|
-
line-height: 0.72;
|
|
976
|
-
padding: 0.05em 0.12em 0 0;
|
|
977
|
-
color: var(--primary);
|
|
978
|
-
font-weight: 600;
|
|
979
|
-
}
|
|
980
|
-
|
|
981
979
|
[data-skin="editorial"] .tiptap p {
|
|
982
980
|
margin-bottom: 0.75rem;
|
|
983
981
|
}
|
|
@@ -1,69 +1,284 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { memo } from "react";
|
|
3
|
+
import { memo, useEffect, useMemo, useState, type CSSProperties } from "react";
|
|
4
|
+
import { useEditorStore } from "@/stores/editor-store";
|
|
5
|
+
import { wsFetch } from "@/lib/workspace-client";
|
|
4
6
|
import type { ActivityEvent } from "@/lib/proof/activity-shared";
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
8
|
+
type ReviewCounts = Record<string, number>;
|
|
9
|
+
|
|
10
|
+
type ActivityGroup = {
|
|
11
|
+
path: string;
|
|
12
|
+
filename: string;
|
|
13
|
+
latestAt: string;
|
|
14
|
+
events: ActivityEvent[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function hashString(input: string): number {
|
|
18
|
+
let hash = 0;
|
|
19
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
20
|
+
hash = (hash * 31 + input.charCodeAt(i)) | 0;
|
|
21
|
+
}
|
|
22
|
+
return Math.abs(hash);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function agentLabel(agentId: string): string {
|
|
26
|
+
const raw = agentId.split(/[:/]/).pop() ?? agentId;
|
|
27
|
+
const compact = raw.replace(/[^a-z0-9]/gi, "");
|
|
28
|
+
return (compact.slice(0, 2) || raw.slice(0, 2) || "??").toUpperCase();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function agentName(agentId: string): string {
|
|
32
|
+
const raw = agentId.split(/[:/]/).pop() ?? agentId;
|
|
33
|
+
return raw.replace(/[-_]+/g, " ").replace(/^./, (c) => c.toUpperCase());
|
|
15
34
|
}
|
|
16
35
|
|
|
17
|
-
function
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"block.delete": "deleted",
|
|
23
|
-
"block.append": "appended",
|
|
24
|
-
"block.prepend": "prepended",
|
|
25
|
-
"comment.add": "commented on",
|
|
26
|
-
"comment.reply": "replied to comment",
|
|
27
|
-
"comment.resolve": "resolved comment",
|
|
28
|
-
"comment.reopen": "reopened comment",
|
|
29
|
-
"suggestion.add": "suggested",
|
|
30
|
-
"suggestion.accept": "accepted suggestion",
|
|
31
|
-
"suggestion.reject": "rejected suggestion",
|
|
36
|
+
function agentChipStyle(agentId: string): CSSProperties {
|
|
37
|
+
const hue = hashString(agentId) % 360;
|
|
38
|
+
return {
|
|
39
|
+
backgroundColor: `hsl(${hue} 72% 42%)`,
|
|
40
|
+
color: "white",
|
|
32
41
|
};
|
|
33
|
-
return map[type] ?? type;
|
|
34
42
|
}
|
|
35
43
|
|
|
36
|
-
function
|
|
37
|
-
const
|
|
38
|
-
|
|
44
|
+
function timeAgo(iso: string): string {
|
|
45
|
+
const diffSeconds = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000));
|
|
46
|
+
if (diffSeconds < 45) return "just now";
|
|
47
|
+
|
|
48
|
+
const steps: Array<[number, Intl.RelativeTimeFormatUnit]> = [
|
|
49
|
+
[60, "second"],
|
|
50
|
+
[60, "minute"],
|
|
51
|
+
[24, "hour"],
|
|
52
|
+
[7, "day"],
|
|
53
|
+
];
|
|
54
|
+
let value = diffSeconds;
|
|
55
|
+
let unit: Intl.RelativeTimeFormatUnit = "second";
|
|
56
|
+
for (const [step, nextUnit] of steps) {
|
|
57
|
+
if (value < step) break;
|
|
58
|
+
value = Math.floor(value / step);
|
|
59
|
+
unit = nextUnit;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "always", style: "short" });
|
|
63
|
+
return rtf.format(-value, unit).replace(/\s+/g, " ").replace(/\./g, "");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function blockCount(event: ActivityEvent): number {
|
|
67
|
+
if (Array.isArray((event as { refs?: unknown }).refs)) {
|
|
68
|
+
return ((event as { refs?: unknown[] }).refs ?? []).length;
|
|
69
|
+
}
|
|
70
|
+
return typeof (event as { ref?: unknown }).ref === "string" ? 1 : 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function summaryFor(event: ActivityEvent): string {
|
|
74
|
+
const actor = agentName(event.by);
|
|
75
|
+
const kind = event.type;
|
|
76
|
+
const count = blockCount(event);
|
|
77
|
+
|
|
78
|
+
if (kind === "block.append" || (kind === "block.inserted" && (event as { position?: string }).position === "end")) {
|
|
79
|
+
return `${actor} appended ${count || 1} block${(count || 1) === 1 ? "" : "s"}`;
|
|
80
|
+
}
|
|
81
|
+
if (kind === "block.prepend" || (kind === "block.inserted" && (event as { position?: string }).position === "start")) {
|
|
82
|
+
return `${actor} prepended ${count || 1} block${(count || 1) === 1 ? "" : "s"}`;
|
|
83
|
+
}
|
|
84
|
+
if (kind === "block.inserted") {
|
|
85
|
+
return `${actor} inserted ${count || 1} block${(count || 1) === 1 ? "" : "s"}`;
|
|
86
|
+
}
|
|
87
|
+
if (kind === "block.replaced" || kind === "file.rawWritten") {
|
|
88
|
+
return `${actor} edited`;
|
|
89
|
+
}
|
|
90
|
+
if (kind === "block.deleted") {
|
|
91
|
+
return `${actor} deleted ${count || 1} block${(count || 1) === 1 ? "" : "s"}`;
|
|
92
|
+
}
|
|
93
|
+
if (kind === "suggestion.added") {
|
|
94
|
+
return `${actor} suggested a change`;
|
|
95
|
+
}
|
|
96
|
+
if (kind === "suggestion.accepted") {
|
|
97
|
+
return `${actor} accepted a suggestion`;
|
|
98
|
+
}
|
|
99
|
+
if (kind === "suggestion.rejected") {
|
|
100
|
+
return `${actor} rejected a suggestion`;
|
|
101
|
+
}
|
|
102
|
+
if (kind === "comment.added") {
|
|
103
|
+
return `${actor} commented`;
|
|
104
|
+
}
|
|
105
|
+
if (kind === "comment.replied") {
|
|
106
|
+
return `${actor} replied`;
|
|
107
|
+
}
|
|
108
|
+
if (kind === "comment.resolved") {
|
|
109
|
+
return `${actor} resolved a comment`;
|
|
110
|
+
}
|
|
111
|
+
if (kind === "comment.reopened") {
|
|
112
|
+
return `${actor} reopened a comment`;
|
|
113
|
+
}
|
|
114
|
+
if (kind === "file.externallyEdited") {
|
|
115
|
+
return `${actor} changed file externally`;
|
|
116
|
+
}
|
|
117
|
+
return `${actor} ${kind.replace(/^\w+\./, "").replace(/([a-z])([A-Z])/g, "$1 $2")}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function groupActivity(activity: ActivityEvent[]): ActivityGroup[] {
|
|
121
|
+
const groups = new Map<string, ActivityEvent[]>();
|
|
122
|
+
for (const event of activity) {
|
|
123
|
+
const list = groups.get(event.path) ?? [];
|
|
124
|
+
list.push(event);
|
|
125
|
+
groups.set(event.path, list);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return Array.from(groups.entries())
|
|
129
|
+
.map(([path, events]) => ({
|
|
130
|
+
path,
|
|
131
|
+
filename: path.split("/").pop() ?? path,
|
|
132
|
+
latestAt: events[0]?.at ?? "",
|
|
133
|
+
events,
|
|
134
|
+
}))
|
|
135
|
+
.sort((a, b) => (a.latestAt < b.latestAt ? 1 : -1));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isMarkdownPath(path: string): boolean {
|
|
139
|
+
return /\.(md|markdown)$/i.test(path);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function fetchReviewCount(path: string): Promise<number> {
|
|
143
|
+
if (!isMarkdownPath(path)) return 0;
|
|
144
|
+
|
|
145
|
+
const endpoints = [`/api/agent/sidecar/${path}`, `/api/agent/files/${path}/pending`, `/api/agent/files/${path}`];
|
|
146
|
+
for (const endpoint of endpoints) {
|
|
147
|
+
try {
|
|
148
|
+
const res = await wsFetch(endpoint);
|
|
149
|
+
if (!res.ok) continue;
|
|
150
|
+
const data = (await res.json()) as Record<string, unknown>;
|
|
39
151
|
|
|
152
|
+
if (Array.isArray(data.pending)) return data.pending.length;
|
|
153
|
+
if (Array.isArray(data.events) && endpoint.includes("/pending")) return data.events.length;
|
|
154
|
+
if (typeof data.pendingCount === "number") return data.pendingCount;
|
|
155
|
+
if (typeof data.count === "number") return data.count;
|
|
156
|
+
if (typeof data.total === "number") return data.total;
|
|
157
|
+
if (Array.isArray(data.suggestions) || Array.isArray(data.comments) || data.blockProvenance) {
|
|
158
|
+
const suggestions = Array.isArray(data.suggestions)
|
|
159
|
+
? data.suggestions.filter(
|
|
160
|
+
(item) => item && typeof item === "object" && (item as { status?: string }).status === "pending",
|
|
161
|
+
).length
|
|
162
|
+
: 0;
|
|
163
|
+
const comments = Array.isArray(data.comments)
|
|
164
|
+
? data.comments.filter(
|
|
165
|
+
(item) => item && typeof item === "object" && !(item as { resolved?: boolean }).resolved,
|
|
166
|
+
).length
|
|
167
|
+
: 0;
|
|
168
|
+
const proofSpans = data.blockProvenance && typeof data.blockProvenance === "object"
|
|
169
|
+
? Object.keys(data.blockProvenance as Record<string, unknown>).length
|
|
170
|
+
: 0;
|
|
171
|
+
return suggestions + comments + proofSpans;
|
|
172
|
+
}
|
|
173
|
+
} catch {
|
|
174
|
+
// Ignore fetch errors; review badge stays hidden.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function ActivityItemImpl({ event }: { event: ActivityEvent }) {
|
|
40
181
|
return (
|
|
41
|
-
<div className="flex
|
|
42
|
-
<div
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
182
|
+
<div className="flex items-start gap-2.5 rounded-md px-2 py-2 hover:bg-background/50">
|
|
183
|
+
<div
|
|
184
|
+
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold shadow-sm ring-1 ring-black/5"
|
|
185
|
+
style={agentChipStyle(event.by)}
|
|
186
|
+
title={event.by}
|
|
187
|
+
aria-hidden
|
|
188
|
+
>
|
|
189
|
+
{agentLabel(event.by)}
|
|
190
|
+
</div>
|
|
191
|
+
<div className="min-w-0 flex-1">
|
|
192
|
+
<p className="truncate text-xs text-foreground/85">
|
|
193
|
+
{summaryFor(event)}
|
|
194
|
+
</p>
|
|
195
|
+
<p className="truncate text-[10px] text-muted-foreground">{timeAgo(event.at)}</p>
|
|
52
196
|
</div>
|
|
53
|
-
<p className="text-[11px] text-muted-foreground truncate">
|
|
54
|
-
<span className="text-foreground/60">{event.by}</span>{" "}
|
|
55
|
-
{verbFor(event.type)}
|
|
56
|
-
{ref && (
|
|
57
|
-
<>
|
|
58
|
-
{" "}
|
|
59
|
-
<span className="font-mono text-[10px] text-muted-foreground/60">{ref}</span>
|
|
60
|
-
</>
|
|
61
|
-
)}
|
|
62
|
-
</p>
|
|
63
197
|
</div>
|
|
64
198
|
);
|
|
65
199
|
}
|
|
66
200
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
201
|
+
const ActivityItem = memo(ActivityItemImpl);
|
|
202
|
+
|
|
203
|
+
function ActivityGroupView({ group, reviewCount }: { group: ActivityGroup; reviewCount: number }) {
|
|
204
|
+
const loadPage = useEditorStore((s) => s.loadPage);
|
|
205
|
+
const latest = group.events[0];
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<section className="space-y-1.5 rounded-md border border-border/70 bg-background/50 p-2">
|
|
209
|
+
<div className="flex items-start justify-between gap-2">
|
|
210
|
+
<button
|
|
211
|
+
type="button"
|
|
212
|
+
onClick={() => void loadPage(group.path)}
|
|
213
|
+
className="min-w-0 text-left"
|
|
214
|
+
title={group.path}
|
|
215
|
+
>
|
|
216
|
+
<p className="truncate text-xs font-medium text-foreground/90">{group.filename}</p>
|
|
217
|
+
<p className="truncate text-[10px] text-muted-foreground">{group.path}</p>
|
|
218
|
+
</button>
|
|
219
|
+
<div className="flex shrink-0 items-center gap-1.5">
|
|
220
|
+
{reviewCount > 0 && (
|
|
221
|
+
<button
|
|
222
|
+
type="button"
|
|
223
|
+
onClick={() => void loadPage(group.path)}
|
|
224
|
+
className="inline-flex items-center rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-700 hover:bg-amber-500/15"
|
|
225
|
+
title="Open file for review"
|
|
226
|
+
>
|
|
227
|
+
review {reviewCount}
|
|
228
|
+
</button>
|
|
229
|
+
)}
|
|
230
|
+
<span className="text-[10px] text-muted-foreground">{timeAgo(latest.at)}</span>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
<div className="space-y-0.5">
|
|
234
|
+
{group.events.map((event) => (
|
|
235
|
+
<ActivityItem key={`${event.path}-${event.id}`} event={event} />
|
|
236
|
+
))}
|
|
237
|
+
</div>
|
|
238
|
+
</section>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function ActivityFeed({ activity }: { activity: ActivityEvent[] }) {
|
|
243
|
+
const groups = useMemo(() => groupActivity(activity), [activity]);
|
|
244
|
+
const [reviewCounts, setReviewCounts] = useState<ReviewCounts>({});
|
|
245
|
+
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
let cancelled = false;
|
|
248
|
+
const paths = Array.from(new Set(groups.map((group) => group.path)));
|
|
249
|
+
if (paths.length === 0) {
|
|
250
|
+
setReviewCounts({});
|
|
251
|
+
return () => {
|
|
252
|
+
cancelled = true;
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
void (async () => {
|
|
257
|
+
const pairs = await Promise.all(
|
|
258
|
+
paths.map(async (path) => [path, await fetchReviewCount(path)] as const),
|
|
259
|
+
);
|
|
260
|
+
if (cancelled) return;
|
|
261
|
+
setReviewCounts(Object.fromEntries(pairs.filter(([, count]) => count > 0)));
|
|
262
|
+
})();
|
|
263
|
+
|
|
264
|
+
return () => {
|
|
265
|
+
cancelled = true;
|
|
266
|
+
};
|
|
267
|
+
}, [groups]);
|
|
268
|
+
|
|
269
|
+
if (groups.length === 0) {
|
|
270
|
+
return <p className="py-2 text-xs text-muted-foreground/60">No events recorded</p>;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
<div className="space-y-2">
|
|
275
|
+
{groups.map((group) => (
|
|
276
|
+
<ActivityGroupView
|
|
277
|
+
key={group.path}
|
|
278
|
+
group={group}
|
|
279
|
+
reviewCount={reviewCounts[group.path] ?? 0}
|
|
280
|
+
/>
|
|
281
|
+
))}
|
|
282
|
+
</div>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
@@ -5,7 +5,7 @@ import { X, Copy, Check, Bot, Wifi, ExternalLink } from "lucide-react";
|
|
|
5
5
|
import { Button } from "@/components/ui/button";
|
|
6
6
|
import { useAIPanelStore } from "@/stores/ai-panel-store";
|
|
7
7
|
import { TokenSection } from "./token-section";
|
|
8
|
-
import {
|
|
8
|
+
import { ActivityFeed } from "./activity-row";
|
|
9
9
|
const SKILL_CLI = "npx skills add anh-chu/wiki-viewer/agents/wiki-viewer-skill";
|
|
10
10
|
|
|
11
11
|
function useCopyCurl(currentPath: string | null) {
|
|
@@ -288,14 +288,8 @@ export function AIPanel({ currentPath }: { currentPath?: string | null }) {
|
|
|
288
288
|
<h3 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
|
289
289
|
Recent Activity
|
|
290
290
|
</h3>
|
|
291
|
-
<div className="rounded-md border border-border bg-muted/40 px-3 py-
|
|
292
|
-
{activity
|
|
293
|
-
<p className="text-xs text-muted-foreground/60 py-2">No events recorded</p>
|
|
294
|
-
) : (
|
|
295
|
-
activity.map((ev) => (
|
|
296
|
-
<ActivityRow key={`${ev.path}-${ev.id}`} event={ev} />
|
|
297
|
-
))
|
|
298
|
-
)}
|
|
291
|
+
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 max-h-72 overflow-y-auto">
|
|
292
|
+
<ActivityFeed activity={activity} />
|
|
299
293
|
</div>
|
|
300
294
|
</section>
|
|
301
295
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
|
-
import { ChevronDown, ChevronRight, Link2 } from "lucide-react";
|
|
4
|
+
import { ChevronDown, ChevronRight, Link2, Unlink } from "lucide-react";
|
|
5
5
|
import { wsFetch } from "@/lib/workspace-client";
|
|
6
6
|
import { useEditorStore } from "@/stores/editor-store";
|
|
7
|
+
import { useWikiSlugsStore } from "@/stores/wiki-slugs-store";
|
|
7
8
|
|
|
8
9
|
interface BacklinkEntry {
|
|
9
10
|
path: string;
|
|
@@ -16,6 +17,12 @@ function displayPath(filePath: string): string {
|
|
|
16
17
|
return parts.length > 1 ? parts.slice(-2).join(" / ") : parts[0] ?? filePath;
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
/** Last segment sans .md — the wiki slug used for existence checks. */
|
|
21
|
+
function slugFromPath(filePath: string): string {
|
|
22
|
+
const base = filePath.split("/").pop() ?? filePath;
|
|
23
|
+
return base.replace(/\.md$/i, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
19
26
|
interface BacklinksPanelProps {
|
|
20
27
|
currentPath: string;
|
|
21
28
|
}
|
|
@@ -24,6 +31,7 @@ export function BacklinksPanel({ currentPath }: BacklinksPanelProps) {
|
|
|
24
31
|
const [backlinks, setBacklinks] = useState<BacklinkEntry[]>([]);
|
|
25
32
|
const [loading, setLoading] = useState(false);
|
|
26
33
|
const [collapsed, setCollapsed] = useState(false);
|
|
34
|
+
const hasSlug = useWikiSlugsStore((s) => s.has);
|
|
27
35
|
|
|
28
36
|
useEffect(() => {
|
|
29
37
|
if (!currentPath) return;
|
|
@@ -85,22 +93,44 @@ export function BacklinksPanel({ currentPath }: BacklinksPanelProps) {
|
|
|
85
93
|
|
|
86
94
|
{!collapsed && backlinks.length > 0 && (
|
|
87
95
|
<ul className="flex flex-col gap-1.5">
|
|
88
|
-
{backlinks.map((bl) =>
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
96
|
+
{backlinks.map((bl) => {
|
|
97
|
+
const exists = hasSlug(slugFromPath(bl.path));
|
|
98
|
+
return (
|
|
99
|
+
<li key={bl.path} className="flex flex-col">
|
|
100
|
+
<button
|
|
101
|
+
onClick={() =>
|
|
102
|
+
exists && useEditorStore.getState().loadPage(bl.path)
|
|
103
|
+
}
|
|
104
|
+
className={
|
|
105
|
+
exists
|
|
106
|
+
? "text-left text-[12px] text-primary/70 hover:text-primary hover:underline underline-offset-2 truncate transition-colors flex items-center gap-1"
|
|
107
|
+
: "text-left text-[12px] text-muted-foreground/40 truncate cursor-default flex items-center gap-1"
|
|
108
|
+
}
|
|
109
|
+
title={
|
|
110
|
+
exists
|
|
111
|
+
? bl.path
|
|
112
|
+
: `${bl.path} (file not found)`
|
|
113
|
+
}
|
|
114
|
+
>
|
|
115
|
+
{!exists && (
|
|
116
|
+
<Unlink className="h-2.5 w-2.5 shrink-0 text-muted-foreground/30" />
|
|
117
|
+
)}
|
|
118
|
+
{displayPath(bl.path)}
|
|
119
|
+
</button>
|
|
120
|
+
{bl.snippet && (
|
|
121
|
+
<p
|
|
122
|
+
className={
|
|
123
|
+
exists
|
|
124
|
+
? "text-[10.5px] text-muted-foreground/50 line-clamp-1 mt-0.5"
|
|
125
|
+
: "text-[10.5px] text-muted-foreground/25 line-clamp-1 mt-0.5"
|
|
126
|
+
}
|
|
127
|
+
>
|
|
128
|
+
{bl.snippet}
|
|
129
|
+
</p>
|
|
130
|
+
)}
|
|
131
|
+
</li>
|
|
132
|
+
);
|
|
133
|
+
})}
|
|
104
134
|
</ul>
|
|
105
135
|
)}
|
|
106
136
|
</div>
|
|
@@ -28,6 +28,8 @@ interface Props {
|
|
|
28
28
|
onSuggestEdit?: () => void;
|
|
29
29
|
/** Open a comment thread on the current selection's block. */
|
|
30
30
|
onComment?: () => void;
|
|
31
|
+
/** Hide all formatting controls; show only comment button. */
|
|
32
|
+
readOnly?: boolean;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
type OpenPopover =
|
|
@@ -40,7 +42,7 @@ type OpenPopover =
|
|
|
40
42
|
anchor: { top: number; left: number };
|
|
41
43
|
};
|
|
42
44
|
|
|
43
|
-
export function EditorBubbleMenu({ editor, onSuggestEdit, onComment }: Props) {
|
|
45
|
+
export function EditorBubbleMenu({ editor, onSuggestEdit, onComment, readOnly }: Props) {
|
|
44
46
|
const [popover, setPopover] = useState<OpenPopover>(null);
|
|
45
47
|
|
|
46
48
|
useEffect(() => {
|
|
@@ -127,6 +129,8 @@ export function EditorBubbleMenu({ editor, onSuggestEdit, onComment }: Props) {
|
|
|
127
129
|
options={{ placement: "top", offset: 8 }}
|
|
128
130
|
className="flex items-center gap-0.5 px-1 py-1 bg-popover border border-border rounded-sm shadow-lg"
|
|
129
131
|
>
|
|
132
|
+
{!readOnly && (
|
|
133
|
+
<>
|
|
130
134
|
<button
|
|
131
135
|
type="button"
|
|
132
136
|
className={btn(editor.isActive("bold"))}
|
|
@@ -299,6 +303,23 @@ export function EditorBubbleMenu({ editor, onSuggestEdit, onComment }: Props) {
|
|
|
299
303
|
</div>
|
|
300
304
|
)}
|
|
301
305
|
</div>
|
|
306
|
+
</>
|
|
307
|
+
)}
|
|
308
|
+
{readOnly && onComment && (
|
|
309
|
+
<button
|
|
310
|
+
type="button"
|
|
311
|
+
className={btn(false)}
|
|
312
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
313
|
+
onClick={(e) => {
|
|
314
|
+
e.preventDefault();
|
|
315
|
+
onComment();
|
|
316
|
+
}}
|
|
317
|
+
aria-label="Comment"
|
|
318
|
+
title="Add comment"
|
|
319
|
+
>
|
|
320
|
+
<MessageCircle className="w-3.5 h-3.5" />
|
|
321
|
+
</button>
|
|
322
|
+
)}
|
|
302
323
|
</BubbleMenu>
|
|
303
324
|
|
|
304
325
|
{popover?.type === "link" && (
|