@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/dev/api-handlers.ts
CHANGED
|
@@ -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
|
|
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 /
|
|
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 /purge → hard-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(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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 —
|
|
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
|
|
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.
|
|
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 —
|
|
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(
|
|
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
|
}
|
package/src/dev/middleware.ts
CHANGED
|
@@ -47,11 +47,16 @@ export function createNotesDevMiddleware(
|
|
|
47
47
|
|
|
48
48
|
handleNotesApiRoute(route, req, res, ctx)
|
|
49
49
|
.then(() => {
|
|
50
|
-
// Mirror CMS:
|
|
51
|
-
//
|
|
52
|
-
//
|
|
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
|
-
|
|
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
|
})
|
package/src/integration.ts
CHANGED
|
@@ -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';
|
package/src/overlay/App.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
24
|
-
const
|
|
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 · {
|
|
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
|
-
{
|
|
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
|
-
{
|
|
84
|
+
{resolved.length > 0
|
|
60
85
|
? (
|
|
61
86
|
<>
|
|
62
|
-
<div class="notes-
|
|
63
|
-
{
|
|
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(
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
<
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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,
|
|
14
|
-
* and an
|
|
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>
|