@nuasite/notes 0.22.2 → 0.22.3

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.
@@ -20,6 +20,7 @@ import OVERLAY_STYLES from './styles.css?inline'
20
20
 
21
21
  interface NuaNotesConfig {
22
22
  urlFlag?: string
23
+ agencyFlag?: string
23
24
  }
24
25
 
25
26
  declare global {
@@ -34,6 +35,7 @@ function init(): void {
34
35
 
35
36
  const config = window.__NuaNotesConfig ?? {}
36
37
  const urlFlag = config.urlFlag ?? 'nua-notes'
38
+ const agencyFlag = config.agencyFlag ?? 'nua-agency'
37
39
 
38
40
  if (!isReviewMode(urlFlag)) return
39
41
  window.__nuasiteNotesMounted = true
@@ -59,7 +61,7 @@ function init(): void {
59
61
  root.id = 'nua-notes-root'
60
62
  shadow.appendChild(root)
61
63
 
62
- render(<App urlFlag={urlFlag} />, root)
64
+ render(<App urlFlag={urlFlag} agencyFlag={agencyFlag} />, root)
63
65
  }
64
66
 
65
67
  if (typeof window !== 'undefined') {
@@ -1,33 +1,83 @@
1
1
  /**
2
- * Mode-exclusivity bridge with `@nuasite/cms`.
2
+ * Host-page bridge: hide CMS chrome and reserve space for the notes UI.
3
3
  *
4
- * When notes review mode is active we hide the CMS editor chrome via a
5
- * single injected `<style>` tag in the host document. CMS uses a shadow DOM
6
- * mounted on `#cms-app-host`, so hiding that element + suppressing pointer
7
- * events on the data-cms-id markers is enough to make the two UIs not
8
- * collide. There's no postMessage handshake yet — Phase 5 may add one.
4
+ * Two responsibilities, both implemented as a single injected `<style>` tag
5
+ * in the host document so the page can be returned to its original layout
6
+ * by simply removing the tag.
9
7
  *
10
- * `enable()` is idempotent. `disable()` removes the style if it was added.
8
+ * 1. Hide CMS chrome (`#cms-app-host`, `[data-nuasite-cms]`) so we have
9
+ * mode exclusivity with `@nuasite/cms`.
10
+ * 2. Reserve space for the notes toolbar (40px top) and sidebar (340px
11
+ * right) by padding the body. Without this the fixed-position notes UI
12
+ * sits on top of page content and the rightmost 340px of every page is
13
+ * unreachable. The padding is toggled by a `data-nua-notes-collapsed`
14
+ * attribute on `<html>` — when collapsed, only the toolbar gap stays.
15
+ *
16
+ * Note on `box-sizing: border-box` on `body`: most sites already inherit it
17
+ * via a global reset, but we set it explicitly so the math works regardless.
18
+ * The padding cuts into the body's box rather than expanding the page width
19
+ * past 100vw, which would create a horizontal scrollbar.
11
20
  */
12
21
 
13
22
  const STYLE_ID = 'nua-notes-cms-bridge'
23
+ const COLLAPSE_ATTR = 'data-nua-notes-collapsed'
14
24
 
15
- const HIDE_CMS_CSS = `
25
+ const STYLES = `
16
26
  #cms-app-host { display: none !important; }
17
27
  [data-nuasite-cms] { display: none !important; }
18
- /* Allow notes to handle clicks on annotated elements without CMS interfering */
19
- [data-cms-id] { cursor: default !important; }
28
+
29
+ /* Reserve space for the notes toolbar + sidebar so they don't sit on top
30
+ * of host page content. Padding the body lets the page reflow into the
31
+ * visible area instead of being clipped under fixed-position chrome. */
32
+ html:not([${COLLAPSE_ATTR}]) body {
33
+ box-sizing: border-box;
34
+ padding-top: 40px !important;
35
+ padding-right: 340px !important;
36
+ }
37
+
38
+ /* When the sidebar is collapsed, only reserve the toolbar height. */
39
+ html[${COLLAPSE_ATTR}] body {
40
+ box-sizing: border-box;
41
+ padding-top: 40px !important;
42
+ }
43
+
44
+ /* Page-fixed elements (sticky headers, fixed nav) don't get pushed by
45
+ * body padding because they're positioned against the viewport. Offset
46
+ * them via top/right so they slot into the same chrome-free area. We
47
+ * scope this to elements the host page declared as fixed/sticky to
48
+ * avoid touching the notes UI itself (which lives in a shadow DOM). */
49
+ html:not([${COLLAPSE_ATTR}]) body > header[class*="fixed"],
50
+ html:not([${COLLAPSE_ATTR}]) body > nav[class*="fixed"],
51
+ html:not([${COLLAPSE_ATTR}]) body > [class*="sticky"] {
52
+ right: 340px !important;
53
+ }
20
54
  `
21
55
 
22
56
  export function enableCmsBridge(): void {
23
57
  if (document.getElementById(STYLE_ID)) return
24
58
  const style = document.createElement('style')
25
59
  style.id = STYLE_ID
26
- style.textContent = HIDE_CMS_CSS
60
+ style.textContent = STYLES
27
61
  document.head.appendChild(style)
28
62
  }
29
63
 
30
64
  export function disableCmsBridge(): void {
31
65
  const existing = document.getElementById(STYLE_ID)
32
66
  if (existing) existing.remove()
67
+ document.documentElement.removeAttribute(COLLAPSE_ATTR)
68
+ }
69
+
70
+ /** Toggle sidebar-collapsed mode. The toolbar stays visible, the sidebar
71
+ * slides off, and the body's right padding is removed so page content can
72
+ * use the full viewport width. */
73
+ export function setSidebarCollapsed(collapsed: boolean): void {
74
+ if (collapsed) {
75
+ document.documentElement.setAttribute(COLLAPSE_ATTR, '')
76
+ } else {
77
+ document.documentElement.removeAttribute(COLLAPSE_ATTR)
78
+ }
79
+ }
80
+
81
+ export function isSidebarCollapsed(): boolean {
82
+ return document.documentElement.hasAttribute(COLLAPSE_ATTR)
33
83
  }
@@ -3,17 +3,32 @@
3
3
  *
4
4
  * All methods return parsed JSON. Errors throw with the server-provided
5
5
  * message so the overlay can surface them in a banner.
6
+ *
7
+ * Every call sends the active role via the `x-nua-role` header. The server
8
+ * uses this to gate destructive routes (apply / delete / resolve / etc.) to
9
+ * agency callers only. The role is read once at module load from the cookie
10
+ * the URL helpers maintain.
6
11
  */
7
12
 
8
13
  import type { NoteItem, NoteRange, NotesPageFile, NoteStatus, NoteType } from '../types'
14
+ import { resolveRole } from './url-mode'
9
15
 
10
16
  const BASE = '/_nua/notes'
11
17
  const TIMEOUT_MS = 10_000
12
18
 
19
+ /**
20
+ * Resolve the current role on every call (cheap — just reads a cookie). This
21
+ * means a new tab that just gained agency mode picks it up immediately
22
+ * without a page reload.
23
+ */
24
+ function authHeaders(): Record<string, string> {
25
+ return { 'x-nua-role': resolveRole('nua-agency') }
26
+ }
27
+
13
28
  async function postJson<T>(path: string, body: unknown): Promise<T> {
14
29
  const res = await fetch(`${BASE}${path}`, {
15
30
  method: 'POST',
16
- headers: { 'Content-Type': 'application/json' },
31
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
17
32
  body: JSON.stringify(body),
18
33
  signal: AbortSignal.timeout(TIMEOUT_MS),
19
34
  })
@@ -32,7 +47,7 @@ async function postJson<T>(path: string, body: unknown): Promise<T> {
32
47
 
33
48
  export async function listNotes(page: string): Promise<NotesPageFile> {
34
49
  const res = await fetch(`${BASE}/list?page=${encodeURIComponent(page)}`, {
35
- headers: { Accept: 'application/json' },
50
+ headers: { Accept: 'application/json', ...authHeaders() },
36
51
  signal: AbortSignal.timeout(TIMEOUT_MS),
37
52
  })
38
53
  if (!res.ok) throw new Error(`notes: list failed (${res.status})`)
@@ -77,8 +92,15 @@ export async function setNoteStatus(page: string, id: string, status: NoteStatus
77
92
  return updateNote(page, id, { status })
78
93
  }
79
94
 
80
- export async function deleteNote(page: string, id: string): Promise<void> {
81
- await postJson<{ ok: true }>('/delete', { page, id })
95
+ /** Soft delete server flips status to 'deleted' and returns the updated item. */
96
+ export async function deleteNote(page: string, id: string): Promise<NoteItem> {
97
+ const res = await postJson<{ item: NoteItem }>('/delete', { page, id })
98
+ return res.item
99
+ }
100
+
101
+ /** Hard delete — agency only. Removes the item from disk entirely. */
102
+ export async function purgeNote(page: string, id: string): Promise<void> {
103
+ await postJson<{ ok: true }>('/purge', { page, id })
82
104
  }
83
105
 
84
106
  export interface ApplyResponse {
@@ -99,7 +121,7 @@ export interface ApplyResponse {
99
121
  export async function applyNote(page: string, id: string): Promise<ApplyResponse> {
100
122
  const res = await fetch(`${BASE}/apply`, {
101
123
  method: 'POST',
102
- headers: { 'Content-Type': 'application/json' },
124
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
103
125
  body: JSON.stringify({ page, id }),
104
126
  signal: AbortSignal.timeout(TIMEOUT_MS),
105
127
  })
@@ -1,36 +1,73 @@
1
1
  /**
2
- * URL flag + cookie helpers controlling notes review mode.
2
+ * URL flag + cookie helpers controlling notes review mode and the active role.
3
3
  *
4
4
  * Review mode is on when EITHER the URL has the `?<urlFlag>` query param OR
5
5
  * the `nua-notes-mode=1` cookie is set. Visiting a page with the flag the
6
6
  * first time sets the cookie so subsequent navigation stays in review mode.
7
7
  *
8
- * The toggle button in the toolbar clears the cookie + reloads, which drops
9
- * the user back into the regular CMS view.
8
+ * Agency mode is the same idea with a separate flag/cookie pair: visit
9
+ * `?nua-agency` once and the `nua-notes-role=agency` cookie sticks. The
10
+ * toolbar shows the active role so the agency knows when they have full
11
+ * controls vs the read-only client view.
10
12
  */
11
13
 
12
- const COOKIE = 'nua-notes-mode'
14
+ import type { NoteRole } from '../types'
15
+
16
+ const MODE_COOKIE = 'nua-notes-mode'
17
+ const ROLE_COOKIE = 'nua-notes-role'
18
+
19
+ function readCookie(name: string): string | null {
20
+ if (typeof document === 'undefined') return null
21
+ for (const part of document.cookie.split('; ')) {
22
+ const eq = part.indexOf('=')
23
+ if (eq < 0) continue
24
+ if (part.slice(0, eq) === name) return decodeURIComponent(part.slice(eq + 1))
25
+ }
26
+ return null
27
+ }
13
28
 
14
29
  export function isReviewMode(urlFlag: string): boolean {
15
30
  if (typeof window === 'undefined') return false
16
31
  const url = new URL(window.location.href)
17
32
  if (url.searchParams.has(urlFlag)) return true
18
- return document.cookie.split('; ').some(c => c.startsWith(`${COOKIE}=1`))
33
+ return readCookie(MODE_COOKIE) === '1'
19
34
  }
20
35
 
21
36
  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`
37
+ // Session cookie — cleared when the browser closes. Good enough for v0.2.
38
+ document.cookie = `${MODE_COOKIE}=1; path=/; SameSite=Lax`
24
39
  }
25
40
 
26
41
  export function clearReviewModeCookie(): void {
27
- document.cookie = `${COOKIE}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`
42
+ document.cookie = `${MODE_COOKIE}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`
43
+ }
44
+
45
+ /**
46
+ * Resolve the active role from the URL flag and the cookie. If `agencyFlag`
47
+ * is present in the URL, agency mode is on AND the cookie gets persisted so
48
+ * subsequent navigation stays in agency. If neither flag nor cookie is set,
49
+ * the role is `client`.
50
+ */
51
+ export function resolveRole(agencyFlag: string): NoteRole {
52
+ if (typeof window === 'undefined') return 'client'
53
+ const url = new URL(window.location.href)
54
+ if (url.searchParams.has(agencyFlag)) {
55
+ document.cookie = `${ROLE_COOKIE}=agency; path=/; SameSite=Lax`
56
+ return 'agency'
57
+ }
58
+ return readCookie(ROLE_COOKIE) === 'agency' ? 'agency' : 'client'
59
+ }
60
+
61
+ export function clearRoleCookie(): void {
62
+ document.cookie = `${ROLE_COOKIE}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`
28
63
  }
29
64
 
30
- export function exitReviewMode(urlFlag: string): void {
65
+ export function exitReviewMode(urlFlag: string, agencyFlag: string): void {
31
66
  clearReviewModeCookie()
67
+ clearRoleCookie()
32
68
  const url = new URL(window.location.href)
33
69
  url.searchParams.delete(urlFlag)
70
+ url.searchParams.delete(agencyFlag)
34
71
  window.location.href = url.pathname + (url.search || '') + url.hash
35
72
  }
36
73