@nuasite/notes 0.1.0
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/README.md +211 -0
- package/dist/overlay.js +1367 -0
- package/package.json +51 -0
- package/src/apply/apply-suggestion.ts +157 -0
- package/src/dev/api-handlers.ts +215 -0
- package/src/dev/middleware.ts +65 -0
- package/src/dev/request-utils.ts +71 -0
- package/src/index.ts +2 -0
- package/src/integration.ts +168 -0
- package/src/overlay/App.tsx +434 -0
- package/src/overlay/components/CommentPopover.tsx +96 -0
- package/src/overlay/components/DiffPreview.tsx +29 -0
- package/src/overlay/components/ElementHighlight.tsx +33 -0
- package/src/overlay/components/SelectionTooltip.tsx +48 -0
- package/src/overlay/components/Sidebar.tsx +70 -0
- package/src/overlay/components/SidebarItem.tsx +104 -0
- package/src/overlay/components/StaleWarning.tsx +19 -0
- package/src/overlay/components/SuggestPopover.tsx +139 -0
- package/src/overlay/components/Toolbar.tsx +38 -0
- package/src/overlay/env.d.ts +4 -0
- package/src/overlay/index.tsx +71 -0
- package/src/overlay/lib/cms-bridge.ts +33 -0
- package/src/overlay/lib/dom-walker.ts +61 -0
- package/src/overlay/lib/manifest-fetch.ts +35 -0
- package/src/overlay/lib/notes-fetch.ts +121 -0
- package/src/overlay/lib/range-anchor.ts +87 -0
- package/src/overlay/lib/url-mode.ts +43 -0
- package/src/overlay/styles.css +526 -0
- package/src/overlay/types.ts +66 -0
- package/src/storage/id-gen.ts +32 -0
- package/src/storage/json-store.ts +196 -0
- package/src/storage/slug.ts +35 -0
- package/src/storage/types.ts +100 -0
- package/src/tsconfig.json +6 -0
- package/src/types.ts +50 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only fetchers for `@nuasite/cms`'s public manifest endpoints.
|
|
3
|
+
*
|
|
4
|
+
* The overlay uses the per-page manifest to map a `data-cms-id` element back
|
|
5
|
+
* to its source file + line + snippet at the moment a note is created. We
|
|
6
|
+
* intentionally do NOT pull from the global `/cms-manifest.json` here — the
|
|
7
|
+
* per-page file already contains everything we need and is much smaller.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { CmsPageManifest } from '../types'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build the per-page manifest URL for a page path.
|
|
14
|
+
* / → /index.json
|
|
15
|
+
* /inspekce-nemovitosti → /inspekce-nemovitosti.json
|
|
16
|
+
* /blog/post → /blog/post.json
|
|
17
|
+
*/
|
|
18
|
+
export function manifestUrlForPage(page: string): string {
|
|
19
|
+
if (page === '' || page === '/') return '/index.json'
|
|
20
|
+
const trimmed = page.replace(/^\/+|\/+$/g, '')
|
|
21
|
+
return `/${trimmed}.json`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function fetchPageManifest(page: string): Promise<CmsPageManifest | null> {
|
|
25
|
+
const url = manifestUrlForPage(page)
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10_000) })
|
|
28
|
+
if (!res.ok) return null
|
|
29
|
+
const data = (await res.json()) as CmsPageManifest
|
|
30
|
+
return data
|
|
31
|
+
} catch (err) {
|
|
32
|
+
console.warn(`[nuasite-notes] Failed to fetch CMS manifest for "${page}":`, err instanceof Error ? err.message : err)
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin wrapper around `/_nua/notes/*` endpoints.
|
|
3
|
+
*
|
|
4
|
+
* All methods return parsed JSON. Errors throw with the server-provided
|
|
5
|
+
* message so the overlay can surface them in a banner.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { NoteItem, NoteRange, NotesPageFile, NoteStatus, NoteType } from '../types'
|
|
9
|
+
|
|
10
|
+
const BASE = '/_nua/notes'
|
|
11
|
+
const TIMEOUT_MS = 10_000
|
|
12
|
+
|
|
13
|
+
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
|
14
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
15
|
+
method: 'POST',
|
|
16
|
+
headers: { 'Content-Type': 'application/json' },
|
|
17
|
+
body: JSON.stringify(body),
|
|
18
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
19
|
+
})
|
|
20
|
+
const text = await res.text()
|
|
21
|
+
let parsed: any
|
|
22
|
+
try {
|
|
23
|
+
parsed = text ? JSON.parse(text) : {}
|
|
24
|
+
} catch {
|
|
25
|
+
throw new Error(`notes: invalid JSON response from ${path}`)
|
|
26
|
+
}
|
|
27
|
+
if (!res.ok) {
|
|
28
|
+
throw new Error(parsed?.error ?? `notes: ${path} failed (${res.status})`)
|
|
29
|
+
}
|
|
30
|
+
return parsed as T
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function listNotes(page: string): Promise<NotesPageFile> {
|
|
34
|
+
const res = await fetch(`${BASE}/list?page=${encodeURIComponent(page)}`, {
|
|
35
|
+
headers: { Accept: 'application/json' },
|
|
36
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
37
|
+
})
|
|
38
|
+
if (!res.ok) throw new Error(`notes: list failed (${res.status})`)
|
|
39
|
+
return (await res.json()) as NotesPageFile
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CreateInput {
|
|
43
|
+
page: string
|
|
44
|
+
type: NoteType
|
|
45
|
+
targetCmsId: string
|
|
46
|
+
targetSourcePath?: string
|
|
47
|
+
targetSourceLine?: number
|
|
48
|
+
targetSnippet?: string
|
|
49
|
+
range?: NoteRange | null
|
|
50
|
+
body: string
|
|
51
|
+
author: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function createNote(input: CreateInput): Promise<NoteItem> {
|
|
55
|
+
const res = await postJson<{ item: NoteItem }>('/create', input)
|
|
56
|
+
return res.item
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function updateNote(
|
|
60
|
+
page: string,
|
|
61
|
+
id: string,
|
|
62
|
+
patch: Partial<Pick<NoteItem, 'body' | 'status' | 'targetSnippet'>> & { range?: NoteRange | null },
|
|
63
|
+
): Promise<NoteItem> {
|
|
64
|
+
const res = await postJson<{ item: NoteItem }>('/update', { page, id, patch })
|
|
65
|
+
return res.item
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function setNoteStatus(page: string, id: string, status: NoteStatus): Promise<NoteItem> {
|
|
69
|
+
if (status === 'resolved') {
|
|
70
|
+
const res = await postJson<{ item: NoteItem }>('/resolve', { page, id })
|
|
71
|
+
return res.item
|
|
72
|
+
}
|
|
73
|
+
if (status === 'open') {
|
|
74
|
+
const res = await postJson<{ item: NoteItem }>('/reopen', { page, id })
|
|
75
|
+
return res.item
|
|
76
|
+
}
|
|
77
|
+
return updateNote(page, id, { status })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function deleteNote(page: string, id: string): Promise<void> {
|
|
81
|
+
await postJson<{ ok: true }>('/delete', { page, id })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ApplyResponse {
|
|
85
|
+
item: NoteItem
|
|
86
|
+
file?: string
|
|
87
|
+
before?: string
|
|
88
|
+
after?: string
|
|
89
|
+
error?: string
|
|
90
|
+
reason?: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Apply a suggestion. Returns the updated item plus before/after snippets
|
|
95
|
+
* on success, or throws with the server error message. The 409 response
|
|
96
|
+
* (drift) is returned as a normal value because the server already updated
|
|
97
|
+
* the item to `stale` and the overlay should refresh accordingly.
|
|
98
|
+
*/
|
|
99
|
+
export async function applyNote(page: string, id: string): Promise<ApplyResponse> {
|
|
100
|
+
const res = await fetch(`${BASE}/apply`, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: { 'Content-Type': 'application/json' },
|
|
103
|
+
body: JSON.stringify({ page, id }),
|
|
104
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
105
|
+
})
|
|
106
|
+
const text = await res.text()
|
|
107
|
+
let parsed: any
|
|
108
|
+
try {
|
|
109
|
+
parsed = text ? JSON.parse(text) : {}
|
|
110
|
+
} catch {
|
|
111
|
+
throw new Error(`notes: invalid JSON response from /apply`)
|
|
112
|
+
}
|
|
113
|
+
if (res.status === 409) {
|
|
114
|
+
// Drift — server already marked as stale
|
|
115
|
+
return parsed as ApplyResponse
|
|
116
|
+
}
|
|
117
|
+
if (!res.ok) {
|
|
118
|
+
throw new Error(parsed?.error ?? `notes: /apply failed (${res.status})`)
|
|
119
|
+
}
|
|
120
|
+
return parsed as ApplyResponse
|
|
121
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anchor-text re-attachment for range suggestions.
|
|
3
|
+
*
|
|
4
|
+
* Suggestions don't store character offsets — they store the original
|
|
5
|
+
* substring (`anchorText`). On reload we walk the target element's text
|
|
6
|
+
* nodes and look for that substring. If found, we return a DOM `Range`
|
|
7
|
+
* the overlay can use to draw highlights or compute coordinates. If not
|
|
8
|
+
* found, the suggestion is reported as stale so the sidebar can surface
|
|
9
|
+
* a re-attach CTA.
|
|
10
|
+
*
|
|
11
|
+
* The matching is intentionally simple: first exact match, then a
|
|
12
|
+
* collapsed-whitespace fallback (handles HTML re-flowing). Anything more
|
|
13
|
+
* sophisticated (fuzzy / Levenshtein) waits for a real-world failure.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { collectTextNodes, rangeFromOffsets } from './dom-walker'
|
|
17
|
+
|
|
18
|
+
export interface AnchorMatch {
|
|
19
|
+
range: Range
|
|
20
|
+
rect: DOMRect
|
|
21
|
+
start: number
|
|
22
|
+
end: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function collapseWhitespace(s: string): string {
|
|
26
|
+
return s.replace(/\s+/g, ' ').trim()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function findAnchorRange(el: Element, anchorText: string): AnchorMatch | null {
|
|
30
|
+
if (!anchorText) return null
|
|
31
|
+
const { joined } = collectTextNodes(el)
|
|
32
|
+
if (!joined) return null
|
|
33
|
+
|
|
34
|
+
// 1. Exact substring match
|
|
35
|
+
const idx = joined.indexOf(anchorText)
|
|
36
|
+
if (idx >= 0) {
|
|
37
|
+
const range = rangeFromOffsets(el, idx, idx + anchorText.length)
|
|
38
|
+
if (range) return { range, rect: range.getBoundingClientRect(), start: idx, end: idx + anchorText.length }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 2. Whitespace-collapsed fallback. Build a map from collapsed offsets back
|
|
42
|
+
// to original offsets so we can still produce a real DOM range.
|
|
43
|
+
const collapsedAnchor = collapseWhitespace(anchorText)
|
|
44
|
+
if (!collapsedAnchor) return null
|
|
45
|
+
const map: number[] = [] // collapsed index → original index
|
|
46
|
+
let collapsed = ''
|
|
47
|
+
let prevWs = false
|
|
48
|
+
for (let i = 0; i < joined.length; i++) {
|
|
49
|
+
const ch = joined[i]!
|
|
50
|
+
const isWs = /\s/.test(ch)
|
|
51
|
+
if (isWs) {
|
|
52
|
+
if (collapsed.length === 0) continue // leading
|
|
53
|
+
if (prevWs) continue
|
|
54
|
+
collapsed += ' '
|
|
55
|
+
map.push(i)
|
|
56
|
+
prevWs = true
|
|
57
|
+
} else {
|
|
58
|
+
collapsed += ch
|
|
59
|
+
map.push(i)
|
|
60
|
+
prevWs = false
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const trimmed = collapsed.replace(/\s+$/, '')
|
|
64
|
+
const cIdx = trimmed.indexOf(collapsedAnchor)
|
|
65
|
+
if (cIdx < 0) return null
|
|
66
|
+
const startOrig = map[cIdx]
|
|
67
|
+
const endOrigInclusive = map[cIdx + collapsedAnchor.length - 1]
|
|
68
|
+
if (startOrig == null || endOrigInclusive == null) return null
|
|
69
|
+
const range = rangeFromOffsets(el, startOrig, endOrigInclusive + 1)
|
|
70
|
+
if (!range) return null
|
|
71
|
+
return { range, rect: range.getBoundingClientRect(), start: startOrig, end: endOrigInclusive + 1 }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Compute the substring of an element's joined text inside the user's
|
|
76
|
+
* current selection. Returns null if the selection is empty or not fully
|
|
77
|
+
* inside the element.
|
|
78
|
+
*/
|
|
79
|
+
export function selectionInsideElement(el: Element, selection: Selection): { text: string; rect: DOMRect } | null {
|
|
80
|
+
if (selection.rangeCount === 0) return null
|
|
81
|
+
const range = selection.getRangeAt(0)
|
|
82
|
+
if (range.collapsed) return null
|
|
83
|
+
if (!el.contains(range.startContainer) || !el.contains(range.endContainer)) return null
|
|
84
|
+
const text = range.toString()
|
|
85
|
+
if (!text.trim()) return null
|
|
86
|
+
return { text, rect: range.getBoundingClientRect() }
|
|
87
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL flag + cookie helpers controlling notes review mode.
|
|
3
|
+
*
|
|
4
|
+
* Review mode is on when EITHER the URL has the `?<urlFlag>` query param OR
|
|
5
|
+
* the `nua-notes-mode=1` cookie is set. Visiting a page with the flag the
|
|
6
|
+
* first time sets the cookie so subsequent navigation stays in review mode.
|
|
7
|
+
*
|
|
8
|
+
* The toggle button in the toolbar clears the cookie + reloads, which drops
|
|
9
|
+
* the user back into the regular CMS view.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const COOKIE = 'nua-notes-mode'
|
|
13
|
+
|
|
14
|
+
export function isReviewMode(urlFlag: string): boolean {
|
|
15
|
+
if (typeof window === 'undefined') return false
|
|
16
|
+
const url = new URL(window.location.href)
|
|
17
|
+
if (url.searchParams.has(urlFlag)) return true
|
|
18
|
+
return document.cookie.split('; ').some(c => c.startsWith(`${COOKIE}=1`))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function setReviewModeCookie(): void {
|
|
22
|
+
// Session cookie — cleared when the browser closes. Good enough for v0.1.
|
|
23
|
+
document.cookie = `${COOKIE}=1; path=/; SameSite=Lax`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function clearReviewModeCookie(): void {
|
|
27
|
+
document.cookie = `${COOKIE}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function exitReviewMode(urlFlag: string): void {
|
|
31
|
+
clearReviewModeCookie()
|
|
32
|
+
const url = new URL(window.location.href)
|
|
33
|
+
url.searchParams.delete(urlFlag)
|
|
34
|
+
window.location.href = url.pathname + (url.search || '') + url.hash
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getCurrentPagePath(): string {
|
|
38
|
+
if (typeof window === 'undefined') return '/'
|
|
39
|
+
const p = window.location.pathname
|
|
40
|
+
if (p === '' || p === '/') return '/'
|
|
41
|
+
// Drop trailing slash for consistency with CMS / notes storage
|
|
42
|
+
return p.endsWith('/') ? p.slice(0, -1) : p
|
|
43
|
+
}
|