review-handoff-plugin 1.0.0

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/bin/init.js ADDED
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ const cwd = process.cwd()
7
+
8
+ console.log('\n🔍 Review Handoff — iniciando setup...\n')
9
+
10
+ const srcDir = path.join(__dirname, '..', 'component')
11
+
12
+ // Detecta tipo de projeto
13
+ const isNextJs = fs.existsSync(path.join(cwd, 'next.config.js')) ||
14
+ fs.existsSync(path.join(cwd, 'next.config.mjs')) ||
15
+ fs.existsSync(path.join(cwd, 'next.config.ts'))
16
+
17
+ if (isNextJs) {
18
+ setupNextJs()
19
+ } else {
20
+ setupHtml()
21
+ }
22
+
23
+ function setupNextJs() {
24
+ console.log('✓ Projeto Next.js detectado\n')
25
+
26
+ // Copia componente React
27
+ const destDir = path.join(cwd, 'components')
28
+ if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true })
29
+ fs.copyFileSync(path.join(srcDir, 'ReviewToolbar.tsx'), path.join(destDir, 'ReviewToolbar.tsx'))
30
+ console.log('✓ Componente copiado → components/ReviewToolbar.tsx')
31
+
32
+ // Injeta no layout
33
+ const layoutPaths = [
34
+ path.join(cwd, 'app', 'layout.tsx'),
35
+ path.join(cwd, 'app', 'layout.jsx'),
36
+ path.join(cwd, 'src', 'app', 'layout.tsx'),
37
+ path.join(cwd, 'src', 'app', 'layout.jsx'),
38
+ ]
39
+ const layoutPath = layoutPaths.find(p => fs.existsSync(p))
40
+
41
+ if (!layoutPath) {
42
+ console.log('\n⚠️ Não encontrei app/layout.tsx. Adicione manualmente:\n')
43
+ console.log(" import ReviewToolbar from '@/components/ReviewToolbar'")
44
+ console.log(' // dentro do <body>:')
45
+ console.log(' <ReviewToolbar />\n')
46
+ printDone()
47
+ return
48
+ }
49
+
50
+ let layout = fs.readFileSync(layoutPath, 'utf-8')
51
+ if (layout.includes('ReviewToolbar')) {
52
+ console.log('✓ ReviewToolbar já está no layout.')
53
+ printDone()
54
+ return
55
+ }
56
+
57
+ const firstImport = layout.indexOf('import ')
58
+ layout = layout.slice(0, firstImport) +
59
+ "import ReviewToolbar from '@/components/ReviewToolbar'\n" +
60
+ layout.slice(firstImport)
61
+ layout = layout.replace('</body>', ' <ReviewToolbar />\n </body>')
62
+
63
+ fs.writeFileSync(layoutPath, layout, 'utf-8')
64
+ console.log(`✓ ReviewToolbar injetado em ${layoutPath.replace(cwd, '.')}`)
65
+ printDone()
66
+ }
67
+
68
+ function setupHtml() {
69
+ // Copia o script JS puro
70
+ const destPath = path.join(cwd, 'review-toolbar.js')
71
+ fs.copyFileSync(path.join(srcDir, 'review-toolbar.js'), destPath)
72
+ console.log('✓ Script copiado → review-toolbar.js\n')
73
+
74
+ // Tenta injetar em index.html automaticamente
75
+ const htmlPaths = [
76
+ path.join(cwd, 'index.html'),
77
+ path.join(cwd, 'public', 'index.html'),
78
+ path.join(cwd, 'src', 'index.html'),
79
+ ]
80
+ const htmlPath = htmlPaths.find(p => fs.existsSync(p))
81
+
82
+ if (htmlPath) {
83
+ let html = fs.readFileSync(htmlPath, 'utf-8')
84
+ if (!html.includes('review-toolbar.js')) {
85
+ html = html.replace('</body>', ' <script src="/review-toolbar.js"></script>\n</body>')
86
+ fs.writeFileSync(htmlPath, html, 'utf-8')
87
+ console.log(`✓ Script injetado em ${htmlPath.replace(cwd, '.')}`)
88
+ } else {
89
+ console.log('✓ Script já está no HTML.')
90
+ }
91
+ } else {
92
+ console.log('⚠️ Adicione manualmente antes do </body> em todos os HTMLs:\n')
93
+ console.log(' <script src="/review-toolbar.js"></script>\n')
94
+ }
95
+
96
+ printDone()
97
+ }
98
+
99
+ function printDone() {
100
+ console.log('\n✅ Pronto! Qualquer pessoa que abrir o protótipo vai ver a toolbar de comentários.')
101
+ console.log(' Faça o deploy normalmente e compartilhe o link.\n')
102
+ }
@@ -0,0 +1,247 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useCallback } from 'react'
4
+
5
+ const SUPABASE_URL = 'https://ikmtbhnfipatxecxpyfa.supabase.co'
6
+ const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImlrbXRiaG5maXBhdHhlY3hweWZhIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODE2MTE3MzMsImV4cCI6MjA5NzE4NzczM30.Q95hSSGtJcm47xhN7Rn5fFJBvjB94oLjeC3uavLC-Ps'
7
+
8
+ type Pin = {
9
+ id: string
10
+ x_percent: number
11
+ y_percent: number
12
+ body: string
13
+ author_name: string
14
+ status: 'open' | 'resolved'
15
+ created_at: string
16
+ }
17
+
18
+ async function sbFetch(path: string, opts: any = {}) {
19
+ const res = await fetch(`${SUPABASE_URL}/rest/v1/${path}`, {
20
+ ...opts,
21
+ headers: {
22
+ apikey: SUPABASE_ANON_KEY,
23
+ Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
24
+ 'Content-Type': 'application/json',
25
+ Prefer: opts.prefer || '',
26
+ ...(opts.headers || {}),
27
+ },
28
+ })
29
+ if (res.status === 204) return null
30
+ return res.json()
31
+ }
32
+
33
+ async function getOrCreateReview(url: string) {
34
+ const existing = await sbFetch(`reviews?url=eq.${encodeURIComponent(url)}&select=id&limit=1`)
35
+ if (existing?.length > 0) return existing[0].id
36
+ const created = await sbFetch('reviews?select=id', {
37
+ method: 'POST',
38
+ prefer: 'return=representation',
39
+ body: JSON.stringify({ url }),
40
+ })
41
+ return created[0].id
42
+ }
43
+
44
+ export default function ReviewToolbar() {
45
+ const [reviewId, setReviewId] = useState<string | null>(null)
46
+ const [pins, setPins] = useState<Pin[]>([])
47
+ const [mode, setMode] = useState<'pointer' | 'comment'>('pointer')
48
+ const [panel, setPanel] = useState(false)
49
+ const [pendingPos, setPendingPos] = useState<{ x: number; y: number } | null>(null)
50
+ const [body, setBody] = useState('')
51
+ const [saving, setSaving] = useState(false)
52
+
53
+ useEffect(() => {
54
+ const url = (location.origin + location.pathname).replace(/\/+$/, '') || location.origin
55
+ getOrCreateReview(url).then(async (id) => {
56
+ setReviewId(id)
57
+ const data = await sbFetch(`pins?review_id=eq.${id}&order=created_at.asc`)
58
+ setPins(data ?? [])
59
+ })
60
+ }, [])
61
+
62
+ const handleOverlayClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
63
+ if (mode !== 'comment') return
64
+ const x = (e.clientX / window.innerWidth) * 100
65
+ const y = (e.clientY / window.innerHeight) * 100
66
+ setPendingPos({ x, y })
67
+ setPanel(true)
68
+ }, [mode])
69
+
70
+ const handleSave = async () => {
71
+ if (!pendingPos || !body.trim() || !reviewId) return
72
+ setSaving(true)
73
+ const data = await sbFetch('pins?select=*', {
74
+ method: 'POST',
75
+ prefer: 'return=representation',
76
+ body: JSON.stringify({
77
+ review_id: reviewId,
78
+ x_percent: pendingPos.x,
79
+ y_percent: pendingPos.y,
80
+ body: body.trim(),
81
+ author_name: 'Anônimo',
82
+ status: 'open',
83
+ }),
84
+ })
85
+ setPins(p => [...p, data[0]])
86
+ setBody('')
87
+ setPendingPos(null)
88
+ setMode('pointer')
89
+ setSaving(false)
90
+ }
91
+
92
+ const handleToggleStatus = async (pin: Pin) => {
93
+ const next = pin.status === 'open' ? 'resolved' : 'open'
94
+ await sbFetch(`pins?id=eq.${pin.id}`, {
95
+ method: 'PATCH',
96
+ prefer: 'return=minimal',
97
+ body: JSON.stringify({ status: next }),
98
+ })
99
+ setPins(p => p.map(p2 => p2.id === pin.id ? { ...p2, status: next } : p2))
100
+ }
101
+
102
+ const s = styles
103
+
104
+ return (
105
+ <>
106
+ {/* Overlay */}
107
+ <div
108
+ onClick={handleOverlayClick}
109
+ style={{
110
+ ...s.overlay,
111
+ pointerEvents: mode === 'comment' ? 'all' : 'none',
112
+ cursor: mode === 'comment' ? 'crosshair' : 'default',
113
+ }}
114
+ />
115
+
116
+ {/* Pins */}
117
+ {pins.map((pin, i) => (
118
+ <div
119
+ key={pin.id}
120
+ onClick={() => { setPanel(true) }}
121
+ style={{
122
+ ...s.pin,
123
+ left: `calc(${pin.x_percent}% - 14px)`,
124
+ top: `calc(${pin.y_percent}% - 14px)`,
125
+ background: pin.status === 'resolved' ? '#22c55e' : '#6366f1',
126
+ }}
127
+ >
128
+ <span style={s.pinNum}>{i + 1}</span>
129
+ </div>
130
+ ))}
131
+
132
+ {/* Panel */}
133
+ {panel && (
134
+ <div style={s.panel}>
135
+ <div style={s.panelHeader}>
136
+ <span style={s.panelTitle}>Comentários</span>
137
+ <button onClick={() => { setPanel(false); setPendingPos(null); setBody('') }} style={s.closeBtn}>✕</button>
138
+ </div>
139
+
140
+ <div style={s.pinsList}>
141
+ {pins.length === 0 && !pendingPos && (
142
+ <p style={s.empty}>Nenhum comentário ainda.<br />Ative o modo comentário e clique na tela.</p>
143
+ )}
144
+ {pins.map((pin, i) => (
145
+ <div key={pin.id} style={s.card}>
146
+ <div style={s.cardHeader}>
147
+ <div style={{ ...s.badge, background: pin.status === 'resolved' ? '#22c55e' : '#6366f1' }}>{i + 1}</div>
148
+ <span style={s.author}>{pin.author_name}</span>
149
+ {pin.status === 'resolved' && <span style={{ ...s.author, marginLeft: 'auto', color: '#22c55e' }}>✓ resolvido</span>}
150
+ </div>
151
+ <p style={s.cardBody}>{pin.body}</p>
152
+ <button onClick={() => handleToggleStatus(pin)} style={s.statusBtn}>
153
+ {pin.status === 'open' ? 'Marcar resolvido' : 'Reabrir'}
154
+ </button>
155
+ </div>
156
+ ))}
157
+
158
+ {pendingPos && (
159
+ <div style={s.card}>
160
+ <p style={{ ...s.author, marginBottom: 8 }}>Novo comentário</p>
161
+ <textarea
162
+ autoFocus
163
+ value={body}
164
+ onChange={e => setBody(e.target.value)}
165
+ placeholder="Digite seu comentário…"
166
+ rows={3}
167
+ style={s.textarea}
168
+ />
169
+ <div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
170
+ <button onClick={() => { setPendingPos(null); setBody(''); setMode('pointer') }} style={s.cancelBtn}>Cancelar</button>
171
+ <button onClick={handleSave} disabled={saving || !body.trim()} style={s.saveBtn}>
172
+ {saving ? 'Salvando…' : 'Salvar'}
173
+ </button>
174
+ </div>
175
+ </div>
176
+ )}
177
+ </div>
178
+ </div>
179
+ )}
180
+
181
+ {/* Toolbar */}
182
+ <div style={s.toolbar}>
183
+ <button
184
+ onClick={() => setMode(m => m === 'comment' ? 'pointer' : 'comment')}
185
+ style={{ ...s.toolBtn, ...(mode === 'comment' ? s.toolBtnActive : {}) }}
186
+ >
187
+ 💬 Comentar
188
+ </button>
189
+ <div style={s.divider} />
190
+ <button
191
+ onClick={() => setPanel(p => !p)}
192
+ style={{ ...s.toolBtn, ...(panel ? s.toolBtnActive : {}) }}
193
+ >
194
+ ☰ Threads {pins.length > 0 && `(${pins.filter(p => p.status === 'open').length})`}
195
+ </button>
196
+ </div>
197
+ </>
198
+ )
199
+ }
200
+
201
+ const styles: Record<string, React.CSSProperties> = {
202
+ overlay: { position: 'fixed', inset: 0, zIndex: 2147483640 },
203
+ pin: {
204
+ position: 'fixed', width: 28, height: 28, borderRadius: '50% 50% 50% 0',
205
+ border: '2px solid #fff', transform: 'rotate(-45deg)', cursor: 'pointer',
206
+ boxShadow: '0 2px 8px rgba(0,0,0,0.3)', zIndex: 2147483641,
207
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
208
+ },
209
+ pinNum: { transform: 'rotate(45deg)', color: '#fff', fontSize: 11, fontWeight: 700 },
210
+ panel: {
211
+ position: 'fixed', top: 0, right: 0, width: 320, height: '100vh',
212
+ background: '#18181b', borderLeft: '1px solid rgba(255,255,255,0.08)',
213
+ zIndex: 2147483645, display: 'flex', flexDirection: 'column',
214
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
215
+ boxShadow: '-4px 0 24px rgba(0,0,0,0.4)',
216
+ },
217
+ panelHeader: {
218
+ padding: '16px', borderBottom: '1px solid rgba(255,255,255,0.06)',
219
+ display: 'flex', alignItems: 'center',
220
+ },
221
+ panelTitle: { color: '#fff', fontSize: 14, fontWeight: 600, flex: 1 },
222
+ closeBtn: { background: 'none', border: 'none', color: 'rgba(255,255,255,0.4)', cursor: 'pointer', fontSize: 16 },
223
+ pinsList: { flex: 1, overflowY: 'auto', padding: 12, display: 'flex', flexDirection: 'column', gap: 8 },
224
+ empty: { color: 'rgba(255,255,255,0.25)', fontSize: 13, textAlign: 'center', padding: '32px 0', lineHeight: 1.6 },
225
+ card: {
226
+ background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.06)',
227
+ borderRadius: 10, padding: 12,
228
+ },
229
+ cardHeader: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 },
230
+ badge: { width: 20, height: 20, borderRadius: '50%', color: '#fff', fontSize: 10, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 },
231
+ author: { color: 'rgba(255,255,255,0.5)', fontSize: 11 },
232
+ cardBody: { color: 'rgba(255,255,255,0.85)', fontSize: 13, lineHeight: 1.5, margin: 0 },
233
+ statusBtn: { marginTop: 8, fontSize: 11, padding: '4px 8px', borderRadius: 6, border: '1px solid rgba(255,255,255,0.1)', background: 'none', color: 'rgba(255,255,255,0.4)', cursor: 'pointer' },
234
+ textarea: { width: '100%', background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 8, color: '#fff', fontSize: 13, padding: 10, resize: 'none', fontFamily: 'inherit', boxSizing: 'border-box' },
235
+ cancelBtn: { flex: 1, padding: 8, borderRadius: 8, border: '1px solid rgba(255,255,255,0.1)', background: 'none', color: 'rgba(255,255,255,0.5)', fontSize: 13, cursor: 'pointer' },
236
+ saveBtn: { flex: 1, padding: 8, borderRadius: 8, border: 'none', background: '#6366f1', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer' },
237
+ toolbar: {
238
+ position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
239
+ zIndex: 2147483646, display: 'flex', alignItems: 'center', gap: 4,
240
+ background: '#1c1c1f', border: '1px solid rgba(255,255,255,0.1)',
241
+ borderRadius: 14, padding: '6px 10px', boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
242
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
243
+ },
244
+ toolBtn: { display: 'flex', alignItems: 'center', gap: 6, padding: '7px 12px', borderRadius: 9, border: 'none', background: 'none', color: 'rgba(255,255,255,0.5)', fontSize: 12, fontWeight: 500, cursor: 'pointer', whiteSpace: 'nowrap' },
245
+ toolBtnActive: { background: 'rgba(99,102,241,0.2)', color: '#a5b4fc' },
246
+ divider: { width: 1, height: 20, background: 'rgba(255,255,255,0.08)', margin: '0 2px' },
247
+ }
@@ -0,0 +1,404 @@
1
+ (function () {
2
+ if (window.__reviewHandoffLoaded) return
3
+ window.__reviewHandoffLoaded = true
4
+
5
+ const SUPABASE_URL = 'https://ikmtbhnfipatxecxpyfa.supabase.co'
6
+ const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImlrbXRiaG5maXBhdHhlY3hweWZhIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODE2MTE3MzMsImV4cCI6MjA5NzE4NzczM30.Q95hSSGtJcm47xhN7Rn5fFJBvjB94oLjeC3uavLC-Ps'
7
+ const API_BASE = 'https://review-handoff-system.vercel.app'
8
+
9
+ let reviewId = null
10
+ let pins = []
11
+ let mode = 'pointer'
12
+ let panelOpen = false
13
+ let activePanel = null // 'threads' | 'handoff'
14
+ let pendingPos = null
15
+ let handoffData = null
16
+ let handoffHistory = []
17
+ let handoffLoading = false
18
+
19
+ async function sbFetch(path, opts = {}) {
20
+ const res = await fetch(`${SUPABASE_URL}/rest/v1/${path}`, {
21
+ ...opts,
22
+ headers: {
23
+ apikey: SUPABASE_ANON_KEY,
24
+ Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
25
+ 'Content-Type': 'application/json',
26
+ Prefer: opts.prefer || '',
27
+ ...(opts.headers || {}),
28
+ },
29
+ })
30
+ if (res.status === 204) return null
31
+ return res.json()
32
+ }
33
+
34
+ async function getOrCreateReview(url) {
35
+ const existing = await sbFetch(`reviews?url=eq.${encodeURIComponent(url)}&select=id&limit=1`)
36
+ if (existing?.length > 0) return existing[0].id
37
+ const created = await sbFetch('reviews?select=id', {
38
+ method: 'POST',
39
+ prefer: 'return=representation',
40
+ body: JSON.stringify({ url }),
41
+ })
42
+ return created[0].id
43
+ }
44
+
45
+ function injectStyles() {
46
+ const css = `
47
+ #rh-overlay{position:fixed;inset:0;z-index:2147483640;pointer-events:none}
48
+ #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}
50
+ .rh-pin.resolved{background:#22c55e}
51
+ .rh-pin span{transform:rotate(45deg);color:#fff;font-size:11px;font-weight:700;font-family:-apple-system,sans-serif}
52
+ #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}
53
+ #rh-panel.open{transform:translateX(0)}
54
+ #rh-panel-header{padding:16px;border-bottom:1px solid rgba(255,255,255,.06);display:flex;align-items:center;gap:10px}
55
+ #rh-panel-header h2{color:#fff;font-size:14px;font-weight:600;margin:0;flex:1}
56
+ #rh-panel-close{background:none;border:none;color:rgba(255,255,255,.4);cursor:pointer;font-size:18px;padding:4px;line-height:1}
57
+ #rh-pins-list{flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:8px}
58
+ .rh-card{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06);border-radius:10px;padding:12px}
59
+ .rh-card-header{display:flex;align-items:center;gap:8px;margin-bottom:6px}
60
+ .rh-badge{width:20px;height:20px;border-radius:50%;background:#6366f1;color:#fff;font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0}
61
+ .rh-badge.resolved{background:#22c55e}
62
+ .rh-author{color:rgba(255,255,255,.5);font-size:11px;margin:0}
63
+ .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}
67
+ .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}
69
+ .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
+ .rh-btn-save:disabled{opacity:.5;cursor:not-allowed}
71
+ #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}
72
+ .rh-tool-btn{display:flex;align-items:center;gap:6px;padding:7px 12px;border-radius:9px;border:none;background:none;color:rgba(255,255,255,.5);font-size:12px;font-weight:500;cursor:pointer;font-family:inherit;white-space:nowrap}
73
+ .rh-tool-btn:hover{background:rgba(255,255,255,.07);color:#fff}
74
+ .rh-tool-btn.active{background:rgba(99,102,241,.2);color:#a5b4fc}
75
+ .rh-divider{width:1px;height:20px;background:rgba(255,255,255,.08);margin:0 2px}
76
+ #rh-empty{text-align:center;color:rgba(255,255,255,.25);font-size:13px;padding:32px 0;line-height:1.6}
77
+ #rh-handoff-content{flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:12px}
78
+ .rh-handoff-section{margin-bottom:12px}
79
+ .rh-handoff-label{color:rgba(255,255,255,.4);font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;margin:0 0 8px}
80
+ .rh-color-chip{display:flex;align-items:center;gap:6px;background:rgba(255,255,255,.04);border-radius:8px;padding:5px 8px;cursor:pointer;margin-bottom:4px}
81
+ .rh-color-dot{width:16px;height:16px;border-radius:4px;border:1px solid rgba(255,255,255,.15);flex-shrink:0}
82
+ .rh-color-name{color:#fff;font-size:11px;font-weight:600}
83
+ .rh-color-hex{color:rgba(255,255,255,.35);font-size:10px}
84
+ .rh-type-row{background:rgba(255,255,255,.04);border-radius:8px;padding:8px;margin-bottom:4px}
85
+ .rh-type-name{color:#fff;font-size:11px;font-weight:600}
86
+ .rh-type-detail{color:rgba(255,255,255,.4);font-size:10px}
87
+ .rh-comp-chip{display:inline-block;background:rgba(99,102,241,.1);border:1px solid rgba(99,102,241,.2);border-radius:6px;padding:3px 8px;color:#a5b4fc;font-size:11px;margin:2px}
88
+ .rh-generate-btn{width:100%;padding:10px;border-radius:10px;border:none;background:#6366f1;color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;margin-top:8px}
89
+ .rh-generate-btn:disabled{opacity:.5;cursor:not-allowed}
90
+ .rh-regenerate-btn{width:100%;padding:8px;border-radius:10px;border:1px solid rgba(99,102,241,.4);background:rgba(99,102,241,.1);color:#a5b4fc;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;margin-top:8px}
91
+ .rh-summary-box{background:rgba(99,102,241,.08);border:1px solid rgba(99,102,241,.2);border-radius:10px;padding:12px;margin-bottom:12px}
92
+ .rh-summary-text{color:rgba(255,255,255,.7);font-size:12px;margin:0;line-height:1.6}
93
+ `
94
+ const style = document.createElement('style')
95
+ style.textContent = css
96
+ document.head.appendChild(style)
97
+ }
98
+
99
+ function buildUI() {
100
+ injectStyles()
101
+
102
+ // Overlay
103
+ const overlay = document.createElement('div')
104
+ overlay.id = 'rh-overlay'
105
+ overlay.addEventListener('click', (e) => {
106
+ if (mode !== 'comment') return
107
+ const x = (e.clientX / window.innerWidth) * 100
108
+ const y = (e.clientY / window.innerHeight) * 100
109
+ 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()
114
+ })
115
+ document.body.appendChild(overlay)
116
+
117
+ // Panel
118
+ const panel = document.createElement('div')
119
+ panel.id = 'rh-panel'
120
+ panel.innerHTML = `
121
+ <div id="rh-panel-header">
122
+ <h2 id="rh-panel-title">Comentários</h2>
123
+ <button id="rh-panel-close">✕</button>
124
+ </div>
125
+ <div id="rh-pins-list"></div>
126
+ <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
+ `
135
+ document.body.appendChild(panel)
136
+
137
+ document.getElementById('rh-panel-close').onclick = closePanel
138
+ document.getElementById('rh-cancel').onclick = cancelComment
139
+ document.getElementById('rh-save').onclick = handleSave
140
+
141
+ // Toolbar
142
+ const toolbar = document.createElement('div')
143
+ toolbar.id = 'rh-toolbar'
144
+ toolbar.innerHTML = `
145
+ <button class="rh-tool-btn" id="rh-btn-comment">💬 Comentar</button>
146
+ <div class="rh-divider"></div>
147
+ <button class="rh-tool-btn" id="rh-btn-threads">☰ Threads</button>
148
+ <div class="rh-divider"></div>
149
+ <button class="rh-tool-btn" id="rh-btn-handoff">✦ Handoff</button>
150
+ `
151
+ document.body.appendChild(toolbar)
152
+
153
+ document.getElementById('rh-btn-comment').onclick = () => {
154
+ mode = mode === 'comment' ? 'pointer' : 'comment'
155
+ overlay.classList.toggle('active', mode === 'comment')
156
+ updateToolbar()
157
+ if (mode !== 'comment') cancelComment()
158
+ }
159
+ document.getElementById('rh-btn-threads').onclick = () => {
160
+ activePanel === 'threads' ? closePanel() : openPanel('threads')
161
+ }
162
+ document.getElementById('rh-btn-handoff').onclick = () => {
163
+ activePanel === 'handoff' ? closePanel() : openPanel('handoff')
164
+ }
165
+
166
+ renderPinsList()
167
+ }
168
+
169
+ function openPanel(which) {
170
+ activePanel = which
171
+ panelOpen = true
172
+ document.getElementById('rh-panel').classList.add('open')
173
+ document.getElementById('rh-panel-title').textContent = which === 'handoff' ? 'Handoff' : 'Comentários'
174
+ document.getElementById('rh-pins-list').style.display = which === 'threads' ? 'flex' : 'none'
175
+ document.getElementById('rh-handoff-content').style.display = which === 'handoff' ? 'flex' : 'none'
176
+ document.getElementById('rh-comment-form').style.display = 'none'
177
+ if (which === 'handoff') renderHandoff()
178
+ updateToolbar()
179
+ }
180
+
181
+ function closePanel() {
182
+ panelOpen = false
183
+ activePanel = null
184
+ document.getElementById('rh-panel').classList.remove('open')
185
+ cancelComment()
186
+ updateToolbar()
187
+ }
188
+
189
+ function cancelComment() {
190
+ pendingPos = null
191
+ const form = document.getElementById('rh-comment-form')
192
+ if (form) form.style.display = 'none'
193
+ mode = 'pointer'
194
+ document.getElementById('rh-overlay').classList.remove('active')
195
+ updateToolbar()
196
+ }
197
+
198
+ function updateToolbar() {
199
+ document.getElementById('rh-btn-comment')?.classList.toggle('active', mode === 'comment')
200
+ document.getElementById('rh-btn-threads')?.classList.toggle('active', activePanel === 'threads')
201
+ document.getElementById('rh-btn-handoff')?.classList.toggle('active', activePanel === 'handoff')
202
+ }
203
+
204
+ function renderPins() {
205
+ document.querySelectorAll('.rh-pin').forEach(el => el.remove())
206
+ pins.forEach((pin, i) => {
207
+ const el = document.createElement('div')
208
+ 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)`
211
+ el.innerHTML = `<span>${i + 1}</span>`
212
+ el.onclick = (e) => { e.stopPropagation(); openPanel('threads') }
213
+ document.body.appendChild(el)
214
+ })
215
+ }
216
+
217
+ function renderPinsList() {
218
+ const list = document.getElementById('rh-pins-list')
219
+ if (!list) return
220
+ const open = pins.filter(p => p.status === 'open').length
221
+ document.getElementById('rh-btn-threads').textContent = `☰ Threads${pins.length > 0 ? ` (${open})` : ''}`
222
+ if (pins.length === 0) {
223
+ list.innerHTML = '<div id="rh-empty">Nenhum comentário ainda.<br>Ative o modo comentário e clique na tela.</div>'
224
+ return
225
+ }
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>' : ''}
232
+ </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('')
239
+
240
+ list.querySelectorAll('.rh-status-btn').forEach(btn => {
241
+ btn.onclick = () => toggleStatus(btn.dataset.id, btn.dataset.status)
242
+ })
243
+ }
244
+
245
+ function renderHandoff() {
246
+ const container = document.getElementById('rh-handoff-content')
247
+ if (!container) return
248
+
249
+ if (!handoffData) {
250
+ container.innerHTML = `
251
+ <p style="color:rgba(255,255,255,.4);font-size:13px;line-height:1.5;margin:0 0 12px">
252
+ Analisa cores, tipografia, espaçamento e componentes deste protótipo.
253
+ </p>
254
+ <button class="rh-generate-btn" id="rh-gen-btn" ${handoffLoading ? 'disabled' : ''}>
255
+ ${handoffLoading ? '✨ Analisando…' : '✨ Gerar Handoff'}
256
+ </button>
257
+ ${handoffLoading ? '<p style="color:rgba(255,255,255,.25);font-size:11px;text-align:center;margin-top:8px">Isso pode levar 20–40 segundos…</p>' : ''}
258
+ ${handoffHistory.length > 0 ? `
259
+ <p class="rh-handoff-label" style="margin-top:16px">Histórico</p>
260
+ ${handoffHistory.map((h, i) => `
261
+ <button style="width:100%;text-align:left;padding:7px 10px;border-radius:8px;border:1px solid rgba(255,255,255,.07);background:rgba(255,255,255,.03);color:rgba(255,255,255,.4);font-size:12px;cursor:pointer;font-family:inherit;margin-bottom:4px" data-idx="${i}" class="rh-hist-btn">
262
+ ${i === 0 ? '● ' : '○ '}${new Date(h.created_at).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
263
+ </button>
264
+ `).join('')}
265
+ ` : ''}
266
+ `
267
+ document.getElementById('rh-gen-btn')?.addEventListener('click', generateHandoff)
268
+ container.querySelectorAll('.rh-hist-btn').forEach(btn => {
269
+ btn.addEventListener('click', () => {
270
+ handoffData = handoffHistory[+btn.dataset.idx].data
271
+ renderHandoff()
272
+ })
273
+ })
274
+ return
275
+ }
276
+
277
+ const d = handoffData
278
+ container.innerHTML = `
279
+ <div class="rh-summary-box"><p class="rh-summary-text">${d.summary || ''}</p></div>
280
+
281
+ ${d.colors?.length ? `
282
+ <div class="rh-handoff-section">
283
+ <p class="rh-handoff-label">🎨 Cores</p>
284
+ ${d.colors.map(c => `
285
+ <div class="rh-color-chip" onclick="navigator.clipboard.writeText('${c.hex}')">
286
+ <div class="rh-color-dot" style="background:${c.hex}"></div>
287
+ <div><div class="rh-color-name">${c.name}</div><div class="rh-color-hex">${c.hex}</div></div>
288
+ </div>
289
+ `).join('')}
290
+ </div>
291
+ ` : ''}
292
+
293
+ ${d.typography?.length ? `
294
+ <div class="rh-handoff-section">
295
+ <p class="rh-handoff-label">✏️ Tipografia</p>
296
+ ${d.typography.map(t => `
297
+ <div class="rh-type-row">
298
+ <div class="rh-type-name">${t.name}</div>
299
+ <div class="rh-type-detail">${t.fontFamily} · ${t.sizes?.join(', ')}</div>
300
+ </div>
301
+ `).join('')}
302
+ </div>
303
+ ` : ''}
304
+
305
+ ${d.components?.length ? `
306
+ <div class="rh-handoff-section">
307
+ <p class="rh-handoff-label">🧩 Componentes</p>
308
+ <div>${d.components.map(c => `<span class="rh-comp-chip">${c.name}</span>`).join('')}</div>
309
+ </div>
310
+ ` : ''}
311
+
312
+ <button class="rh-regenerate-btn" id="rh-regen-btn">↺ Gerar novamente</button>
313
+ `
314
+ document.getElementById('rh-regen-btn').onclick = () => { handoffData = null; renderHandoff() }
315
+ }
316
+
317
+ async function generateHandoff() {
318
+ if (!reviewId) return
319
+ handoffLoading = true
320
+ renderHandoff()
321
+ try {
322
+ const res = await fetch(`${API_BASE}/api/handoff`, {
323
+ method: 'POST',
324
+ headers: { 'Content-Type': 'application/json' },
325
+ body: JSON.stringify({
326
+ vercelUrl: location.origin,
327
+ reviewId,
328
+ }),
329
+ })
330
+ const data = await res.json()
331
+ if (!res.ok) throw new Error(data.error)
332
+ handoffData = data.handoff
333
+ if (data.id) handoffHistory.unshift({ id: data.id, created_at: data.created_at, data: data.handoff })
334
+ } catch (e) {
335
+ console.error('[review-handoff] handoff error:', e)
336
+ } finally {
337
+ handoffLoading = false
338
+ renderHandoff()
339
+ }
340
+ }
341
+
342
+ async function handleSave() {
343
+ if (!pendingPos || !reviewId) return
344
+ const body = document.getElementById('rh-textarea').value.trim()
345
+ if (!body) return
346
+ const btn = document.getElementById('rh-save')
347
+ btn.disabled = true
348
+ btn.textContent = 'Salvando…'
349
+ const data = await sbFetch('pins?select=*', {
350
+ method: 'POST',
351
+ prefer: 'return=representation',
352
+ body: JSON.stringify({
353
+ review_id: reviewId,
354
+ x_percent: pendingPos.x,
355
+ y_percent: pendingPos.y,
356
+ body,
357
+ author_name: 'Anônimo',
358
+ status: 'open',
359
+ }),
360
+ })
361
+ pins.push(data[0])
362
+ renderPins()
363
+ renderPinsList()
364
+ cancelComment()
365
+ btn.disabled = false
366
+ btn.textContent = 'Salvar'
367
+ }
368
+
369
+ async function toggleStatus(pinId, current) {
370
+ const next = current === 'open' ? 'resolved' : 'open'
371
+ await sbFetch(`pins?id=eq.${pinId}`, {
372
+ method: 'PATCH',
373
+ prefer: 'return=minimal',
374
+ body: JSON.stringify({ status: next }),
375
+ })
376
+ const pin = pins.find(p => p.id === pinId)
377
+ if (pin) pin.status = next
378
+ renderPins()
379
+ renderPinsList()
380
+ }
381
+
382
+ async function init() {
383
+ try {
384
+ const url = (location.origin + location.pathname).replace(/\/+$/, '') || location.origin
385
+ 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(() => [])
390
+ handoffHistory = hist ?? []
391
+ if (handoffHistory.length > 0) handoffData = handoffHistory[0].data
392
+ buildUI()
393
+ renderPins()
394
+ } catch (e) {
395
+ console.error('[review-handoff]', e)
396
+ }
397
+ }
398
+
399
+ if (document.readyState === 'loading') {
400
+ document.addEventListener('DOMContentLoaded', init)
401
+ } else {
402
+ init()
403
+ }
404
+ })()
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "review-handoff-plugin",
3
+ "version": "1.0.0",
4
+ "description": "Toolbar de comentários para protótipos Next.js",
5
+ "bin": {
6
+ "review-handoff": "bin/init.js"
7
+ },
8
+ "files": [
9
+ "bin",
10
+ "component"
11
+ ],
12
+ "keywords": [
13
+ "review",
14
+ "handoff",
15
+ "comments",
16
+ "nextjs"
17
+ ],
18
+ "license": "MIT"
19
+ }