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 +102 -0
- package/component/ReviewToolbar.tsx +247 -0
- package/component/review-toolbar.js +404 -0
- package/package.json +19 -0
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
|
+
}
|