@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.
@@ -7,21 +7,41 @@
7
7
  * Routes (all mounted under `/_nua/notes/`):
8
8
  *
9
9
  * GET /list?page=/<page> → list items for one page
10
- * GET /inbox → list items across all pages (Phase 5 use)
11
- * POST /create → create a comment or suggestion
12
- * POST /update → patch an existing item
13
- * POST /resolve → mark item as resolved
14
- * POST /reopen → reopen a resolved item
15
- * POST /delete → delete an item
16
- * POST /applywrite a suggestion's replacement back to source
10
+ * GET /inbox → list items across all pages
11
+ * POST /create → create a comment or suggestion (any role)
12
+ * POST /update → patch an existing item (agency only)
13
+ * POST /resolve → mark item as resolved (agency only)
14
+ * POST /reopen → reopen a resolved item (agency only)
15
+ * POST /delete → soft-delete an item (agency only)
16
+ * POST /purgehard-remove a soft-deleted item (agency only)
17
+ * POST /apply → write a suggestion to source (agency only)
18
+ *
19
+ * Role gating: `client` is the default and can only create. `agency` is
20
+ * granted by the `x-nua-role: agency` request header. The header is
21
+ * unauthenticated — anyone who knows it can claim agency. The point is to
22
+ * stop a client from accidentally clicking Apply or Delete, not to harden
23
+ * against an adversary. The dev server is local; auth is out of scope.
17
24
  */
18
25
 
19
26
  import type { IncomingMessage, ServerResponse } from 'node:http'
20
27
  import { applySuggestion } from '../apply/apply-suggestion'
21
28
  import type { NotesJsonStore } from '../storage/json-store'
22
- import type { NoteItem, NoteItemPatch, NoteRange, NoteStatus, NoteType } from '../storage/types'
29
+ import type { NoteItem, NoteItemPatch, NoteRange, NoteRole, NoteStatus, NoteType } from '../storage/types'
23
30
  import { parseJsonBody, sendError, sendJson } from './request-utils'
24
31
 
32
+ /** Read the requester's role from a request header. Defaults to client. */
33
+ function readRole(req: IncomingMessage): NoteRole {
34
+ const raw = req.headers['x-nua-role']
35
+ const value = Array.isArray(raw) ? raw[0] : raw
36
+ return value === 'agency' ? 'agency' : 'client'
37
+ }
38
+
39
+ function requireAgency(role: NoteRole, res: ServerResponse, req: IncomingMessage, action: string): boolean {
40
+ if (role === 'agency') return true
41
+ sendError(res, `${action} requires agency role`, 403, req)
42
+ return false
43
+ }
44
+
25
45
  export interface NotesApiContext {
26
46
  store: NotesJsonStore
27
47
  projectRoot: string
@@ -63,6 +83,7 @@ export async function handleNotesApiRoute(
63
83
  ): Promise<void> {
64
84
  const { store } = ctx
65
85
  const method = req.method ?? 'GET'
86
+ const role = readRole(req)
66
87
 
67
88
  // GET /list?page=/some-page
68
89
  if (method === 'GET' && route === 'list') {
@@ -105,28 +126,33 @@ export async function handleNotesApiRoute(
105
126
  sendError(res, 'Comment items require a non-empty body', 400, req)
106
127
  return
107
128
  }
108
- const item = await store.addItem(body.page, {
109
- type: body.type,
110
- targetCmsId: body.targetCmsId,
111
- targetSourcePath: body.targetSourcePath,
112
- targetSourceLine: body.targetSourceLine,
113
- targetSnippet: body.targetSnippet,
114
- range: body.range ?? null,
115
- body: body.body ?? '',
116
- author: body.author,
117
- })
129
+ const item = await store.addItem(
130
+ body.page,
131
+ {
132
+ type: body.type,
133
+ targetCmsId: body.targetCmsId,
134
+ targetSourcePath: body.targetSourcePath,
135
+ targetSourceLine: body.targetSourceLine,
136
+ targetSnippet: body.targetSnippet,
137
+ range: body.range ?? null,
138
+ body: body.body ?? '',
139
+ author: body.author,
140
+ },
141
+ role,
142
+ )
118
143
  sendJson(res, { item }, 201, req)
119
144
  return
120
145
  }
121
146
 
122
- // POST /update
147
+ // POST /update — agency only
123
148
  if (method === 'POST' && route === 'update') {
149
+ if (!requireAgency(role, res, req, 'update')) return
124
150
  const body = await parseJsonBody<UpdateBody>(req)
125
151
  if (!body.page || !body.id || !body.patch) {
126
152
  sendError(res, 'Missing required fields: page, id, patch', 400, req)
127
153
  return
128
154
  }
129
- const updated = await store.updateItem(body.page, body.id, body.patch)
155
+ const updated = await store.updateItem(body.page, body.id, body.patch, role)
130
156
  if (!updated) {
131
157
  sendError(res, `Item not found: ${body.id}`, 404, req)
132
158
  return
@@ -135,15 +161,17 @@ export async function handleNotesApiRoute(
135
161
  return
136
162
  }
137
163
 
138
- // POST /resolve and POST /reopen — convenience wrappers around update
164
+ // POST /resolve and POST /reopen — agency only
139
165
  if (method === 'POST' && (route === 'resolve' || route === 'reopen')) {
166
+ if (!requireAgency(role, res, req, route)) return
140
167
  const body = await parseJsonBody<IdBody>(req)
141
168
  if (!body.page || !body.id) {
142
169
  sendError(res, 'Missing required fields: page, id', 400, req)
143
170
  return
144
171
  }
145
172
  const status: NoteStatus = route === 'resolve' ? 'resolved' : 'open'
146
- const updated = await store.updateItem(body.page, body.id, { status })
173
+ const action = route === 'resolve' ? 'resolved' : 'reopened'
174
+ const updated = await store.updateItem(body.page, body.id, { status }, role, action)
147
175
  if (!updated) {
148
176
  sendError(res, `Item not found: ${body.id}`, 404, req)
149
177
  return
@@ -152,14 +180,32 @@ export async function handleNotesApiRoute(
152
180
  return
153
181
  }
154
182
 
155
- // POST /delete
183
+ // POST /delete — agency only, soft delete (status flips to 'deleted')
156
184
  if (method === 'POST' && route === 'delete') {
185
+ if (!requireAgency(role, res, req, 'delete')) return
186
+ const body = await parseJsonBody<IdBody>(req)
187
+ if (!body.page || !body.id) {
188
+ sendError(res, 'Missing required fields: page, id', 400, req)
189
+ return
190
+ }
191
+ const updated = await store.deleteItem(body.page, body.id, role)
192
+ if (!updated) {
193
+ sendError(res, `Item not found: ${body.id}`, 404, req)
194
+ return
195
+ }
196
+ sendJson(res, { item: updated }, 200, req)
197
+ return
198
+ }
199
+
200
+ // POST /purge — agency only, hard delete from disk
201
+ if (method === 'POST' && route === 'purge') {
202
+ if (!requireAgency(role, res, req, 'purge')) return
157
203
  const body = await parseJsonBody<IdBody>(req)
158
204
  if (!body.page || !body.id) {
159
205
  sendError(res, 'Missing required fields: page, id', 400, req)
160
206
  return
161
207
  }
162
- const ok = await store.deleteItem(body.page, body.id)
208
+ const ok = await store.purgeItem(body.page, body.id)
163
209
  if (!ok) {
164
210
  sendError(res, `Item not found: ${body.id}`, 404, req)
165
211
  return
@@ -168,15 +214,16 @@ export async function handleNotesApiRoute(
168
214
  return
169
215
  }
170
216
 
171
- // POST /apply — write the suggestion's replacement back to the source file
217
+ // POST /apply — agency only. Write the suggestion's replacement back to the source file.
172
218
  if (method === 'POST' && route === 'apply') {
219
+ if (!requireAgency(role, res, req, 'apply')) return
173
220
  const body = await parseJsonBody<IdBody>(req)
174
221
  if (!body.page || !body.id) {
175
222
  sendError(res, 'Missing required fields: page, id', 400, req)
176
223
  return
177
224
  }
178
225
  const file = await store.readPage(body.page)
179
- const item = file.items.find(it => it.id === body.id)
226
+ const item = file.items.find((it) => it.id === body.id)
180
227
  if (!item) {
181
228
  sendError(res, `Item not found: ${body.id}`, 404, req)
182
229
  return
@@ -192,7 +239,7 @@ export async function handleNotesApiRoute(
192
239
  // Drift detected — mark the item as stale so the sidebar can warn
193
240
  // the agency without losing the suggestion.
194
241
  if (result.reason === 'not-found' || result.reason === 'ambiguous') {
195
- const updated = await store.updateItem(body.page, body.id, { status: 'stale' })
242
+ const updated = await store.updateItem(body.page, body.id, { status: 'stale' }, role, 'stale', result.message)
196
243
  sendJson(res, { item: updated, error: result.message, reason: result.reason }, 409, req)
197
244
  return
198
245
  }
@@ -203,7 +250,14 @@ export async function handleNotesApiRoute(
203
250
  // Successful write — flip the item to `applied`. The middleware will
204
251
  // fire a Vite full-reload after this returns; CMS's own watcher also
205
252
  // notices the source-file change and triggers HMR.
206
- const updated = await store.updateItem(body.page, body.id, { status: 'applied' })
253
+ const updated = await store.updateItem(
254
+ body.page,
255
+ body.id,
256
+ { status: 'applied' },
257
+ role,
258
+ 'applied',
259
+ result.file,
260
+ )
207
261
  sendJson(res, { item: updated, file: result.file, before: result.before, after: result.after }, 200, req)
208
262
  return
209
263
  }
@@ -47,11 +47,16 @@ export function createNotesDevMiddleware(
47
47
 
48
48
  handleNotesApiRoute(route, req, res, ctx)
49
49
  .then(() => {
50
- // Mirror CMS: explicitly trigger full-reload after content-modifying
51
- // routes. In sandboxed dev environments (E2B etc.) chokidar events
52
- // may not fire reliably for note JSON files, so we send the HMR
50
+ // Mirror CMS: trigger full-reload after content-modifying routes.
51
+ // In sandboxed dev environments (E2B etc.) chokidar events may
52
+ // not fire reliably for note JSON files, so we send the HMR
53
53
  // event directly. The overlay re-fetches `/list` on reload.
54
- if (req.method === 'POST' && server.ws) {
54
+ //
55
+ // Only trigger on 2xx — a 403/404/400 shouldn't force a reload
56
+ // because nothing changed and the reload races with whatever
57
+ // the client is doing next.
58
+ const statusCode = res.statusCode
59
+ if (req.method === 'POST' && server.ws && statusCode >= 200 && statusCode < 300) {
55
60
  server.ws.send({ type: 'full-reload' })
56
61
  }
57
62
  })
@@ -29,6 +29,7 @@ export default function nuaNotes(options: NuaNotesOptions = {}): AstroIntegratio
29
29
  const {
30
30
  enabled = true,
31
31
  urlFlag = 'nua-notes',
32
+ agencyFlag = 'nua-agency',
32
33
  notesDir = 'data/notes',
33
34
  } = options
34
35
 
@@ -79,7 +80,7 @@ export default function nuaNotes(options: NuaNotesOptions = {}): AstroIntegratio
79
80
  (function () {
80
81
  if (window.__nuasiteNotesAlive) return;
81
82
  window.__nuasiteNotesAlive = true;
82
- window.__NuaNotesConfig = ${JSON.stringify({ urlFlag })};
83
+ window.__NuaNotesConfig = ${JSON.stringify({ urlFlag, agencyFlag })};
83
84
  if (!document.querySelector('script[data-nuasite-notes]')) {
84
85
  const s = document.createElement('script');
85
86
  s.type = 'module';
@@ -6,14 +6,34 @@ import { SelectionTooltip } from './components/SelectionTooltip'
6
6
  import { Sidebar } from './components/Sidebar'
7
7
  import { SuggestPopover } from './components/SuggestPopover'
8
8
  import { Toolbar } from './components/Toolbar'
9
+ import { isSidebarCollapsed, setSidebarCollapsed } from './lib/cms-bridge'
9
10
  import { fetchPageManifest } from './lib/manifest-fetch'
10
- import { applyNote, createNote, deleteNote, listNotes, setNoteStatus } from './lib/notes-fetch'
11
+ import { applyNote, createNote, deleteNote, listNotes, purgeNote, setNoteStatus } from './lib/notes-fetch'
11
12
  import { findAnchorRange, selectionInsideElement } from './lib/range-anchor'
12
- import { exitReviewMode, getCurrentPagePath } from './lib/url-mode'
13
- import type { CmsPageManifest, NoteItem } from './types'
13
+ import { exitReviewMode, getCurrentPagePath, resolveRole } from './lib/url-mode'
14
+ import type { CmsPageManifest, NoteItem, NoteRole } from './types'
15
+
16
+ const COLLAPSED_KEY = 'nua-notes-sidebar-collapsed'
17
+
18
+ function loadCollapsed(): boolean {
19
+ try {
20
+ return localStorage.getItem(COLLAPSED_KEY) === '1'
21
+ } catch {
22
+ return false
23
+ }
24
+ }
25
+
26
+ function saveCollapsed(value: boolean): void {
27
+ try {
28
+ localStorage.setItem(COLLAPSED_KEY, value ? '1' : '0')
29
+ } catch {
30
+ // ignore
31
+ }
32
+ }
14
33
 
15
34
  interface AppProps {
16
35
  urlFlag: string
36
+ agencyFlag: string
17
37
  }
18
38
 
19
39
  interface PickState {
@@ -66,8 +86,13 @@ function findCmsAncestor(target: EventTarget | null): Element | null {
66
86
  return null
67
87
  }
68
88
 
69
- export function App({ urlFlag }: AppProps) {
89
+ export function App({ urlFlag, agencyFlag }: AppProps) {
70
90
  const page = useMemo(() => getCurrentPagePath(), [])
91
+ // Role is resolved once at mount. Visiting `?nua-agency` persists the
92
+ // cookie so subsequent navigation stays in agency mode without re-typing.
93
+ const role: NoteRole = useMemo(() => resolveRole(agencyFlag), [agencyFlag])
94
+ const isAgency = role === 'agency'
95
+
71
96
  const [items, setItems] = useState<NoteItem[]>([])
72
97
  const [manifest, setManifest] = useState<CmsPageManifest | null>(null)
73
98
  const [picking, setPicking] = useState(false)
@@ -81,6 +106,25 @@ export function App({ urlFlag }: AppProps) {
81
106
  const [author, setAuthor] = useState<string>(() => loadAuthor())
82
107
  const [staleIds, setStaleIds] = useState<Set<string>>(new Set())
83
108
  const [applyingId, setApplyingId] = useState<string | null>(null)
109
+ const [collapsed, setCollapsedState] = useState<boolean>(() => loadCollapsed())
110
+
111
+ // Sync the body padding via cms-bridge whenever collapsed changes.
112
+ // Persist to localStorage so the preference sticks across navigation.
113
+ useEffect(() => {
114
+ setSidebarCollapsed(collapsed)
115
+ saveCollapsed(collapsed)
116
+ }, [collapsed])
117
+
118
+ // Initialize the body padding to match the loaded preference on mount.
119
+ // This runs once before the first render so there's no flash of
120
+ // uncollapsed sidebar when the user has it saved as collapsed.
121
+ useEffect(() => {
122
+ setSidebarCollapsed(loadCollapsed())
123
+ }, [])
124
+
125
+ const toggleCollapsed = useCallback(() => {
126
+ setCollapsedState((c) => !c)
127
+ }, [])
84
128
 
85
129
  // Load notes + manifest on mount
86
130
  useEffect(() => {
@@ -321,7 +365,21 @@ export function App({ urlFlag }: AppProps) {
321
365
 
322
366
  const handleDelete = useCallback(async (id: string) => {
323
367
  try {
324
- await deleteNote(page, id)
368
+ // Soft delete: server flips status to 'deleted' and returns the
369
+ // updated item. We keep it in the list so the agency sees it move
370
+ // into the collapsed Deleted section. (Clients never see this
371
+ // path because the Delete button is hidden for them.)
372
+ const item = await deleteNote(page, id)
373
+ setItems((prev) => prev.map((i) => (i.id === id ? item : i)))
374
+ if (activeId === id) setActiveId(null)
375
+ } catch (err) {
376
+ setError(err instanceof Error ? err.message : String(err))
377
+ }
378
+ }, [page, activeId])
379
+
380
+ const handlePurge = useCallback(async (id: string) => {
381
+ try {
382
+ await purgeNote(page, id)
325
383
  setItems((prev) => prev.filter((i) => i.id !== id))
326
384
  if (activeId === id) setActiveId(null)
327
385
  } catch (err) {
@@ -368,29 +426,38 @@ export function App({ urlFlag }: AppProps) {
368
426
  <div class="notes-root">
369
427
  <Toolbar
370
428
  page={page}
371
- count={items.length}
429
+ count={items.filter((i) => i.status !== 'deleted').length}
372
430
  picking={picking}
431
+ role={role}
432
+ collapsed={collapsed}
373
433
  onTogglePick={() => {
374
434
  setPicking((p) => !p)
375
435
  setPendingPick(null)
376
436
  setPendingSuggest(null)
377
437
  }}
378
- onExit={() => exitReviewMode(urlFlag)}
379
- />
380
- <Sidebar
381
- page={page}
382
- items={items}
383
- activeId={activeId}
384
- picking={picking}
385
- error={error}
386
- staleIds={staleIds}
387
- applyingId={applyingId}
388
- onFocus={setActiveId}
389
- onResolve={handleResolve}
390
- onReopen={handleReopen}
391
- onDelete={handleDelete}
392
- onApply={handleApply}
438
+ onToggleCollapse={toggleCollapsed}
439
+ onExit={() => exitReviewMode(urlFlag, agencyFlag)}
393
440
  />
441
+ {collapsed
442
+ ? null
443
+ : (
444
+ <Sidebar
445
+ page={page}
446
+ items={items}
447
+ activeId={activeId}
448
+ picking={picking}
449
+ error={error}
450
+ staleIds={staleIds}
451
+ applyingId={applyingId}
452
+ isAgency={isAgency}
453
+ onFocus={setActiveId}
454
+ onResolve={handleResolve}
455
+ onReopen={handleReopen}
456
+ onDelete={handleDelete}
457
+ onPurge={handlePurge}
458
+ onApply={handleApply}
459
+ />
460
+ )}
394
461
  {picking && hoverRect ? <ElementHighlight rect={hoverRect.rect} /> : null}
395
462
  {activeRect ? <ElementHighlight rect={activeRect} persistent /> : null}
396
463
  {pendingSelection && !pendingPick && !pendingSuggest
@@ -10,18 +10,39 @@ interface SidebarProps {
10
10
  error: string | null
11
11
  staleIds: Set<string>
12
12
  applyingId: string | null
13
+ isAgency: boolean
13
14
  onFocus: (id: string) => void
14
15
  onResolve: (id: string) => void
15
16
  onReopen: (id: string) => void
16
17
  onDelete: (id: string) => void
18
+ onPurge: (id: string) => void
17
19
  onApply: (id: string) => void
18
20
  }
19
21
 
20
22
  export function Sidebar(
21
- { page, items, activeId, picking, error, staleIds, applyingId, onFocus, onResolve, onReopen, onDelete, onApply }: SidebarProps,
23
+ {
24
+ page,
25
+ items,
26
+ activeId,
27
+ picking,
28
+ error,
29
+ staleIds,
30
+ applyingId,
31
+ isAgency,
32
+ onFocus,
33
+ onResolve,
34
+ onReopen,
35
+ onDelete,
36
+ onPurge,
37
+ onApply,
38
+ }: SidebarProps,
22
39
  ) {
23
- const open = items.filter((i) => i.status !== 'resolved' && i.status !== 'applied')
24
- const closed = items.filter((i) => i.status === 'resolved' || i.status === 'applied')
40
+ // Sort items into three buckets. Clients never see deleted items at all.
41
+ const open = items.filter((i) => i.status === 'open' || i.status === 'stale' || i.status === 'rejected')
42
+ const resolved = items.filter((i) => i.status === 'resolved' || i.status === 'applied')
43
+ const deleted = isAgency ? items.filter((i) => i.status === 'deleted') : []
44
+ const visible = [...open, ...resolved, ...deleted]
45
+
25
46
  const renderItem = (item: NoteItem) => (
26
47
  <SidebarItem
27
48
  key={item.id}
@@ -29,24 +50,28 @@ export function Sidebar(
29
50
  active={item.id === activeId}
30
51
  stale={staleIds.has(item.id)}
31
52
  applying={applyingId === item.id}
53
+ isAgency={isAgency}
32
54
  onFocus={() => onFocus(item.id)}
33
55
  onResolve={() => onResolve(item.id)}
34
56
  onReopen={() => onReopen(item.id)}
35
57
  onDelete={() => onDelete(item.id)}
58
+ onPurge={() => onPurge(item.id)}
36
59
  onApply={() => onApply(item.id)}
37
60
  />
38
61
  )
62
+
39
63
  return (
40
64
  <aside class="notes-sidebar">
41
65
  <header class="notes-sidebar__header">
42
66
  <h3 class="notes-sidebar__title">Review notes</h3>
43
67
  <div class="notes-sidebar__meta">
44
- {open.length} open · {closed.length} resolved · <code>{page}</code>
68
+ {open.length} open · {resolved.length} resolved
69
+ {isAgency && deleted.length > 0 ? ` · ${deleted.length} deleted` : ''} · <code>{page}</code>
45
70
  </div>
46
71
  </header>
47
72
  <div class="notes-sidebar__list">
48
73
  {error ? <div class="notes-banner">{error}</div> : null}
49
- {items.length === 0
74
+ {visible.length === 0
50
75
  ? (
51
76
  <div class="notes-sidebar__empty">
52
77
  {picking
@@ -56,14 +81,22 @@ export function Sidebar(
56
81
  )
57
82
  : null}
58
83
  {open.map(renderItem)}
59
- {closed.length > 0
84
+ {resolved.length > 0
60
85
  ? (
61
86
  <>
62
- <div class="notes-sidebar__meta" style={{ marginTop: '4px' }}>Resolved</div>
63
- {closed.map(renderItem)}
87
+ <div class="notes-sidebar__section">Resolved</div>
88
+ {resolved.map(renderItem)}
64
89
  </>
65
90
  )
66
91
  : null}
92
+ {isAgency && deleted.length > 0
93
+ ? (
94
+ <details class="notes-sidebar__deleted">
95
+ <summary>Deleted ({deleted.length})</summary>
96
+ {deleted.map(renderItem)}
97
+ </details>
98
+ )
99
+ : null}
67
100
  </div>
68
101
  </aside>
69
102
  )
@@ -8,10 +8,12 @@ interface SidebarItemProps {
8
8
  active: boolean
9
9
  stale: boolean
10
10
  applying: boolean
11
+ isAgency: boolean
11
12
  onFocus: () => void
12
13
  onResolve: () => void
13
14
  onReopen: () => void
14
15
  onDelete: () => void
16
+ onPurge: () => void
15
17
  onApply: () => void
16
18
  }
17
19
 
@@ -37,15 +39,24 @@ function formatTime(iso: string): string {
37
39
  * One note card in the sidebar list. Renders both comment and suggestion
38
40
  * shapes from a unified data model: comments show the body; suggestions
39
41
  * show the inline diff and (if any) a rationale + body.
42
+ *
43
+ * Action visibility is split by role:
44
+ * - client: read-only on existing items. No buttons rendered.
45
+ * - agency: full controls (Apply on suggestions, Resolve / Reopen, Delete).
46
+ * On items already in `deleted` state, the only action is Purge
47
+ * (hard remove from disk).
40
48
  */
41
- export function SidebarItem({ item, active, stale, applying, onFocus, onResolve, onReopen, onDelete, onApply }: SidebarItemProps) {
49
+ export function SidebarItem(
50
+ { item, active, stale, applying, isAgency, onFocus, onResolve, onReopen, onDelete, onPurge, onApply }: SidebarItemProps,
51
+ ) {
42
52
  const isResolved = item.status === 'resolved' || item.status === 'applied'
43
53
  const isApplied = item.status === 'applied'
54
+ const isDeleted = item.status === 'deleted'
44
55
  const isSuggestion = item.type === 'suggestion' && item.range
45
- const canApply = isSuggestion && !isApplied && !stale
56
+ const canApply = isAgency && isSuggestion && !isApplied && !isDeleted && !stale
46
57
  return (
47
58
  <div
48
- class={`notes-item ${active ? 'notes-item--active' : ''} ${isResolved ? 'notes-item--resolved' : ''}`}
59
+ class={`notes-item ${active ? 'notes-item--active' : ''} ${isResolved ? 'notes-item--resolved' : ''} ${isDeleted ? 'notes-item--deleted' : ''}`}
49
60
  onMouseEnter={onFocus}
50
61
  >
51
62
  <div class="notes-item__head">
@@ -56,6 +67,9 @@ export function SidebarItem({ item, active, stale, applying, onFocus, onResolve,
56
67
  {isResolved
57
68
  ? <span class="notes-item__badge notes-item__badge--resolved">{item.status}</span>
58
69
  : null}
70
+ {isDeleted
71
+ ? <span class="notes-item__badge notes-item__badge--deleted">deleted</span>
72
+ : null}
59
73
  <span class="notes-item__author">{item.author}</span>
60
74
  </div>
61
75
  <span class="notes-item__time">{formatTime(item.createdAt)}</span>
@@ -86,19 +100,46 @@ export function SidebarItem({ item, active, stale, applying, onFocus, onResolve,
86
100
  </>
87
101
  )}
88
102
 
89
- <div class="notes-item__actions">
90
- {canApply
91
- ? (
92
- <button class="notes-btn notes-btn--primary" onClick={onApply} disabled={applying}>
93
- {applying ? 'Applying...' : 'Apply'}
94
- </button>
95
- )
96
- : null}
97
- {isResolved
98
- ? <button class="notes-btn notes-btn--ghost" onClick={onReopen}>Reopen</button>
99
- : <button class="notes-btn" onClick={onResolve}>Resolve</button>}
100
- <button class="notes-btn notes-btn--ghost notes-btn--danger" onClick={onDelete}>Delete</button>
101
- </div>
103
+ {isAgency && item.history.length > 1
104
+ ? (
105
+ <details class="notes-item__history">
106
+ <summary>History ({item.history.length})</summary>
107
+ <ul>
108
+ {item.history.map((h, i) => (
109
+ <li key={i}>
110
+ <span class="notes-item__history-action">{h.action}</span>
111
+ <span class="notes-item__history-meta">
112
+ {h.role ? ` · ${h.role}` : ''} · {formatTime(h.at)}
113
+ </span>
114
+ {h.note ? <div class="notes-item__history-note">{h.note}</div> : null}
115
+ </li>
116
+ ))}
117
+ </ul>
118
+ </details>
119
+ )
120
+ : null}
121
+
122
+ {isAgency
123
+ ? (
124
+ <div class="notes-item__actions">
125
+ {canApply
126
+ ? (
127
+ <button class="notes-btn notes-btn--primary" onClick={onApply} disabled={applying}>
128
+ {applying ? 'Applying...' : 'Apply'}
129
+ </button>
130
+ )
131
+ : null}
132
+ {isDeleted
133
+ ? <button class="notes-btn notes-btn--ghost notes-btn--danger" onClick={onPurge}>Purge</button>
134
+ : isResolved
135
+ ? <button class="notes-btn notes-btn--ghost" onClick={onReopen}>Reopen</button>
136
+ : <button class="notes-btn" onClick={onResolve}>Resolve</button>}
137
+ {!isDeleted
138
+ ? <button class="notes-btn notes-btn--ghost notes-btn--danger" onClick={onDelete}>Delete</button>
139
+ : null}
140
+ </div>
141
+ )
142
+ : null}
102
143
  </div>
103
144
  )
104
145
  }
@@ -1,24 +1,29 @@
1
1
  /** @jsxImportSource preact */
2
+ import type { NoteRole } from '../types'
2
3
 
3
4
  interface ToolbarProps {
4
5
  page: string
5
6
  count: number
6
7
  picking: boolean
8
+ role: NoteRole
9
+ collapsed: boolean
7
10
  onTogglePick: () => void
11
+ onToggleCollapse: () => void
8
12
  onExit: () => void
9
13
  }
10
14
 
11
15
  /**
12
16
  * Top bar for notes review mode. Shows the brand mark, current page path,
13
- * note count, a "Pick element" button (to enter the click-to-comment flow),
14
- * and an "Exit review" button that drops the cookie + reloads.
17
+ * note count, the active role (client / agency), the Pick element button,
18
+ * and an Exit button that drops the cookies + reloads back into CMS view.
15
19
  */
16
- export function Toolbar({ page, count, picking, onTogglePick, onExit }: ToolbarProps) {
20
+ export function Toolbar({ page, count, picking, role, collapsed, onTogglePick, onToggleCollapse, onExit }: ToolbarProps) {
17
21
  return (
18
22
  <div class="notes-toolbar">
19
23
  <div class="notes-toolbar__brand">
20
24
  <span class="notes-toolbar__dot" />
21
25
  <span>Notes</span>
26
+ <span class={`notes-toolbar__role notes-toolbar__role--${role}`}>{role}</span>
22
27
  <span class="notes-toolbar__page">{page} · {count} {count === 1 ? 'item' : 'items'}</span>
23
28
  </div>
24
29
  <div class="notes-toolbar__actions">
@@ -29,6 +34,13 @@ export function Toolbar({ page, count, picking, onTogglePick, onExit }: ToolbarP
29
34
  >
30
35
  {picking ? 'Cancel pick' : 'Pick element'}
31
36
  </button>
37
+ <button
38
+ class="notes-btn notes-btn--ghost"
39
+ onClick={onToggleCollapse}
40
+ title={collapsed ? 'Show notes sidebar' : 'Hide notes sidebar to see the full page'}
41
+ >
42
+ {collapsed ? 'Show sidebar' : 'Hide sidebar'}
43
+ </button>
32
44
  <button class="notes-btn notes-btn--ghost" onClick={onExit} title="Leave review mode">
33
45
  Exit
34
46
  </button>