review-handoff-plugin 1.0.0 → 1.0.1

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.
@@ -8,6 +8,7 @@
8
8
 
9
9
  let reviewId = null
10
10
  let pins = []
11
+ let replies = {} // { [pinId]: Reply[] }
11
12
  let mode = 'pointer'
12
13
  let panelOpen = false
13
14
  let activePanel = null // 'threads' | 'handoff'
@@ -15,6 +16,7 @@
15
16
  let handoffData = null
16
17
  let handoffHistory = []
17
18
  let handoffLoading = false
19
+ let replyingTo = null // pinId being replied to
18
20
 
19
21
  async function sbFetch(path, opts = {}) {
20
22
  const res = await fetch(`${SUPABASE_URL}/rest/v1/${path}`, {
@@ -46,7 +48,12 @@
46
48
  const css = `
47
49
  #rh-overlay{position:fixed;inset:0;z-index:2147483640;pointer-events:none}
48
50
  #rh-overlay.active{pointer-events:all;cursor:crosshair}
49
- .rh-pin{position:fixed;width:28px;height:28px;border-radius:50% 50% 50% 0;background:#6366f1;border:2px solid #fff;transform:rotate(-45deg);cursor:pointer;pointer-events:all;box-shadow:0 2px 8px rgba(0,0,0,.3);z-index:2147483641;display:flex;align-items:center;justify-content:center}
51
+ #rh-popover{position:absolute;width:260px;background:#1c1c1f;border:1px solid rgba(255,255,255,.12);border-radius:12px;padding:14px;box-shadow:0 8px 32px rgba(0,0,0,.5);z-index:2147483647;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;display:none}
52
+ #rh-popover input,#rh-popover textarea{width:100%;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);border-radius:8px;color:#fff;font-size:13px;padding:8px 10px;font-family:inherit;box-sizing:border-box;outline:none;margin-bottom:8px}
53
+ #rh-popover textarea{resize:none}
54
+ #rh-popover input::placeholder,#rh-popover textarea::placeholder{color:rgba(255,255,255,.3)}
55
+ #rh-popover .rh-form-actions{display:flex;gap:8px;margin-top:4px}
56
+ .rh-pin{position:absolute;width:28px;height:28px;border-radius:50% 50% 50% 0;background:#6366f1;border:2px solid #fff;transform:rotate(-45deg);cursor:pointer;pointer-events:all;box-shadow:0 2px 8px rgba(0,0,0,.3);z-index:2147483641;display:flex;align-items:center;justify-content:center}
50
57
  .rh-pin.resolved{background:#22c55e}
51
58
  .rh-pin span{transform:rotate(45deg);color:#fff;font-size:11px;font-weight:700;font-family:-apple-system,sans-serif}
52
59
  #rh-panel{position:fixed;top:0;right:0;width:300px;height:100vh;background:#18181b;border-left:1px solid rgba(255,255,255,.08);z-index:2147483645;display:flex;flex-direction:column;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;box-shadow:-4px 0 24px rgba(0,0,0,.4);transform:translateX(100%);transition:transform .2s ease}
@@ -61,11 +68,25 @@
61
68
  .rh-badge.resolved{background:#22c55e}
62
69
  .rh-author{color:rgba(255,255,255,.5);font-size:11px;margin:0}
63
70
  .rh-body{color:rgba(255,255,255,.85);font-size:13px;line-height:1.5;margin:0}
64
- .rh-status-btn{margin-top:8px;font-size:11px;padding:4px 8px;border-radius:6px;border:1px solid rgba(255,255,255,.1);background:none;color:rgba(255,255,255,.4);cursor:pointer;font-family:inherit}
65
- #rh-comment-form{padding:12px;border-top:1px solid rgba(255,255,255,.06)}
66
- #rh-comment-form textarea{width:100%;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.1);border-radius:8px;color:#fff;font-size:13px;padding:10px;resize:none;font-family:inherit;box-sizing:border-box;outline:none}
71
+ .rh-card-actions{display:flex;gap:6px;margin-top:8px;align-items:center}
72
+ .rh-status-btn{font-size:11px;padding:4px 8px;border-radius:6px;border:1px solid rgba(255,255,255,.25);background:rgba(255,255,255,.06);color:rgba(255,255,255,.8);cursor:pointer;font-family:inherit}
73
+ .rh-status-btn:hover{background:rgba(255,255,255,.12);color:#fff}
74
+ .rh-reply-btn{font-size:11px;padding:4px 8px;border-radius:6px;border:1px solid rgba(99,102,241,.3);background:rgba(99,102,241,.08);color:#a5b4fc;cursor:pointer;font-family:inherit}
75
+ .rh-reply-btn:hover{background:rgba(99,102,241,.18);color:#c7d2fe}
76
+ .rh-replies{margin-top:10px;padding-top:10px;border-top:1px solid rgba(255,255,255,.06);display:flex;flex-direction:column;gap:6px}
77
+ .rh-reply{background:rgba(255,255,255,.03);border-radius:8px;padding:8px 10px}
78
+ .rh-reply-author{color:rgba(255,255,255,.4);font-size:10px;margin:0 0 3px}
79
+ .rh-reply-body{color:rgba(255,255,255,.75);font-size:12px;line-height:1.5;margin:0}
80
+ .rh-reply-form{margin-top:10px;padding-top:10px;border-top:1px solid rgba(255,255,255,.06)}
81
+ .rh-reply-form input,.rh-reply-form textarea{width:100%;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.1);border-radius:7px;color:#fff;font-size:12px;padding:7px 9px;font-family:inherit;box-sizing:border-box;outline:none;margin-bottom:6px}
82
+ .rh-reply-form textarea{resize:none}
83
+ .rh-reply-form input::placeholder,.rh-reply-form textarea::placeholder{color:rgba(255,255,255,.25)}
84
+ .rh-reply-actions{display:flex;gap:6px}
85
+ .rh-reply-cancel{flex:1;padding:6px;border-radius:7px;border:1px solid rgba(255,255,255,.25);background:rgba(255,255,255,.06);color:rgba(255,255,255,.8);font-size:12px;cursor:pointer;font-family:inherit}
86
+ .rh-reply-send{flex:1;padding:6px;border-radius:7px;border:none;background:#6366f1;color:#fff;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit}
87
+ .rh-reply-send:disabled{opacity:.5;cursor:not-allowed}
67
88
  .rh-form-actions{display:flex;gap:8px;margin-top:8px}
68
- .rh-btn-cancel{flex:1;padding:8px;border-radius:8px;border:1px solid rgba(255,255,255,.1);background:none;color:rgba(255,255,255,.5);font-size:13px;cursor:pointer;font-family:inherit}
89
+ .rh-btn-cancel{flex:1;padding:8px;border-radius:8px;border:1px solid rgba(255,255,255,.25);background:rgba(255,255,255,.06);color:rgba(255,255,255,.8);font-size:13px;cursor:pointer;font-family:inherit}
69
90
  .rh-btn-save{flex:1;padding:8px;border-radius:8px;border:none;background:#6366f1;color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit}
70
91
  .rh-btn-save:disabled{opacity:.5;cursor:not-allowed}
71
92
  #rh-toolbar{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);z-index:2147483646;display:flex;align-items:center;gap:4px;background:#1c1c1f;border:1px solid rgba(255,255,255,.1);border-radius:14px;padding:6px 10px;box-shadow:0 8px 32px rgba(0,0,0,.5);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
@@ -104,13 +125,10 @@
104
125
  overlay.id = 'rh-overlay'
105
126
  overlay.addEventListener('click', (e) => {
106
127
  if (mode !== 'comment') return
107
- const x = (e.clientX / window.innerWidth) * 100
108
- const y = (e.clientY / window.innerHeight) * 100
128
+ const x = (e.pageX / document.documentElement.clientWidth) * 100
129
+ const y = (e.pageY / document.documentElement.scrollHeight) * 100
109
130
  pendingPos = { x, y }
110
- openPanel('threads')
111
- document.getElementById('rh-comment-form').style.display = 'block'
112
- document.getElementById('rh-textarea').value = ''
113
- document.getElementById('rh-textarea').focus()
131
+ showPopover(e.clientX, e.clientY)
114
132
  })
115
133
  document.body.appendChild(overlay)
116
134
 
@@ -124,17 +142,24 @@
124
142
  </div>
125
143
  <div id="rh-pins-list"></div>
126
144
  <div id="rh-handoff-content" style="display:none"></div>
127
- <div id="rh-comment-form" style="display:none">
128
- <textarea id="rh-textarea" rows="3" placeholder="Digite seu comentário…"></textarea>
129
- <div class="rh-form-actions">
130
- <button class="rh-btn-cancel" id="rh-cancel">Cancelar</button>
131
- <button class="rh-btn-save" id="rh-save">Salvar</button>
132
- </div>
133
- </div>
134
145
  `
135
146
  document.body.appendChild(panel)
136
147
 
137
148
  document.getElementById('rh-panel-close').onclick = closePanel
149
+
150
+ // Popover (new comment)
151
+ const popover = document.createElement('div')
152
+ popover.id = 'rh-popover'
153
+ popover.innerHTML = `
154
+ <input id="rh-author-input" type="text" placeholder="Seu nome (opcional)" />
155
+ <textarea id="rh-textarea" rows="3" placeholder="Digite seu comentário…"></textarea>
156
+ <div class="rh-form-actions">
157
+ <button class="rh-btn-cancel" id="rh-cancel">Cancelar</button>
158
+ <button class="rh-btn-save" id="rh-save">Salvar</button>
159
+ </div>
160
+ `
161
+ document.body.appendChild(popover)
162
+
138
163
  document.getElementById('rh-cancel').onclick = cancelComment
139
164
  document.getElementById('rh-save').onclick = handleSave
140
165
 
@@ -173,7 +198,7 @@
173
198
  document.getElementById('rh-panel-title').textContent = which === 'handoff' ? 'Handoff' : 'Comentários'
174
199
  document.getElementById('rh-pins-list').style.display = which === 'threads' ? 'flex' : 'none'
175
200
  document.getElementById('rh-handoff-content').style.display = which === 'handoff' ? 'flex' : 'none'
176
- document.getElementById('rh-comment-form').style.display = 'none'
201
+ cancelComment()
177
202
  if (which === 'handoff') renderHandoff()
178
203
  updateToolbar()
179
204
  }
@@ -181,15 +206,31 @@
181
206
  function closePanel() {
182
207
  panelOpen = false
183
208
  activePanel = null
209
+ replyingTo = null
184
210
  document.getElementById('rh-panel').classList.remove('open')
185
211
  cancelComment()
186
212
  updateToolbar()
187
213
  }
188
214
 
215
+ function showPopover(clientX, clientY) {
216
+ const pop = document.getElementById('rh-popover')
217
+ document.getElementById('rh-author-input').value = ''
218
+ document.getElementById('rh-textarea').value = ''
219
+ const pw = 260, ph = 160
220
+ let left = clientX + window.scrollX + 16
221
+ let top = clientY + window.scrollY + 16
222
+ if (clientX + pw + 20 > window.innerWidth) left = clientX + window.scrollX - pw - 8
223
+ if (clientY + ph + 20 > window.innerHeight) top = clientY + window.scrollY - ph - 8
224
+ pop.style.left = left + 'px'
225
+ pop.style.top = top + 'px'
226
+ pop.style.display = 'block'
227
+ document.getElementById('rh-textarea').focus()
228
+ }
229
+
189
230
  function cancelComment() {
190
231
  pendingPos = null
191
- const form = document.getElementById('rh-comment-form')
192
- if (form) form.style.display = 'none'
232
+ const pop = document.getElementById('rh-popover')
233
+ if (pop) pop.style.display = 'none'
193
234
  mode = 'pointer'
194
235
  document.getElementById('rh-overlay').classList.remove('active')
195
236
  updateToolbar()
@@ -206,8 +247,8 @@
206
247
  pins.forEach((pin, i) => {
207
248
  const el = document.createElement('div')
208
249
  el.className = 'rh-pin' + (pin.status === 'resolved' ? ' resolved' : '')
209
- el.style.left = `calc(${pin.x_percent}% - 14px)`
210
- el.style.top = `calc(${pin.y_percent}% - 14px)`
250
+ el.style.left = `calc(${pin.x_percent / 100 * document.documentElement.clientWidth}px - 14px)`
251
+ el.style.top = `calc(${pin.y_percent / 100 * document.documentElement.scrollHeight}px - 14px)`
211
252
  el.innerHTML = `<span>${i + 1}</span>`
212
253
  el.onclick = (e) => { e.stopPropagation(); openPanel('threads') }
213
254
  document.body.appendChild(el)
@@ -223,23 +264,100 @@
223
264
  list.innerHTML = '<div id="rh-empty">Nenhum comentário ainda.<br>Ative o modo comentário e clique na tela.</div>'
224
265
  return
225
266
  }
226
- list.innerHTML = pins.map((pin, i) => `
227
- <div class="rh-card">
228
- <div class="rh-card-header">
229
- <div class="rh-badge ${pin.status === 'resolved' ? 'resolved' : ''}">${i + 1}</div>
230
- <p class="rh-author">${pin.author_name || 'Anônimo'}</p>
231
- ${pin.status === 'resolved' ? '<p class="rh-author" style="margin-left:auto;color:#22c55e">✓ resolvido</p>' : ''}
267
+
268
+ list.innerHTML = pins.map((pin, i) => {
269
+ const pinReplies = replies[pin.id] || []
270
+ const repliesHTML = pinReplies.length > 0 ? `
271
+ <div class="rh-replies">
272
+ ${pinReplies.map(r => `
273
+ <div class="rh-reply">
274
+ <p class="rh-reply-author">${r.author_name}</p>
275
+ <p class="rh-reply-body">${r.body}</p>
276
+ </div>
277
+ `).join('')}
232
278
  </div>
233
- <p class="rh-body">${pin.body}</p>
234
- <button class="rh-status-btn" data-id="${pin.id}" data-status="${pin.status}">
235
- ${pin.status === 'open' ? 'Marcar resolvido' : 'Reabrir'}
236
- </button>
237
- </div>
238
- `).join('')
279
+ ` : ''
280
+
281
+ const replyFormHTML = replyingTo === pin.id ? `
282
+ <div class="rh-reply-form" id="rh-reply-form-${pin.id}">
283
+ <input type="text" placeholder="Seu nome (opcional)" id="rh-reply-author-${pin.id}" />
284
+ <textarea rows="2" placeholder="Sua resposta…" id="rh-reply-body-${pin.id}"></textarea>
285
+ <div class="rh-reply-actions">
286
+ <button class="rh-reply-cancel" data-pin="${pin.id}">Cancelar</button>
287
+ <button class="rh-reply-send" data-pin="${pin.id}">Enviar</button>
288
+ </div>
289
+ </div>
290
+ ` : ''
291
+
292
+ return `
293
+ <div class="rh-card" data-pin-id="${pin.id}">
294
+ <div class="rh-card-header">
295
+ <div class="rh-badge ${pin.status === 'resolved' ? 'resolved' : ''}">${i + 1}</div>
296
+ <p class="rh-author">${pin.author_name || 'Anônimo'}</p>
297
+ ${pin.status === 'resolved' ? '<p class="rh-author" style="margin-left:auto;color:#22c55e">✓ resolvido</p>' : ''}
298
+ </div>
299
+ <p class="rh-body">${pin.body}</p>
300
+ <div class="rh-card-actions">
301
+ <button class="rh-status-btn" data-id="${pin.id}" data-status="${pin.status}">
302
+ ${pin.status === 'open' ? 'Marcar resolvido' : 'Reabrir'}
303
+ </button>
304
+ <button class="rh-reply-btn" data-pin="${pin.id}">↩ Responder</button>
305
+ ${pinReplies.length > 0 ? `<span style="color:rgba(255,255,255,.3);font-size:11px;margin-left:auto">${pinReplies.length} resp.</span>` : ''}
306
+ </div>
307
+ ${repliesHTML}
308
+ ${replyFormHTML}
309
+ </div>
310
+ `
311
+ }).join('')
239
312
 
240
313
  list.querySelectorAll('.rh-status-btn').forEach(btn => {
241
314
  btn.onclick = () => toggleStatus(btn.dataset.id, btn.dataset.status)
242
315
  })
316
+
317
+ list.querySelectorAll('.rh-reply-btn').forEach(btn => {
318
+ btn.onclick = () => {
319
+ replyingTo = replyingTo === btn.dataset.pin ? null : btn.dataset.pin
320
+ renderPinsList()
321
+ if (replyingTo) {
322
+ setTimeout(() => {
323
+ document.getElementById(`rh-reply-body-${replyingTo}`)?.focus()
324
+ }, 50)
325
+ }
326
+ }
327
+ })
328
+
329
+ list.querySelectorAll('.rh-reply-cancel').forEach(btn => {
330
+ btn.onclick = () => { replyingTo = null; renderPinsList() }
331
+ })
332
+
333
+ list.querySelectorAll('.rh-reply-send').forEach(btn => {
334
+ btn.onclick = () => sendReply(btn.dataset.pin)
335
+ })
336
+ }
337
+
338
+ async function sendReply(pinId) {
339
+ const bodyEl = document.getElementById(`rh-reply-body-${pinId}`)
340
+ const authorEl = document.getElementById(`rh-reply-author-${pinId}`)
341
+ const body = bodyEl?.value.trim()
342
+ if (!body) return
343
+
344
+ const authorName = authorEl?.value.trim() || 'Anônimo'
345
+ const sendBtn = document.querySelector(`.rh-reply-send[data-pin="${pinId}"]`)
346
+ if (sendBtn) { sendBtn.disabled = true; sendBtn.textContent = 'Enviando…' }
347
+
348
+ const data = await sbFetch('replies?select=*', {
349
+ method: 'POST',
350
+ prefer: 'return=representation',
351
+ body: JSON.stringify({ pin_id: pinId, author_name: authorName, body }),
352
+ })
353
+
354
+ if (data?.[0]) {
355
+ if (!replies[pinId]) replies[pinId] = []
356
+ replies[pinId].push(data[0])
357
+ }
358
+
359
+ replyingTo = null
360
+ renderPinsList()
243
361
  }
244
362
 
245
363
  function renderHandoff() {
@@ -322,10 +440,7 @@
322
440
  const res = await fetch(`${API_BASE}/api/handoff`, {
323
441
  method: 'POST',
324
442
  headers: { 'Content-Type': 'application/json' },
325
- body: JSON.stringify({
326
- vercelUrl: location.origin,
327
- reviewId,
328
- }),
443
+ body: JSON.stringify({ vercelUrl: location.origin, reviewId }),
329
444
  })
330
445
  const data = await res.json()
331
446
  if (!res.ok) throw new Error(data.error)
@@ -343,6 +458,7 @@
343
458
  if (!pendingPos || !reviewId) return
344
459
  const body = document.getElementById('rh-textarea').value.trim()
345
460
  if (!body) return
461
+ const authorName = document.getElementById('rh-author-input').value.trim() || 'Anônimo'
346
462
  const btn = document.getElementById('rh-save')
347
463
  btn.disabled = true
348
464
  btn.textContent = 'Salvando…'
@@ -354,14 +470,19 @@
354
470
  x_percent: pendingPos.x,
355
471
  y_percent: pendingPos.y,
356
472
  body,
357
- author_name: 'Anônimo',
473
+ author_name: authorName,
358
474
  status: 'open',
359
475
  }),
360
476
  })
361
477
  pins.push(data[0])
478
+ replies[data[0].id] = []
362
479
  renderPins()
363
480
  renderPinsList()
364
- cancelComment()
481
+ document.getElementById('rh-popover').style.display = 'none'
482
+ pendingPos = null
483
+ mode = 'pointer'
484
+ document.getElementById('rh-overlay').classList.remove('active')
485
+ updateToolbar()
365
486
  btn.disabled = false
366
487
  btn.textContent = 'Salvar'
367
488
  }
@@ -383,12 +504,27 @@
383
504
  try {
384
505
  const url = (location.origin + location.pathname).replace(/\/+$/, '') || location.origin
385
506
  reviewId = await getOrCreateReview(url)
386
- const data = await sbFetch(`pins?review_id=eq.${reviewId}&order=created_at.asc`)
387
- pins = data ?? []
388
- // Load handoff history
389
- const hist = await fetch(`${API_BASE}/api/handoff?reviewId=${reviewId}`).then(r => r.json()).catch(() => [])
507
+
508
+ const [pinsData, repliesData, hist] = await Promise.all([
509
+ sbFetch(`pins?review_id=eq.${reviewId}&order=created_at.asc`),
510
+ sbFetch(`replies?pin_id=in.(select id from pins where review_id=eq.${reviewId})&order=created_at.asc`),
511
+ fetch(`${API_BASE}/api/handoff?reviewId=${reviewId}`).then(r => r.json()).catch(() => []),
512
+ ])
513
+
514
+ pins = pinsData ?? []
515
+
516
+ // Group replies by pin_id
517
+ replies = {}
518
+ pins.forEach(p => { replies[p.id] = [] })
519
+ if (repliesData && !repliesData.error) {
520
+ repliesData.forEach(r => {
521
+ if (replies[r.pin_id]) replies[r.pin_id].push(r)
522
+ })
523
+ }
524
+
390
525
  handoffHistory = hist ?? []
391
526
  if (handoffHistory.length > 0) handoffData = handoffHistory[0].data
527
+
392
528
  buildUI()
393
529
  renderPins()
394
530
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "review-handoff-plugin",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Toolbar de comentários para protótipos Next.js",
5
5
  "bin": {
6
6
  "review-handoff": "bin/init.js"