@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.
- package/README.md +79 -41
- package/dist/overlay.js +227 -65
- package/package.json +1 -1
- package/src/dev/api-handlers.ts +82 -28
- package/src/dev/middleware.ts +9 -4
- package/src/integration.ts +2 -1
- package/src/overlay/App.tsx +88 -21
- package/src/overlay/components/Sidebar.tsx +41 -8
- package/src/overlay/components/SidebarItem.tsx +57 -16
- package/src/overlay/components/Toolbar.tsx +15 -3
- package/src/overlay/index.tsx +3 -1
- package/src/overlay/lib/cms-bridge.ts +61 -11
- package/src/overlay/lib/notes-fetch.ts +27 -5
- package/src/overlay/lib/url-mode.ts +46 -9
- package/src/overlay/styles.css +388 -138
- package/src/overlay/types.ts +11 -1
- package/src/storage/json-store.ts +57 -13
- package/src/storage/types.ts +54 -4
- package/src/types.ts +11 -0
package/src/overlay/index.tsx
CHANGED
|
@@ -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
|
-
*
|
|
2
|
+
* Host-page bridge: hide CMS chrome and reserve space for the notes UI.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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
|
|
25
|
+
const STYLES = `
|
|
16
26
|
#cms-app-host { display: none !important; }
|
|
17
27
|
[data-nuasite-cms] { display: none !important; }
|
|
18
|
-
|
|
19
|
-
|
|
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 =
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
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
|
|
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.
|
|
23
|
-
document.cookie = `${
|
|
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 = `${
|
|
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
|
|