@dfosco/storyboard-core 1.10.0 → 1.11.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/package.json +1 -1
- package/src/comments/api.js +44 -0
- package/src/comments/api.test.js +88 -0
- package/src/comments/commentCache.js +55 -0
- package/src/comments/commentCache.test.js +48 -0
- package/src/comments/index.js +5 -0
- package/src/comments/queries.js +64 -1
- package/src/comments/ui/authModal.js +10 -11
- package/src/comments/ui/commentWindow.js +443 -639
- package/src/comments/ui/comments.css +111 -7
- package/src/comments/ui/commentsDrawer.js +135 -141
- package/src/comments/ui/composer.js +6 -8
- package/src/comments/ui/mount.js +127 -28
package/package.json
CHANGED
package/src/comments/api.js
CHANGED
|
@@ -9,6 +9,8 @@ import { getCommentsConfig } from './config.js'
|
|
|
9
9
|
import { parseMetadata, serializeMetadata, updateMetadata } from './metadata.js'
|
|
10
10
|
import {
|
|
11
11
|
SEARCH_DISCUSSION,
|
|
12
|
+
SEARCH_DISCUSSION_LIGHTWEIGHT,
|
|
13
|
+
GET_COMMENT_DETAIL,
|
|
12
14
|
GET_CATEGORY_ID,
|
|
13
15
|
CREATE_DISCUSSION,
|
|
14
16
|
ADD_COMMENT,
|
|
@@ -52,6 +54,48 @@ export async function fetchRouteDiscussion(route) {
|
|
|
52
54
|
return { ...discussion, comments }
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Fetch lightweight comment listing for a route (pins only — no replies, no reactions).
|
|
59
|
+
* Returns null if no discussion exists for the route.
|
|
60
|
+
* @param {string} route - The route path (e.g. "/Overview")
|
|
61
|
+
* @returns {Promise<object|null>}
|
|
62
|
+
*/
|
|
63
|
+
export async function fetchRouteCommentsSummary(route) {
|
|
64
|
+
const config = getCommentsConfig()
|
|
65
|
+
const title = `Comments: ${route}`
|
|
66
|
+
const query = `"${title}" in:title repo:${config.repo.owner}/${config.repo.name}`
|
|
67
|
+
|
|
68
|
+
const data = await graphql(SEARCH_DISCUSSION_LIGHTWEIGHT, { query })
|
|
69
|
+
const discussion = data.search?.nodes?.[0]
|
|
70
|
+
if (!discussion) return null
|
|
71
|
+
|
|
72
|
+
const comments = (discussion.comments?.nodes ?? []).map((comment) => {
|
|
73
|
+
const { meta, text } = parseMetadata(comment.body)
|
|
74
|
+
return { ...comment, meta, text }
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
return { ...discussion, comments }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Fetch full detail for a single comment (replies, reactions, etc.).
|
|
82
|
+
* @param {string} commentId - The GraphQL node ID of the comment
|
|
83
|
+
* @returns {Promise<object|null>}
|
|
84
|
+
*/
|
|
85
|
+
export async function fetchCommentDetail(commentId) {
|
|
86
|
+
const data = await graphql(GET_COMMENT_DETAIL, { id: commentId })
|
|
87
|
+
const node = data.node
|
|
88
|
+
if (!node) return null
|
|
89
|
+
|
|
90
|
+
const { meta, text } = parseMetadata(node.body)
|
|
91
|
+
const replies = (node.replies?.nodes ?? []).map((reply) => {
|
|
92
|
+
const { meta: replyMeta, text: replyText } = parseMetadata(reply.body)
|
|
93
|
+
return { ...reply, meta: replyMeta, text: replyText }
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
return { ...node, meta, text, replies }
|
|
97
|
+
}
|
|
98
|
+
|
|
55
99
|
/**
|
|
56
100
|
* Get the repository ID and discussion category ID.
|
|
57
101
|
* @returns {Promise<{ repositoryId: string, categoryId: string }>}
|
package/src/comments/api.test.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
fetchRouteDiscussion,
|
|
3
|
+
fetchRouteCommentsSummary,
|
|
4
|
+
fetchCommentDetail,
|
|
3
5
|
createComment,
|
|
4
6
|
replyToComment,
|
|
5
7
|
resolveComment,
|
|
@@ -103,6 +105,92 @@ describe('api', () => {
|
|
|
103
105
|
})
|
|
104
106
|
})
|
|
105
107
|
|
|
108
|
+
describe('fetchRouteCommentsSummary', () => {
|
|
109
|
+
it('returns null when no discussion found', async () => {
|
|
110
|
+
graphql.mockResolvedValue({ search: { nodes: [] } })
|
|
111
|
+
const result = await fetchRouteCommentsSummary('/Overview')
|
|
112
|
+
expect(result).toBeNull()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('parses comments with metadata (no replies/reactions)', async () => {
|
|
116
|
+
graphql.mockResolvedValue({
|
|
117
|
+
search: {
|
|
118
|
+
nodes: [
|
|
119
|
+
{
|
|
120
|
+
id: 'D_123',
|
|
121
|
+
title: 'Comments: /Overview',
|
|
122
|
+
comments: {
|
|
123
|
+
nodes: [
|
|
124
|
+
{
|
|
125
|
+
id: 'C_1',
|
|
126
|
+
body: '<!-- sb-meta {"x":10,"y":20} -->\nHello',
|
|
127
|
+
author: { login: 'dfosco', avatarUrl: 'https://example.com' },
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const result = await fetchRouteCommentsSummary('/Overview')
|
|
137
|
+
expect(result.id).toBe('D_123')
|
|
138
|
+
expect(result.comments).toHaveLength(1)
|
|
139
|
+
expect(result.comments[0].meta).toEqual({ x: 10, y: 20 })
|
|
140
|
+
expect(result.comments[0].text).toBe('Hello')
|
|
141
|
+
// Should use lightweight query (no replies/reactions in query)
|
|
142
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
143
|
+
expect.stringContaining('SearchDiscussionLightweight'),
|
|
144
|
+
expect.any(Object)
|
|
145
|
+
)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('fetchCommentDetail', () => {
|
|
150
|
+
it('returns null when node not found', async () => {
|
|
151
|
+
graphql.mockResolvedValue({ node: null })
|
|
152
|
+
const result = await fetchCommentDetail('C_999')
|
|
153
|
+
expect(result).toBeNull()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('returns full comment with replies and reactions', async () => {
|
|
157
|
+
graphql.mockResolvedValue({
|
|
158
|
+
node: {
|
|
159
|
+
id: 'C_1',
|
|
160
|
+
body: '<!-- sb-meta {"x":10,"y":20} -->\nHello',
|
|
161
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
162
|
+
author: { login: 'dfosco', avatarUrl: 'https://example.com' },
|
|
163
|
+
replies: {
|
|
164
|
+
nodes: [
|
|
165
|
+
{
|
|
166
|
+
id: 'R_1',
|
|
167
|
+
body: 'A reply',
|
|
168
|
+
createdAt: '2026-01-02T00:00:00Z',
|
|
169
|
+
author: { login: 'user2', avatarUrl: 'https://example.com/2' },
|
|
170
|
+
reactionGroups: [],
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
reactionGroups: [
|
|
175
|
+
{ content: 'HEART', users: { totalCount: 1 }, viewerHasReacted: true },
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const result = await fetchCommentDetail('C_1')
|
|
181
|
+
expect(result.id).toBe('C_1')
|
|
182
|
+
expect(result.meta).toEqual({ x: 10, y: 20 })
|
|
183
|
+
expect(result.text).toBe('Hello')
|
|
184
|
+
expect(result.replies).toHaveLength(1)
|
|
185
|
+
expect(result.replies[0].text).toBe('A reply')
|
|
186
|
+
expect(result.reactionGroups).toHaveLength(1)
|
|
187
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
188
|
+
expect.stringContaining('GetCommentDetail'),
|
|
189
|
+
{ id: 'C_1' }
|
|
190
|
+
)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
106
194
|
describe('createComment', () => {
|
|
107
195
|
it('creates a comment on existing discussion', async () => {
|
|
108
196
|
// First call: search for discussion
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* localStorage cache for comment pin data.
|
|
3
|
+
*
|
|
4
|
+
* Stores lightweight comment listings per route so pins render instantly
|
|
5
|
+
* on repeat visits without hitting the GitHub API.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const CACHE_PREFIX = 'sb-comments:'
|
|
9
|
+
const TTL_MS = 2 * 60 * 1000 // 2 minutes
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Get cached comment listing for a route.
|
|
13
|
+
* Returns null if cache is missing or expired.
|
|
14
|
+
* @param {string} route
|
|
15
|
+
* @returns {object|null} - Cached discussion object (with .comments array)
|
|
16
|
+
*/
|
|
17
|
+
export function getCachedComments(route) {
|
|
18
|
+
try {
|
|
19
|
+
const raw = localStorage.getItem(CACHE_PREFIX + route)
|
|
20
|
+
if (!raw) return null
|
|
21
|
+
const entry = JSON.parse(raw)
|
|
22
|
+
if (Date.now() - entry.ts > TTL_MS) {
|
|
23
|
+
localStorage.removeItem(CACHE_PREFIX + route)
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
return entry.data
|
|
27
|
+
} catch {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Store comment listing in cache for a route.
|
|
34
|
+
* @param {string} route
|
|
35
|
+
* @param {object} data - Discussion object with .comments array
|
|
36
|
+
*/
|
|
37
|
+
export function setCachedComments(route, data) {
|
|
38
|
+
try {
|
|
39
|
+
localStorage.setItem(CACHE_PREFIX + route, JSON.stringify({ ts: Date.now(), data }))
|
|
40
|
+
} catch {
|
|
41
|
+
// localStorage full or unavailable — ignore
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Clear cached comments for a specific route.
|
|
47
|
+
* @param {string} route
|
|
48
|
+
*/
|
|
49
|
+
export function clearCachedComments(route) {
|
|
50
|
+
try {
|
|
51
|
+
localStorage.removeItem(CACHE_PREFIX + route)
|
|
52
|
+
} catch {
|
|
53
|
+
// ignore
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { getCachedComments, setCachedComments, clearCachedComments } from './commentCache.js'
|
|
2
|
+
|
|
3
|
+
describe('commentCache', () => {
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
localStorage.clear()
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
it('returns null for uncached route', () => {
|
|
9
|
+
expect(getCachedComments('/Overview')).toBeNull()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('stores and retrieves cached comments', () => {
|
|
13
|
+
const data = { id: 'D_1', comments: [{ id: 'C_1', meta: { x: 10, y: 20 } }] }
|
|
14
|
+
setCachedComments('/Overview', data)
|
|
15
|
+
const cached = getCachedComments('/Overview')
|
|
16
|
+
expect(cached).toEqual(data)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('returns null for expired cache', () => {
|
|
20
|
+
const data = { id: 'D_1', comments: [] }
|
|
21
|
+
// Manually write expired entry
|
|
22
|
+
localStorage.setItem('sb-comments:/Overview', JSON.stringify({
|
|
23
|
+
ts: Date.now() - 3 * 60 * 1000, // 3 min ago (exceeds 2-min TTL)
|
|
24
|
+
data,
|
|
25
|
+
}))
|
|
26
|
+
expect(getCachedComments('/Overview')).toBeNull()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('clears cached comments for a route', () => {
|
|
30
|
+
setCachedComments('/Overview', { id: 'D_1', comments: [] })
|
|
31
|
+
clearCachedComments('/Overview')
|
|
32
|
+
expect(getCachedComments('/Overview')).toBeNull()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('does not affect other routes when clearing', () => {
|
|
36
|
+
const data1 = { id: 'D_1', comments: [] }
|
|
37
|
+
const data2 = { id: 'D_2', comments: [] }
|
|
38
|
+
setCachedComments('/Overview', data1)
|
|
39
|
+
setCachedComments('/Issues', data2)
|
|
40
|
+
clearCachedComments('/Overview')
|
|
41
|
+
expect(getCachedComments('/Issues')).toEqual(data2)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('handles corrupted localStorage gracefully', () => {
|
|
45
|
+
localStorage.setItem('sb-comments:/Overview', 'not-json')
|
|
46
|
+
expect(getCachedComments('/Overview')).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
})
|
package/src/comments/index.js
CHANGED
|
@@ -17,6 +17,8 @@ export { parseMetadata, serializeMetadata, updateMetadata } from './metadata.js'
|
|
|
17
17
|
// API
|
|
18
18
|
export {
|
|
19
19
|
fetchRouteDiscussion,
|
|
20
|
+
fetchRouteCommentsSummary,
|
|
21
|
+
fetchCommentDetail,
|
|
20
22
|
createComment,
|
|
21
23
|
replyToComment,
|
|
22
24
|
resolveComment,
|
|
@@ -30,6 +32,9 @@ export {
|
|
|
30
32
|
listDiscussions,
|
|
31
33
|
} from './api.js'
|
|
32
34
|
|
|
35
|
+
// Cache
|
|
36
|
+
export { getCachedComments, setCachedComments, clearCachedComments } from './commentCache.js'
|
|
37
|
+
|
|
33
38
|
// GraphQL client (for advanced use)
|
|
34
39
|
export { graphql } from './graphql.js'
|
|
35
40
|
|
package/src/comments/queries.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* GraphQL query and mutation strings for the comments system.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
/** Search for a discussion by title
|
|
5
|
+
/** Search for a discussion by title — full data (used by drawer) */
|
|
6
6
|
export const SEARCH_DISCUSSION = `
|
|
7
7
|
query SearchDiscussion($query: String!) {
|
|
8
8
|
search(query: $query, type: DISCUSSION, first: 1) {
|
|
@@ -50,6 +50,69 @@ export const SEARCH_DISCUSSION = `
|
|
|
50
50
|
}
|
|
51
51
|
`
|
|
52
52
|
|
|
53
|
+
/** Search for a discussion — lightweight (pins only: id, body for metadata, author) */
|
|
54
|
+
export const SEARCH_DISCUSSION_LIGHTWEIGHT = `
|
|
55
|
+
query SearchDiscussionLightweight($query: String!) {
|
|
56
|
+
search(query: $query, type: DISCUSSION, first: 1) {
|
|
57
|
+
nodes {
|
|
58
|
+
... on Discussion {
|
|
59
|
+
id
|
|
60
|
+
title
|
|
61
|
+
url
|
|
62
|
+
comments(first: 100) {
|
|
63
|
+
nodes {
|
|
64
|
+
id
|
|
65
|
+
body
|
|
66
|
+
author {
|
|
67
|
+
login
|
|
68
|
+
avatarUrl
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
`
|
|
77
|
+
|
|
78
|
+
/** Fetch full detail for a single comment by node ID */
|
|
79
|
+
export const GET_COMMENT_DETAIL = `
|
|
80
|
+
query GetCommentDetail($id: ID!) {
|
|
81
|
+
node(id: $id) {
|
|
82
|
+
... on DiscussionComment {
|
|
83
|
+
id
|
|
84
|
+
body
|
|
85
|
+
createdAt
|
|
86
|
+
author {
|
|
87
|
+
login
|
|
88
|
+
avatarUrl
|
|
89
|
+
}
|
|
90
|
+
replies(first: 50) {
|
|
91
|
+
nodes {
|
|
92
|
+
id
|
|
93
|
+
body
|
|
94
|
+
createdAt
|
|
95
|
+
author {
|
|
96
|
+
login
|
|
97
|
+
avatarUrl
|
|
98
|
+
}
|
|
99
|
+
reactionGroups {
|
|
100
|
+
content
|
|
101
|
+
users(first: 0) { totalCount }
|
|
102
|
+
viewerHasReacted
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
reactionGroups {
|
|
107
|
+
content
|
|
108
|
+
users(first: 0) { totalCount }
|
|
109
|
+
viewerHasReacted
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
`
|
|
115
|
+
|
|
53
116
|
/** Get the discussion category ID by name */
|
|
54
117
|
export const GET_CATEGORY_ID = `
|
|
55
118
|
query GetCategoryId($owner: String!, $name: String!, $slug: String!) {
|
|
@@ -21,30 +21,29 @@ export function openAuthModal() {
|
|
|
21
21
|
const backdrop = document.createElement('div')
|
|
22
22
|
backdrop.id = MODAL_ID
|
|
23
23
|
backdrop.className = 'sb-auth-backdrop fixed top-0 right-0 bottom-0 left-0 flex items-center justify-center sans-serif'
|
|
24
|
-
backdrop.style.cssText = 'z-index:100000;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px);'
|
|
25
24
|
|
|
26
25
|
backdrop.innerHTML = `
|
|
27
|
-
<div class="sb-bg ba sb-b-default br3 sb-shadow sb-fg overflow-hidden"
|
|
26
|
+
<div class="sb-auth-modal sb-bg ba sb-b-default br3 sb-shadow sb-fg overflow-hidden" x-data="sbAuthModal">
|
|
28
27
|
<div class="flex items-center justify-between ph4 pv3 bb sb-b-muted">
|
|
29
28
|
<h2 class="ma0 f5 fw6 sb-fg">Sign in for comments</h2>
|
|
30
|
-
<button class="flex items-center justify-center bg-transparent bn br2 sb-fg-muted pointer
|
|
29
|
+
<button class="flex items-center justify-center bg-transparent bn br2 sb-fg-muted pointer sb-close-btn" @click="close()" aria-label="Close">×</button>
|
|
31
30
|
</div>
|
|
32
31
|
<div class="pa4">
|
|
33
|
-
<p class="ma0 mb3 lh-copy sb-fg-muted
|
|
32
|
+
<p class="ma0 mb3 lh-copy sb-fg-muted sb-f-sm">
|
|
34
33
|
Enter a <a class="sb-fg-accent no-underline" href="https://github.com/settings/tokens/new" target="_blank" rel="noopener">GitHub Personal Access Token</a>
|
|
35
34
|
to leave comments on this prototype. Your token is stored locally in your browser.
|
|
36
35
|
</p>
|
|
37
|
-
<label class="db mb1 fw5 sb-fg
|
|
38
|
-
<input class="sb-input w-100 ph3 pv2 br2 f6 code db"
|
|
36
|
+
<label class="db mb1 fw5 sb-fg sb-f-sm" for="sb-auth-token-input">Personal Access Token</label>
|
|
37
|
+
<input class="sb-input w-100 ph3 pv2 br2 f6 code db" id="sb-auth-token-input" type="password"
|
|
39
38
|
placeholder="ghp_xxxxxxxxxxxx" autocomplete="off" spellcheck="false"
|
|
40
39
|
x-model="token" @keydown.enter="submit()" />
|
|
41
|
-
<div class="mt2 ph3 pv2 sb-bg-inset ba sb-b-muted br2 f7 sb-fg-muted lh-copy">Required scopes: <code class="dib ph1 sb-bg-muted br1 code sb-fg
|
|
40
|
+
<div class="mt2 ph3 pv2 sb-bg-inset ba sb-b-muted br2 f7 sb-fg-muted lh-copy">Required scopes: <code class="dib ph1 sb-bg-muted br1 code sb-fg sb-code-badge">repo</code> <code class="dib ph1 sb-bg-muted br1 code sb-fg sb-code-badge">read:user</code></div>
|
|
42
41
|
<template x-if="error">
|
|
43
|
-
<div class="mt2 ph3 pv2 br2 sb-fg-danger
|
|
42
|
+
<div class="mt2 ph3 pv2 br2 sb-fg-danger sb-f-sm sb-error-alert" x-text="error"></div>
|
|
44
43
|
</template>
|
|
45
44
|
<template x-if="user">
|
|
46
45
|
<div class="flex items-center pv1">
|
|
47
|
-
<img class="br-100 ba sb-b-default mr3
|
|
46
|
+
<img class="br-100 ba sb-b-default mr3 sb-avatar-lg" :src="user.avatarUrl" :alt="user.login" />
|
|
48
47
|
<div class="f6 sb-fg">
|
|
49
48
|
<span x-text="user.login"></span>
|
|
50
49
|
<span class="db f7 sb-fg-success mt1">✓ Signed in</span>
|
|
@@ -53,8 +52,8 @@ export function openAuthModal() {
|
|
|
53
52
|
</template>
|
|
54
53
|
</div>
|
|
55
54
|
<div class="flex items-center justify-end ph4 pv3 bt sb-b-muted">
|
|
56
|
-
<button class="sb-btn-cancel ph3 pv1 br2 fw5 sans-serif pointer mr2
|
|
57
|
-
<button class="sb-btn-success ph3 pv1 br2 fw5 sans-serif pointer bn
|
|
55
|
+
<button class="sb-btn-cancel ph3 pv1 br2 fw5 sans-serif pointer mr2 sb-f-sm" @click="close()">Cancel</button>
|
|
56
|
+
<button class="sb-btn-success ph3 pv1 br2 fw5 sans-serif pointer bn sb-f-sm" :disabled="submitting"
|
|
58
57
|
@click="user ? done() : submit()" x-text="user ? 'Done' : (submitting ? 'Validating…' : 'Sign in')">
|
|
59
58
|
Sign in
|
|
60
59
|
</button>
|