@henryavila/mdprobe 0.1.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/README.md +404 -0
- package/bin/cli.js +335 -0
- package/dist/assets/index-DPysqH1p.js +2 -0
- package/dist/assets/index-nl9v2RuJ.css +1 -0
- package/dist/index.html +19 -0
- package/package.json +75 -0
- package/schema.json +104 -0
- package/skills/mdprobe/SKILL.md +358 -0
- package/src/anchoring.js +262 -0
- package/src/annotations.js +504 -0
- package/src/cli-utils.js +58 -0
- package/src/config.js +76 -0
- package/src/export.js +211 -0
- package/src/handler.js +229 -0
- package/src/hash.js +51 -0
- package/src/renderer.js +247 -0
- package/src/server.js +849 -0
- package/src/ui/app.jsx +152 -0
- package/src/ui/components/AnnotationForm.jsx +72 -0
- package/src/ui/components/Content.jsx +334 -0
- package/src/ui/components/ExportMenu.jsx +62 -0
- package/src/ui/components/LeftPanel.jsx +99 -0
- package/src/ui/components/Popover.jsx +94 -0
- package/src/ui/components/ReplyThread.jsx +28 -0
- package/src/ui/components/RightPanel.jsx +171 -0
- package/src/ui/components/SectionApproval.jsx +31 -0
- package/src/ui/components/ThemePicker.jsx +18 -0
- package/src/ui/hooks/useAnnotations.js +160 -0
- package/src/ui/hooks/useClientLibs.js +97 -0
- package/src/ui/hooks/useKeyboard.js +128 -0
- package/src/ui/hooks/useTheme.js +57 -0
- package/src/ui/hooks/useWebSocket.js +126 -0
- package/src/ui/index.html +19 -0
- package/src/ui/state/store.js +76 -0
- package/src/ui/styles/themes.css +1243 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { leftPanelOpen, files, currentFile, currentToc, openAnnotations, sections } from '../state/store.js'
|
|
2
|
+
|
|
3
|
+
export function LeftPanel({ onFileSelect }) {
|
|
4
|
+
const isCollapsed = !leftPanelOpen.value
|
|
5
|
+
|
|
6
|
+
// Count open annotations whose startLine falls within this section's range
|
|
7
|
+
function annotationsInSection(sectionIndex) {
|
|
8
|
+
const toc = currentToc.value
|
|
9
|
+
const section = toc[sectionIndex]
|
|
10
|
+
if (!section) return 0
|
|
11
|
+
const startLine = section.line
|
|
12
|
+
// End line is the start of the next same-or-higher level section, or Infinity
|
|
13
|
+
let endLine = Infinity
|
|
14
|
+
for (let i = sectionIndex + 1; i < toc.length; i++) {
|
|
15
|
+
if (toc[i].level <= section.level) {
|
|
16
|
+
endLine = toc[i].line
|
|
17
|
+
break
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return openAnnotations.value.filter(a => {
|
|
21
|
+
const line = a.selectors?.position?.startLine
|
|
22
|
+
return line != null && line >= startLine && line < endLine
|
|
23
|
+
}).length
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<aside class={`left-panel ${isCollapsed ? 'collapsed' : ''}`}>
|
|
28
|
+
{isCollapsed ? (
|
|
29
|
+
<div class="panel-collapsed-indicator" onClick={() => leftPanelOpen.value = true}>
|
|
30
|
+
<span>☰</span>
|
|
31
|
+
<span class="shortcut-key">[</span>
|
|
32
|
+
{openAnnotations.value.length > 0 && (
|
|
33
|
+
<span class="badge">{openAnnotations.value.length}</span>
|
|
34
|
+
)}
|
|
35
|
+
</div>
|
|
36
|
+
) : (
|
|
37
|
+
<div class="panel-content">
|
|
38
|
+
<div class="panel-header" style="padding: 12px; display: flex; justify-content: space-between; align-items: center">
|
|
39
|
+
<span style="font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
|
|
40
|
+
Files & TOC
|
|
41
|
+
</span>
|
|
42
|
+
<button class="btn btn-sm btn-ghost" onClick={() => leftPanelOpen.value = false}>×</button>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
{/* Files section */}
|
|
46
|
+
{files.value.length > 1 && (
|
|
47
|
+
<div style="padding: 0 8px 8px">
|
|
48
|
+
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); padding: 4px 4px 6px; font-weight: 600">Files</div>
|
|
49
|
+
{files.value.map(f => {
|
|
50
|
+
const path = f.path || f
|
|
51
|
+
const label = f.label || path.replace('.md', '')
|
|
52
|
+
const isActive = path === currentFile.value
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
key={path}
|
|
56
|
+
class={`file-item ${isActive ? 'active' : ''}`}
|
|
57
|
+
onClick={() => onFileSelect(path)}
|
|
58
|
+
>
|
|
59
|
+
<span class="icon">{'\uD83D\uDCC4'}</span>
|
|
60
|
+
<span>{label}</span>
|
|
61
|
+
</div>
|
|
62
|
+
)
|
|
63
|
+
})}
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
|
|
67
|
+
{/* TOC section */}
|
|
68
|
+
<div style="padding: 0 8px">
|
|
69
|
+
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); padding: 4px 4px 6px; font-weight: 600">Sections</div>
|
|
70
|
+
{currentToc.value.length === 0 ? (
|
|
71
|
+
<div style="padding: 8px; color: var(--text-muted); font-size: 13px">No sections</div>
|
|
72
|
+
) : (
|
|
73
|
+
currentToc.value.map((entry, i) => {
|
|
74
|
+
const count = annotationsInSection(i)
|
|
75
|
+
const sec = sections.value.find(s => s.heading === entry.heading && s.level === entry.level)
|
|
76
|
+
const statusDot = sec?.status === 'approved' ? 'dot-approved'
|
|
77
|
+
: sec?.status === 'rejected' ? 'dot-rejected' : ''
|
|
78
|
+
return (
|
|
79
|
+
<div
|
|
80
|
+
key={i}
|
|
81
|
+
class={`toc-item level-${entry.level}`}
|
|
82
|
+
onClick={() => {
|
|
83
|
+
const el = document.querySelector(`[data-source-line="${entry.line}"]`)
|
|
84
|
+
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
{statusDot && <span class={`toc-dot ${statusDot}`} />}
|
|
88
|
+
{entry.heading}
|
|
89
|
+
{count > 0 && <span class="badge" style="margin-left: 6px">{count}</span>}
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
</aside>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'preact/hooks'
|
|
2
|
+
import { AnnotationForm } from './AnnotationForm.jsx'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Draggable annotation popover — appears at the text selection position.
|
|
6
|
+
* No backdrop: uses strong shadow for visual separation.
|
|
7
|
+
* Drag-handle on the header lets users reposition to read content underneath.
|
|
8
|
+
*/
|
|
9
|
+
export function Popover({ x, y, exact, selectors, onSave, onCancel }) {
|
|
10
|
+
const popoverRef = useRef(null)
|
|
11
|
+
const dragRef = useRef({ active: false, startX: 0, startY: 0 })
|
|
12
|
+
|
|
13
|
+
// Compute initial viewport-aware position
|
|
14
|
+
const popoverWidth = 520
|
|
15
|
+
const popoverHeight = 440
|
|
16
|
+
const viewportW = typeof window !== 'undefined' ? window.innerWidth : 800
|
|
17
|
+
const viewportH = typeof window !== 'undefined' ? window.innerHeight : 800
|
|
18
|
+
const spaceBelow = viewportH - y
|
|
19
|
+
const flipAbove = spaceBelow < popoverHeight
|
|
20
|
+
|
|
21
|
+
const initialLeft = Math.max(16, Math.min(x - popoverWidth / 2, viewportW - popoverWidth - 16))
|
|
22
|
+
const initialTop = flipAbove ? Math.max(8, y - popoverHeight - 8) : y
|
|
23
|
+
|
|
24
|
+
const [pos, setPos] = useState({ left: initialLeft, top: initialTop })
|
|
25
|
+
|
|
26
|
+
// Close on Escape
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
function handleKeyDown(e) {
|
|
29
|
+
if (e.key === 'Escape') onCancel()
|
|
30
|
+
}
|
|
31
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
32
|
+
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
33
|
+
}, [onCancel])
|
|
34
|
+
|
|
35
|
+
// Drag: mousemove + mouseup on document for smooth tracking
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
function onMouseMove(e) {
|
|
38
|
+
if (!dragRef.current.active) return
|
|
39
|
+
setPos({
|
|
40
|
+
left: e.clientX - dragRef.current.startX,
|
|
41
|
+
top: e.clientY - dragRef.current.startY,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
function onMouseUp() {
|
|
45
|
+
dragRef.current.active = false
|
|
46
|
+
}
|
|
47
|
+
document.addEventListener('mousemove', onMouseMove)
|
|
48
|
+
document.addEventListener('mouseup', onMouseUp)
|
|
49
|
+
return () => {
|
|
50
|
+
document.removeEventListener('mousemove', onMouseMove)
|
|
51
|
+
document.removeEventListener('mouseup', onMouseUp)
|
|
52
|
+
}
|
|
53
|
+
}, [])
|
|
54
|
+
|
|
55
|
+
function handleDragStart(e) {
|
|
56
|
+
if (e.button !== 0) return
|
|
57
|
+
dragRef.current = {
|
|
58
|
+
active: true,
|
|
59
|
+
startX: e.clientX - pos.left,
|
|
60
|
+
startY: e.clientY - pos.top,
|
|
61
|
+
}
|
|
62
|
+
e.preventDefault()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const style = {
|
|
66
|
+
position: 'fixed',
|
|
67
|
+
left: `${pos.left}px`,
|
|
68
|
+
top: `${pos.top}px`,
|
|
69
|
+
width: `${popoverWidth}px`,
|
|
70
|
+
zIndex: 101,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div class="popover popover--enter" ref={popoverRef} style={style}>
|
|
75
|
+
<div class="popover__header" onMouseDown={handleDragStart}>
|
|
76
|
+
<span class="popover__title">New Annotation</span>
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
class="popover__close"
|
|
80
|
+
onClick={onCancel}
|
|
81
|
+
aria-label="Close"
|
|
82
|
+
>
|
|
83
|
+
×
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
<AnnotationForm
|
|
87
|
+
exact={exact}
|
|
88
|
+
selectors={selectors}
|
|
89
|
+
onSave={onSave}
|
|
90
|
+
onCancel={onCancel}
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function ReplyThread({ replies }) {
|
|
2
|
+
if (!replies || replies.length === 0) return null
|
|
3
|
+
|
|
4
|
+
return (
|
|
5
|
+
<div style="margin-top: 8px">
|
|
6
|
+
{replies.map((reply, i) => (
|
|
7
|
+
<div key={i} class="reply">
|
|
8
|
+
<div class="author">{reply.author}</div>
|
|
9
|
+
<div class="text">{reply.comment}</div>
|
|
10
|
+
<div style="font-size: 10px; color: var(--text-muted); margin-top: 2px">
|
|
11
|
+
{formatTime(reply.created_at)}
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
))}
|
|
15
|
+
</div>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatTime(isoString) {
|
|
20
|
+
if (!isoString) return ''
|
|
21
|
+
try {
|
|
22
|
+
const date = new Date(isoString)
|
|
23
|
+
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) +
|
|
24
|
+
' ' + date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
|
25
|
+
} catch {
|
|
26
|
+
return isoString
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { useState } from 'preact/hooks'
|
|
2
|
+
import { rightPanelOpen, filteredAnnotations, selectedAnnotationId, showResolved,
|
|
3
|
+
filterTag, filterAuthor, uniqueTags, uniqueAuthors, openAnnotations } from '../state/store.js'
|
|
4
|
+
import { AnnotationForm } from './AnnotationForm.jsx'
|
|
5
|
+
import { ReplyThread } from './ReplyThread.jsx'
|
|
6
|
+
|
|
7
|
+
export function RightPanel({ annotationOps }) {
|
|
8
|
+
const isCollapsed = !rightPanelOpen.value
|
|
9
|
+
const [editingId, setEditingId] = useState(null)
|
|
10
|
+
|
|
11
|
+
function handleAnnotationClick(ann) {
|
|
12
|
+
selectedAnnotationId.value = ann.id
|
|
13
|
+
// Scroll to highlight in content
|
|
14
|
+
const highlight = document.querySelector(`[data-highlight-id="${ann.id}"]`)
|
|
15
|
+
highlight?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<aside class={`right-panel ${isCollapsed ? 'collapsed' : ''}`}>
|
|
20
|
+
{isCollapsed ? (
|
|
21
|
+
<div class="panel-collapsed-indicator" onClick={() => rightPanelOpen.value = true}>
|
|
22
|
+
<span>💬</span>
|
|
23
|
+
<span class="shortcut-key">]</span>
|
|
24
|
+
{openAnnotations.value.length > 0 && (
|
|
25
|
+
<span class="badge">{openAnnotations.value.length}</span>
|
|
26
|
+
)}
|
|
27
|
+
</div>
|
|
28
|
+
) : (
|
|
29
|
+
<div class="panel-content">
|
|
30
|
+
{/* Header */}
|
|
31
|
+
<div class="panel-header" style="padding: 12px; display: flex; justify-content: space-between; align-items: center">
|
|
32
|
+
<span style="font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
|
|
33
|
+
Annotations ({openAnnotations.value.length} open)
|
|
34
|
+
</span>
|
|
35
|
+
<button class="btn btn-sm btn-ghost" onClick={() => rightPanelOpen.value = false}>×</button>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
{/* Filters */}
|
|
39
|
+
<div style="padding: 0 12px 8px; display: flex; gap: 6px; flex-wrap: wrap">
|
|
40
|
+
<select
|
|
41
|
+
class="filter-select"
|
|
42
|
+
value={filterTag.value || ''}
|
|
43
|
+
onChange={e => filterTag.value = e.target.value || null}
|
|
44
|
+
style="padding: 3px 6px; font-size: 11px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg-primary); color: var(--text-primary)"
|
|
45
|
+
>
|
|
46
|
+
<option value="">All tags</option>
|
|
47
|
+
{uniqueTags.value.map(t => <option key={t} value={t}>{t}</option>)}
|
|
48
|
+
</select>
|
|
49
|
+
|
|
50
|
+
<select
|
|
51
|
+
value={filterAuthor.value || ''}
|
|
52
|
+
onChange={e => filterAuthor.value = e.target.value || null}
|
|
53
|
+
style="padding: 3px 6px; font-size: 11px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg-primary); color: var(--text-primary)"
|
|
54
|
+
>
|
|
55
|
+
<option value="">All authors</option>
|
|
56
|
+
{uniqueAuthors.value.map(a => <option key={a} value={a}>{a}</option>)}
|
|
57
|
+
</select>
|
|
58
|
+
|
|
59
|
+
<label style="display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--text-muted); cursor: pointer">
|
|
60
|
+
<input type="checkbox" checked={showResolved.value} onChange={e => showResolved.value = e.target.checked} />
|
|
61
|
+
Show resolved
|
|
62
|
+
</label>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
{/* Annotation list */}
|
|
66
|
+
<div style="overflow-y: auto; padding: 0 8px; flex: 1">
|
|
67
|
+
{filteredAnnotations.value.length === 0 ? (
|
|
68
|
+
<div style="padding: 16px; text-align: center; color: var(--text-muted); font-size: 13px">
|
|
69
|
+
No annotations
|
|
70
|
+
</div>
|
|
71
|
+
) : (
|
|
72
|
+
filteredAnnotations.value.map(ann => (
|
|
73
|
+
<div
|
|
74
|
+
key={ann.id}
|
|
75
|
+
data-annotation-id={ann.id}
|
|
76
|
+
class={`annotation-card ${selectedAnnotationId.value === ann.id ? 'selected' : ''} ${ann.status === 'resolved' ? 'resolved' : ''}`}
|
|
77
|
+
onClick={() => handleAnnotationClick(ann)}
|
|
78
|
+
>
|
|
79
|
+
{/* Tag + Author + Time */}
|
|
80
|
+
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 6px">
|
|
81
|
+
<span class={`tag tag-${ann.tag}`}>{ann.tag}</span>
|
|
82
|
+
<span style="font-size: 11px; color: var(--text-muted)">{ann.author}</span>
|
|
83
|
+
{ann.status === 'resolved' && <span style="font-size: 10px; color: var(--status-approved)">✓ resolved</span>}
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{/* Quote */}
|
|
87
|
+
{ann.selectors?.quote?.exact && (
|
|
88
|
+
<div class="quote">{ann.selectors.quote.exact}</div>
|
|
89
|
+
)}
|
|
90
|
+
|
|
91
|
+
{/* Comment */}
|
|
92
|
+
<div style="font-size: 13px; margin-top: 4px">{ann.comment}</div>
|
|
93
|
+
|
|
94
|
+
{/* Actions (when selected) */}
|
|
95
|
+
{selectedAnnotationId.value === ann.id && (
|
|
96
|
+
<div style="margin-top: 8px; display: flex; gap: 6px; flex-wrap: wrap">
|
|
97
|
+
{ann.status === 'open' ? (
|
|
98
|
+
<button class="btn btn-sm" onClick={(e) => { e.stopPropagation(); annotationOps.resolveAnnotation(ann.id) }}>
|
|
99
|
+
Resolve
|
|
100
|
+
</button>
|
|
101
|
+
) : (
|
|
102
|
+
<button class="btn btn-sm" onClick={(e) => { e.stopPropagation(); annotationOps.reopenAnnotation(ann.id) }}>
|
|
103
|
+
Reopen
|
|
104
|
+
</button>
|
|
105
|
+
)}
|
|
106
|
+
<button class="btn btn-sm" onClick={(e) => { e.stopPropagation(); setEditingId(ann.id) }}>
|
|
107
|
+
Edit
|
|
108
|
+
</button>
|
|
109
|
+
<button class="btn btn-sm btn-danger" onClick={(e) => {
|
|
110
|
+
e.stopPropagation()
|
|
111
|
+
if (confirm('Delete this annotation?')) annotationOps.deleteAnnotation(ann.id)
|
|
112
|
+
}}>
|
|
113
|
+
Delete
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
|
|
118
|
+
{/* Edit form */}
|
|
119
|
+
{editingId === ann.id && (
|
|
120
|
+
<AnnotationForm
|
|
121
|
+
annotation={ann}
|
|
122
|
+
onSave={(data) => {
|
|
123
|
+
annotationOps.updateAnnotation(ann.id, data)
|
|
124
|
+
setEditingId(null)
|
|
125
|
+
}}
|
|
126
|
+
onCancel={() => setEditingId(null)}
|
|
127
|
+
/>
|
|
128
|
+
)}
|
|
129
|
+
|
|
130
|
+
{/* Replies */}
|
|
131
|
+
{ann.replies?.length > 0 && (
|
|
132
|
+
<ReplyThread replies={ann.replies} />
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
{/* Reply input (when selected) */}
|
|
136
|
+
{selectedAnnotationId.value === ann.id && (
|
|
137
|
+
<ReplyInput annotationId={ann.id} onReply={annotationOps.addReply} />
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
))
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
</aside>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function ReplyInput({ annotationId, onReply }) {
|
|
150
|
+
const [text, setText] = useState('')
|
|
151
|
+
|
|
152
|
+
function handleSubmit(e) {
|
|
153
|
+
e.preventDefault()
|
|
154
|
+
if (text.trim()) {
|
|
155
|
+
onReply(annotationId, text.trim())
|
|
156
|
+
setText('')
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<form class="reply-input" onSubmit={handleSubmit} onClick={e => e.stopPropagation()}>
|
|
162
|
+
<input
|
|
163
|
+
type="text"
|
|
164
|
+
value={text}
|
|
165
|
+
onInput={e => setText(e.target.value)}
|
|
166
|
+
placeholder="Reply..."
|
|
167
|
+
/>
|
|
168
|
+
<button type="submit" class="btn btn-sm btn-primary" disabled={!text.trim()}>Reply</button>
|
|
169
|
+
</form>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { sections } from '../state/store.js'
|
|
2
|
+
|
|
3
|
+
export function SectionApproval({ heading, annotationOps }) {
|
|
4
|
+
const section = sections.value.find(s => s.heading === heading)
|
|
5
|
+
if (!section) return null
|
|
6
|
+
|
|
7
|
+
const status = section.status
|
|
8
|
+
const computed = section.computed || status
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div class="section-approval" style="display: inline-flex; gap: 4px; margin-left: 8px; vertical-align: middle">
|
|
12
|
+
<button
|
|
13
|
+
class={`btn btn-sm ${computed === 'approved' ? 'section-status approved' : computed === 'indeterminate' ? 'section-status indeterminate' : ''}`}
|
|
14
|
+
onClick={() => status === 'approved' ? annotationOps.resetSection(heading) : annotationOps.approveSection(heading)}
|
|
15
|
+
title={computed === 'indeterminate' ? 'Partially approved — click to approve all' : 'Approve section'}
|
|
16
|
+
>
|
|
17
|
+
{computed === 'indeterminate' ? '\u2500' : '\u2713'}
|
|
18
|
+
</button>
|
|
19
|
+
<button
|
|
20
|
+
class={`btn btn-sm ${computed === 'rejected' ? 'section-status rejected' : ''}`}
|
|
21
|
+
onClick={() => status === 'rejected' ? annotationOps.resetSection(heading) : annotationOps.rejectSection(heading)}
|
|
22
|
+
title="Reject section"
|
|
23
|
+
>
|
|
24
|
+
✗
|
|
25
|
+
</button>
|
|
26
|
+
<span class={`section-status ${computed}`} style="font-size: 10px">
|
|
27
|
+
{computed}
|
|
28
|
+
</span>
|
|
29
|
+
</div>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { theme } from '../state/store.js'
|
|
2
|
+
|
|
3
|
+
export function ThemePicker({ themes, onSelect }) {
|
|
4
|
+
return (
|
|
5
|
+
<div class="theme-picker" title="Switch theme">
|
|
6
|
+
{themes.map(t => (
|
|
7
|
+
<button
|
|
8
|
+
key={t.id}
|
|
9
|
+
class={`theme-swatch ${theme.value === t.id ? 'active' : ''}`}
|
|
10
|
+
style={`background: ${t.color}`}
|
|
11
|
+
onClick={() => onSelect(t.id)}
|
|
12
|
+
title={t.label}
|
|
13
|
+
aria-label={`Switch to ${t.label} theme`}
|
|
14
|
+
/>
|
|
15
|
+
))}
|
|
16
|
+
</div>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { annotations, sections, currentFile, author, driftWarning, sectionLevel } from '../state/store.js'
|
|
2
|
+
|
|
3
|
+
const API_BASE = '' // same origin
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* CRUD operations for annotations and section review status.
|
|
7
|
+
* All methods communicate with the mdprobe HTTP API and update the
|
|
8
|
+
* corresponding signals in the store on success.
|
|
9
|
+
*
|
|
10
|
+
* @returns {object} annotation and section action methods
|
|
11
|
+
*/
|
|
12
|
+
export function useAnnotations() {
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Annotations
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** Fetch all annotations (and sections) for the given file path. */
|
|
18
|
+
async function fetchAnnotations(filePath) {
|
|
19
|
+
const res = await fetch(
|
|
20
|
+
`${API_BASE}/api/annotations?path=${encodeURIComponent(filePath)}`,
|
|
21
|
+
)
|
|
22
|
+
if (!res.ok) throw new Error(`Failed to fetch annotations: ${res.status}`)
|
|
23
|
+
const data = await res.json()
|
|
24
|
+
annotations.value = data.annotations || []
|
|
25
|
+
sections.value = data.sections || []
|
|
26
|
+
if (data.sectionLevel != null) sectionLevel.value = data.sectionLevel
|
|
27
|
+
driftWarning.value = data.drift || false
|
|
28
|
+
return data
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Create a new annotation on the current file. */
|
|
32
|
+
async function createAnnotation({ selectors, comment, tag }) {
|
|
33
|
+
const data = await postAnnotation('add', {
|
|
34
|
+
selectors,
|
|
35
|
+
comment,
|
|
36
|
+
tag,
|
|
37
|
+
author: author.value,
|
|
38
|
+
})
|
|
39
|
+
annotations.value = data.annotations || annotations.value
|
|
40
|
+
return data
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Mark an annotation as resolved. */
|
|
44
|
+
async function resolveAnnotation(id) {
|
|
45
|
+
const data = await postAnnotation('resolve', { id })
|
|
46
|
+
annotations.value = data.annotations || annotations.value
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Re-open a previously resolved annotation. */
|
|
50
|
+
async function reopenAnnotation(id) {
|
|
51
|
+
const data = await postAnnotation('reopen', { id })
|
|
52
|
+
annotations.value = data.annotations || annotations.value
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Update the comment and/or tag of an existing annotation. */
|
|
56
|
+
async function updateAnnotation(id, { comment, tag }) {
|
|
57
|
+
const data = await postAnnotation('update', { id, comment, tag })
|
|
58
|
+
annotations.value = data.annotations || annotations.value
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Delete an annotation by id. */
|
|
62
|
+
async function deleteAnnotation(id) {
|
|
63
|
+
const data = await postAnnotation('delete', { id })
|
|
64
|
+
annotations.value = data.annotations || annotations.value
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Add a threaded reply to an annotation. */
|
|
68
|
+
async function addReply(annotationId, comment) {
|
|
69
|
+
const data = await postAnnotation('reply', {
|
|
70
|
+
id: annotationId,
|
|
71
|
+
author: author.value,
|
|
72
|
+
comment,
|
|
73
|
+
})
|
|
74
|
+
annotations.value = data.annotations || annotations.value
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Sections
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/** Approve a single section by heading text (cascades to children). */
|
|
82
|
+
async function approveSection(heading) {
|
|
83
|
+
const data = await postSection('approve', { heading })
|
|
84
|
+
sections.value = data.sections || sections.value
|
|
85
|
+
if (data.sectionLevel != null) sectionLevel.value = data.sectionLevel
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Reject a single section by heading text (no cascade). */
|
|
89
|
+
async function rejectSection(heading) {
|
|
90
|
+
const data = await postSection('reject', { heading })
|
|
91
|
+
sections.value = data.sections || sections.value
|
|
92
|
+
if (data.sectionLevel != null) sectionLevel.value = data.sectionLevel
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Approve all sections in the current file at once. */
|
|
96
|
+
async function approveAllSections() {
|
|
97
|
+
const data = await postSection('approveAll')
|
|
98
|
+
sections.value = data.sections || sections.value
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Reset a single section to pending. */
|
|
102
|
+
async function resetSection(heading) {
|
|
103
|
+
const data = await postSection('reset', { heading })
|
|
104
|
+
sections.value = data.sections || sections.value
|
|
105
|
+
if (data.sectionLevel != null) sectionLevel.value = data.sectionLevel
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Reset all section review statuses in the current file. */
|
|
109
|
+
async function clearAllSections() {
|
|
110
|
+
const data = await postSection('clearAll')
|
|
111
|
+
sections.value = data.sections || sections.value
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Helpers
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
async function postAnnotation(action, data) {
|
|
119
|
+
const res = await fetch(`${API_BASE}/api/annotations`, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: { 'Content-Type': 'application/json' },
|
|
122
|
+
body: JSON.stringify({
|
|
123
|
+
file: currentFile.value,
|
|
124
|
+
action,
|
|
125
|
+
data,
|
|
126
|
+
}),
|
|
127
|
+
})
|
|
128
|
+
if (!res.ok) throw new Error(`Annotation ${action} failed: ${res.status}`)
|
|
129
|
+
return res.json()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function postSection(action, extra = {}) {
|
|
133
|
+
const res = await fetch(`${API_BASE}/api/sections`, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: { 'Content-Type': 'application/json' },
|
|
136
|
+
body: JSON.stringify({
|
|
137
|
+
file: currentFile.value,
|
|
138
|
+
action,
|
|
139
|
+
...extra,
|
|
140
|
+
}),
|
|
141
|
+
})
|
|
142
|
+
if (!res.ok) throw new Error(`Section ${action} failed: ${res.status}`)
|
|
143
|
+
return res.json()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
fetchAnnotations,
|
|
148
|
+
createAnnotation,
|
|
149
|
+
resolveAnnotation,
|
|
150
|
+
reopenAnnotation,
|
|
151
|
+
updateAnnotation,
|
|
152
|
+
deleteAnnotation,
|
|
153
|
+
addReply,
|
|
154
|
+
approveSection,
|
|
155
|
+
rejectSection,
|
|
156
|
+
resetSection,
|
|
157
|
+
approveAllSections,
|
|
158
|
+
clearAllSections,
|
|
159
|
+
}
|
|
160
|
+
}
|