@dfosco/storyboard-core 1.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/package.json +18 -0
- package/src/comments/api.js +196 -0
- package/src/comments/api.test.js +194 -0
- package/src/comments/auth.js +79 -0
- package/src/comments/auth.test.js +60 -0
- package/src/comments/commentMode.js +63 -0
- package/src/comments/commentMode.test.js +87 -0
- package/src/comments/config.js +43 -0
- package/src/comments/config.test.js +76 -0
- package/src/comments/graphql.js +65 -0
- package/src/comments/graphql.test.js +95 -0
- package/src/comments/index.js +40 -0
- package/src/comments/metadata.js +52 -0
- package/src/comments/metadata.test.js +110 -0
- package/src/comments/queries.js +182 -0
- package/src/comments/ui/CommentOverlay.js +52 -0
- package/src/comments/ui/authModal.js +349 -0
- package/src/comments/ui/commentWindow.js +872 -0
- package/src/comments/ui/commentsDrawer.js +389 -0
- package/src/comments/ui/composer.js +248 -0
- package/src/comments/ui/mount.js +364 -0
- package/src/devtools.js +365 -0
- package/src/devtools.test.js +81 -0
- package/src/dotPath.js +53 -0
- package/src/dotPath.test.js +114 -0
- package/src/hashSubscribe.js +19 -0
- package/src/hashSubscribe.test.js +62 -0
- package/src/hideMode.js +421 -0
- package/src/hideMode.test.js +224 -0
- package/src/index.js +38 -0
- package/src/interceptHideParams.js +35 -0
- package/src/interceptHideParams.test.js +90 -0
- package/src/loader.js +212 -0
- package/src/loader.test.js +232 -0
- package/src/localStorage.js +134 -0
- package/src/localStorage.test.js +148 -0
- package/src/sceneDebug.js +108 -0
- package/src/sceneDebug.test.js +128 -0
- package/src/session.js +76 -0
- package/src/session.test.js +91 -0
- package/src/viewfinder.js +47 -0
- package/src/viewfinder.test.js +87 -0
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dfosco/storyboard-core",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/dfosco/storyboard.git",
|
|
9
|
+
"directory": "packages/core"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./src/index.js",
|
|
16
|
+
"./comments": "./src/comments/index.js"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API for comments — fetch, create, reply, resolve, move, delete, reactions.
|
|
3
|
+
*
|
|
4
|
+
* All functions assume comments config has been initialized via initCommentsConfig().
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { graphql } from './graphql.js'
|
|
8
|
+
import { getCommentsConfig } from './config.js'
|
|
9
|
+
import { parseMetadata, serializeMetadata, updateMetadata } from './metadata.js'
|
|
10
|
+
import {
|
|
11
|
+
SEARCH_DISCUSSION,
|
|
12
|
+
GET_CATEGORY_ID,
|
|
13
|
+
CREATE_DISCUSSION,
|
|
14
|
+
ADD_COMMENT,
|
|
15
|
+
ADD_REPLY,
|
|
16
|
+
UPDATE_COMMENT,
|
|
17
|
+
DELETE_COMMENT,
|
|
18
|
+
ADD_REACTION,
|
|
19
|
+
REMOVE_REACTION,
|
|
20
|
+
LIST_DISCUSSIONS,
|
|
21
|
+
} from './queries.js'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Fetch the discussion for a given route, including all comments and replies.
|
|
25
|
+
* Returns null if no discussion exists for the route.
|
|
26
|
+
* @param {string} route - The route path (e.g. "/Overview")
|
|
27
|
+
* @returns {Promise<object|null>}
|
|
28
|
+
*/
|
|
29
|
+
export async function fetchRouteDiscussion(route) {
|
|
30
|
+
const config = getCommentsConfig()
|
|
31
|
+
const title = `Comments: ${route}`
|
|
32
|
+
const query = `"${title}" in:title repo:${config.repo.owner}/${config.repo.name}`
|
|
33
|
+
|
|
34
|
+
const data = await graphql(SEARCH_DISCUSSION, { query })
|
|
35
|
+
const discussion = data.search?.nodes?.[0]
|
|
36
|
+
if (!discussion) return null
|
|
37
|
+
|
|
38
|
+
// Parse metadata from each comment
|
|
39
|
+
const comments = (discussion.comments?.nodes ?? []).map((comment) => {
|
|
40
|
+
const { meta, text } = parseMetadata(comment.body)
|
|
41
|
+
return {
|
|
42
|
+
...comment,
|
|
43
|
+
meta,
|
|
44
|
+
text,
|
|
45
|
+
replies: (comment.replies?.nodes ?? []).map((reply) => {
|
|
46
|
+
const { meta: replyMeta, text: replyText } = parseMetadata(reply.body)
|
|
47
|
+
return { ...reply, meta: replyMeta, text: replyText }
|
|
48
|
+
}),
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
return { ...discussion, comments }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get the repository ID and discussion category ID.
|
|
57
|
+
* @returns {Promise<{ repositoryId: string, categoryId: string }>}
|
|
58
|
+
*/
|
|
59
|
+
async function getRepoAndCategoryIds() {
|
|
60
|
+
const config = getCommentsConfig()
|
|
61
|
+
const categorySlug = config.discussions.category.toLowerCase().replace(/\s+/g, '-')
|
|
62
|
+
const data = await graphql(GET_CATEGORY_ID, {
|
|
63
|
+
owner: config.repo.owner,
|
|
64
|
+
name: config.repo.name,
|
|
65
|
+
slug: categorySlug,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const repositoryId = data.repository?.id
|
|
69
|
+
let categoryId = data.repository?.discussionCategory?.id
|
|
70
|
+
|
|
71
|
+
// Fallback: search by name in the list
|
|
72
|
+
if (!categoryId) {
|
|
73
|
+
const cat = data.repository?.discussionCategories?.nodes?.find(
|
|
74
|
+
(c) => c.name === config.discussions.category
|
|
75
|
+
)
|
|
76
|
+
categoryId = cat?.id
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!repositoryId || !categoryId) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Could not find repository or discussion category "${config.discussions.category}" in ${config.repo.owner}/${config.repo.name}`
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { repositoryId, categoryId }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Create a new comment on a route. Creates the route discussion if it doesn't exist.
|
|
90
|
+
* @param {string} route - The route path
|
|
91
|
+
* @param {number} x - X coordinate (percentage)
|
|
92
|
+
* @param {number} y - Y coordinate (percentage)
|
|
93
|
+
* @param {string} text - Comment text
|
|
94
|
+
* @returns {Promise<object>} - The created comment
|
|
95
|
+
*/
|
|
96
|
+
export async function createComment(route, x, y, text) {
|
|
97
|
+
let discussion = await fetchRouteDiscussion(route)
|
|
98
|
+
|
|
99
|
+
if (!discussion) {
|
|
100
|
+
// Create the route discussion first
|
|
101
|
+
const { repositoryId, categoryId } = await getRepoAndCategoryIds()
|
|
102
|
+
const title = `Comments: ${route}`
|
|
103
|
+
const body = serializeMetadata({ route, createdAt: new Date().toISOString() }, '')
|
|
104
|
+
const result = await graphql(CREATE_DISCUSSION, { repositoryId, categoryId, title, body })
|
|
105
|
+
discussion = result.createDiscussion.discussion
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const body = serializeMetadata({ x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 }, text)
|
|
109
|
+
const result = await graphql(ADD_COMMENT, { discussionId: discussion.id, body })
|
|
110
|
+
return result.addDiscussionComment.comment
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Reply to an existing comment.
|
|
115
|
+
* @param {string} discussionId - The discussion ID
|
|
116
|
+
* @param {string} commentId - The comment ID to reply to
|
|
117
|
+
* @param {string} text - Reply text
|
|
118
|
+
* @returns {Promise<object>}
|
|
119
|
+
*/
|
|
120
|
+
export async function replyToComment(discussionId, commentId, text) {
|
|
121
|
+
const result = await graphql(ADD_REPLY, { discussionId, replyToId: commentId, body: text })
|
|
122
|
+
return result.addDiscussionComment.comment
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve a comment by updating its metadata.
|
|
127
|
+
* @param {string} commentId - The comment ID
|
|
128
|
+
* @param {string} currentBody - The current comment body
|
|
129
|
+
* @returns {Promise<object>}
|
|
130
|
+
*/
|
|
131
|
+
export async function resolveComment(commentId, currentBody) {
|
|
132
|
+
const newBody = updateMetadata(currentBody, { resolved: true })
|
|
133
|
+
const result = await graphql(UPDATE_COMMENT, { commentId, body: newBody })
|
|
134
|
+
return result.updateDiscussionComment.comment
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Move a comment to new coordinates.
|
|
139
|
+
* @param {string} commentId - The comment ID
|
|
140
|
+
* @param {string} currentBody - The current comment body
|
|
141
|
+
* @param {number} x - New X coordinate (percentage)
|
|
142
|
+
* @param {number} y - New Y coordinate (percentage)
|
|
143
|
+
* @returns {Promise<object>}
|
|
144
|
+
*/
|
|
145
|
+
export async function moveComment(commentId, currentBody, x, y) {
|
|
146
|
+
const newBody = updateMetadata(currentBody, {
|
|
147
|
+
x: Math.round(x * 10) / 10,
|
|
148
|
+
y: Math.round(y * 10) / 10,
|
|
149
|
+
})
|
|
150
|
+
const result = await graphql(UPDATE_COMMENT, { commentId, body: newBody })
|
|
151
|
+
return result.updateDiscussionComment.comment
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Delete a comment (typically a reply).
|
|
156
|
+
* @param {string} commentId - The comment ID to delete
|
|
157
|
+
* @returns {Promise<void>}
|
|
158
|
+
*/
|
|
159
|
+
export async function deleteComment(commentId) {
|
|
160
|
+
await graphql(DELETE_COMMENT, { commentId })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Add a reaction to a comment or reply.
|
|
165
|
+
* @param {string} subjectId - The comment/reply ID
|
|
166
|
+
* @param {string} content - Reaction type (e.g. "THUMBS_UP", "HEART")
|
|
167
|
+
* @returns {Promise<void>}
|
|
168
|
+
*/
|
|
169
|
+
export async function addReaction(subjectId, content) {
|
|
170
|
+
await graphql(ADD_REACTION, { subjectId, content })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Remove a reaction from a comment or reply.
|
|
175
|
+
* @param {string} subjectId - The comment/reply ID
|
|
176
|
+
* @param {string} content - Reaction type
|
|
177
|
+
* @returns {Promise<void>}
|
|
178
|
+
*/
|
|
179
|
+
export async function removeReaction(subjectId, content) {
|
|
180
|
+
await graphql(REMOVE_REACTION, { subjectId, content })
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* List all comment discussions in the configured category.
|
|
185
|
+
* @returns {Promise<object[]>}
|
|
186
|
+
*/
|
|
187
|
+
export async function listDiscussions() {
|
|
188
|
+
const config = getCommentsConfig()
|
|
189
|
+
const { categoryId } = await getRepoAndCategoryIds()
|
|
190
|
+
const data = await graphql(LIST_DISCUSSIONS, {
|
|
191
|
+
owner: config.repo.owner,
|
|
192
|
+
name: config.repo.name,
|
|
193
|
+
categoryId,
|
|
194
|
+
})
|
|
195
|
+
return data.repository?.discussions?.nodes ?? []
|
|
196
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fetchRouteDiscussion,
|
|
3
|
+
createComment,
|
|
4
|
+
replyToComment,
|
|
5
|
+
resolveComment,
|
|
6
|
+
moveComment,
|
|
7
|
+
addReaction,
|
|
8
|
+
removeReaction,
|
|
9
|
+
} from './api.js'
|
|
10
|
+
import { initCommentsConfig } from './config.js'
|
|
11
|
+
import { setToken, clearToken } from './auth.js'
|
|
12
|
+
|
|
13
|
+
// Mock the graphql module
|
|
14
|
+
vi.mock('./graphql.js', () => ({
|
|
15
|
+
graphql: vi.fn(),
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
import { graphql } from './graphql.js'
|
|
19
|
+
|
|
20
|
+
describe('api', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.clearAllMocks()
|
|
23
|
+
clearToken()
|
|
24
|
+
setToken('ghp_test')
|
|
25
|
+
initCommentsConfig({
|
|
26
|
+
comments: {
|
|
27
|
+
repo: { owner: 'dfosco', name: 'storyboard' },
|
|
28
|
+
discussions: { category: 'Storyboard Comments' },
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('fetchRouteDiscussion', () => {
|
|
34
|
+
it('returns null when no discussion found', async () => {
|
|
35
|
+
graphql.mockResolvedValue({ search: { nodes: [] } })
|
|
36
|
+
const result = await fetchRouteDiscussion('/Overview')
|
|
37
|
+
expect(result).toBeNull()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('parses comments with metadata', async () => {
|
|
41
|
+
graphql.mockResolvedValue({
|
|
42
|
+
search: {
|
|
43
|
+
nodes: [
|
|
44
|
+
{
|
|
45
|
+
id: 'D_123',
|
|
46
|
+
title: 'Comments: /Overview',
|
|
47
|
+
comments: {
|
|
48
|
+
nodes: [
|
|
49
|
+
{
|
|
50
|
+
id: 'C_1',
|
|
51
|
+
body: '<!-- sb-meta {"x":10,"y":20} -->\nHello',
|
|
52
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
53
|
+
author: { login: 'dfosco', avatarUrl: 'https://example.com' },
|
|
54
|
+
replies: { nodes: [] },
|
|
55
|
+
reactionGroups: [],
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const result = await fetchRouteDiscussion('/Overview')
|
|
65
|
+
expect(result.id).toBe('D_123')
|
|
66
|
+
expect(result.comments).toHaveLength(1)
|
|
67
|
+
expect(result.comments[0].meta).toEqual({ x: 10, y: 20 })
|
|
68
|
+
expect(result.comments[0].text).toBe('Hello')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('parses replies with metadata', async () => {
|
|
72
|
+
graphql.mockResolvedValue({
|
|
73
|
+
search: {
|
|
74
|
+
nodes: [
|
|
75
|
+
{
|
|
76
|
+
id: 'D_123',
|
|
77
|
+
comments: {
|
|
78
|
+
nodes: [
|
|
79
|
+
{
|
|
80
|
+
id: 'C_1',
|
|
81
|
+
body: '<!-- sb-meta {"x":10,"y":20} -->\nComment',
|
|
82
|
+
replies: {
|
|
83
|
+
nodes: [
|
|
84
|
+
{
|
|
85
|
+
id: 'R_1',
|
|
86
|
+
body: 'A reply',
|
|
87
|
+
author: { login: 'user2' },
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
reactionGroups: [],
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const result = await fetchRouteDiscussion('/Test')
|
|
101
|
+
expect(result.comments[0].replies).toHaveLength(1)
|
|
102
|
+
expect(result.comments[0].replies[0].text).toBe('A reply')
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('createComment', () => {
|
|
107
|
+
it('creates a comment on existing discussion', async () => {
|
|
108
|
+
// First call: search for discussion
|
|
109
|
+
graphql.mockResolvedValueOnce({
|
|
110
|
+
search: {
|
|
111
|
+
nodes: [{ id: 'D_123', comments: { nodes: [] } }],
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
// Second call: add comment
|
|
115
|
+
graphql.mockResolvedValueOnce({
|
|
116
|
+
addDiscussionComment: {
|
|
117
|
+
comment: { id: 'C_new', body: '<!-- sb-meta {"x":50,"y":60} -->\nTest' },
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const result = await createComment('/Overview', 50, 60, 'Test')
|
|
122
|
+
expect(result.id).toBe('C_new')
|
|
123
|
+
expect(graphql).toHaveBeenCalledTimes(2)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('replyToComment', () => {
|
|
128
|
+
it('posts a reply', async () => {
|
|
129
|
+
graphql.mockResolvedValue({
|
|
130
|
+
addDiscussionComment: {
|
|
131
|
+
comment: { id: 'R_1', body: 'Reply text' },
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const result = await replyToComment('D_123', 'C_1', 'Reply text')
|
|
136
|
+
expect(result.id).toBe('R_1')
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('resolveComment', () => {
|
|
141
|
+
it('updates metadata with resolved flag', async () => {
|
|
142
|
+
graphql.mockResolvedValue({
|
|
143
|
+
updateDiscussionComment: {
|
|
144
|
+
comment: { id: 'C_1', body: '<!-- sb-meta {"x":10,"y":20,"resolved":true} -->\nText' },
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const body = '<!-- sb-meta {"x":10,"y":20} -->\nText'
|
|
149
|
+
await resolveComment('C_1', body)
|
|
150
|
+
|
|
151
|
+
const calledBody = graphql.mock.calls[0][1].body
|
|
152
|
+
expect(calledBody).toContain('"resolved":true')
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('moveComment', () => {
|
|
157
|
+
it('updates coordinates in metadata', async () => {
|
|
158
|
+
graphql.mockResolvedValue({
|
|
159
|
+
updateDiscussionComment: {
|
|
160
|
+
comment: { id: 'C_1', body: 'updated' },
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const body = '<!-- sb-meta {"x":10,"y":20} -->\nText'
|
|
165
|
+
await moveComment('C_1', body, 50, 60)
|
|
166
|
+
|
|
167
|
+
const calledBody = graphql.mock.calls[0][1].body
|
|
168
|
+
expect(calledBody).toContain('"x":50')
|
|
169
|
+
expect(calledBody).toContain('"y":60')
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('addReaction', () => {
|
|
174
|
+
it('calls graphql with correct args', async () => {
|
|
175
|
+
graphql.mockResolvedValue({})
|
|
176
|
+
await addReaction('C_1', 'HEART')
|
|
177
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
178
|
+
expect.stringContaining('addReaction'),
|
|
179
|
+
{ subjectId: 'C_1', content: 'HEART' }
|
|
180
|
+
)
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('removeReaction', () => {
|
|
185
|
+
it('calls graphql with correct args', async () => {
|
|
186
|
+
graphql.mockResolvedValue({})
|
|
187
|
+
await removeReaction('C_1', 'THUMBS_UP')
|
|
188
|
+
expect(graphql).toHaveBeenCalledWith(
|
|
189
|
+
expect.stringContaining('removeReaction'),
|
|
190
|
+
{ subjectId: 'C_1', content: 'THUMBS_UP' }
|
|
191
|
+
)
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAT authentication for comments.
|
|
3
|
+
*
|
|
4
|
+
* Stores and retrieves the GitHub PAT from localStorage.
|
|
5
|
+
* Provides validation by fetching the authenticated user.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const STORAGE_KEY = 'sb-comments-token'
|
|
9
|
+
const USER_KEY = 'sb-comments-user'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Get the stored PAT token.
|
|
13
|
+
* @returns {string|null}
|
|
14
|
+
*/
|
|
15
|
+
export function getToken() {
|
|
16
|
+
try {
|
|
17
|
+
return localStorage.getItem(STORAGE_KEY)
|
|
18
|
+
} catch {
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Store a PAT token.
|
|
25
|
+
* @param {string} token
|
|
26
|
+
*/
|
|
27
|
+
export function setToken(token) {
|
|
28
|
+
localStorage.setItem(STORAGE_KEY, token)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Remove the stored PAT token and user.
|
|
33
|
+
*/
|
|
34
|
+
export function clearToken() {
|
|
35
|
+
localStorage.removeItem(STORAGE_KEY)
|
|
36
|
+
localStorage.removeItem(USER_KEY)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get the cached authenticated user info.
|
|
41
|
+
* @returns {{ login: string, avatarUrl: string }|null}
|
|
42
|
+
*/
|
|
43
|
+
export function getCachedUser() {
|
|
44
|
+
try {
|
|
45
|
+
const raw = localStorage.getItem(USER_KEY)
|
|
46
|
+
return raw ? JSON.parse(raw) : null
|
|
47
|
+
} catch {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Validate a PAT by fetching the authenticated user from GitHub.
|
|
54
|
+
* Caches the result in localStorage on success.
|
|
55
|
+
* @param {string} token - GitHub PAT to validate
|
|
56
|
+
* @returns {Promise<{ login: string, avatarUrl: string }>}
|
|
57
|
+
*/
|
|
58
|
+
export async function validateToken(token) {
|
|
59
|
+
const res = await fetch('https://api.github.com/user', {
|
|
60
|
+
headers: { Authorization: `bearer ${token}` },
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
throw new Error('Invalid token — GitHub returned ' + res.status)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const user = await res.json()
|
|
68
|
+
const userInfo = { login: user.login, avatarUrl: user.avatar_url }
|
|
69
|
+
localStorage.setItem(USER_KEY, JSON.stringify(userInfo))
|
|
70
|
+
return userInfo
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check whether the user is currently authenticated.
|
|
75
|
+
* @returns {boolean}
|
|
76
|
+
*/
|
|
77
|
+
export function isAuthenticated() {
|
|
78
|
+
return getToken() !== null
|
|
79
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { getToken, setToken, clearToken, getCachedUser, isAuthenticated } from './auth.js'
|
|
2
|
+
|
|
3
|
+
describe('auth token management', () => {
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
localStorage.clear()
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
it('returns null when no token is stored', () => {
|
|
9
|
+
expect(getToken()).toBeNull()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('stores and retrieves a token', () => {
|
|
13
|
+
setToken('ghp_test123')
|
|
14
|
+
expect(getToken()).toBe('ghp_test123')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('clears token and user', () => {
|
|
18
|
+
setToken('ghp_test123')
|
|
19
|
+
localStorage.setItem('sb-comments-user', JSON.stringify({ login: 'test' }))
|
|
20
|
+
clearToken()
|
|
21
|
+
expect(getToken()).toBeNull()
|
|
22
|
+
expect(getCachedUser()).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('getCachedUser', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
localStorage.clear()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('returns null when no user is cached', () => {
|
|
32
|
+
expect(getCachedUser()).toBeNull()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('returns cached user info', () => {
|
|
36
|
+
const user = { login: 'dfosco', avatarUrl: 'https://example.com/avatar.png' }
|
|
37
|
+
localStorage.setItem('sb-comments-user', JSON.stringify(user))
|
|
38
|
+
expect(getCachedUser()).toEqual(user)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('returns null on invalid JSON', () => {
|
|
42
|
+
localStorage.setItem('sb-comments-user', 'not json')
|
|
43
|
+
expect(getCachedUser()).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('isAuthenticated', () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
localStorage.clear()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('returns false when no token', () => {
|
|
53
|
+
expect(isAuthenticated()).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('returns true when token exists', () => {
|
|
57
|
+
setToken('ghp_abc')
|
|
58
|
+
expect(isAuthenticated()).toBe(true)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comment mode state — manages the toggle between normal and comment mode.
|
|
3
|
+
*
|
|
4
|
+
* When active: cursor changes to crosshair, comment pins are visible,
|
|
5
|
+
* clicking places a new comment.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { isCommentsEnabled } from './config.js'
|
|
9
|
+
import { isAuthenticated } from './auth.js'
|
|
10
|
+
|
|
11
|
+
let _active = false
|
|
12
|
+
const _listeners = new Set()
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Check whether comment mode is currently active.
|
|
16
|
+
*/
|
|
17
|
+
export function isCommentModeActive() {
|
|
18
|
+
return _active
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Toggle comment mode on/off.
|
|
23
|
+
* Only activates if comments are enabled and user is authenticated.
|
|
24
|
+
* @returns {boolean} The new state
|
|
25
|
+
*/
|
|
26
|
+
export function toggleCommentMode() {
|
|
27
|
+
if (!isCommentsEnabled()) {
|
|
28
|
+
console.warn('[storyboard] Comments not enabled — check storyboard.config.json')
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!_active && !isAuthenticated()) {
|
|
33
|
+
console.warn('[storyboard] Sign in first to use comments')
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_active = !_active
|
|
38
|
+
_notify()
|
|
39
|
+
return _active
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Explicitly set comment mode.
|
|
44
|
+
* @param {boolean} active
|
|
45
|
+
*/
|
|
46
|
+
export function setCommentMode(active) {
|
|
47
|
+
_active = active
|
|
48
|
+
_notify()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Subscribe to comment mode changes.
|
|
53
|
+
* @param {(active: boolean) => void} callback
|
|
54
|
+
* @returns {() => void} Unsubscribe function
|
|
55
|
+
*/
|
|
56
|
+
export function subscribeToCommentMode(callback) {
|
|
57
|
+
_listeners.add(callback)
|
|
58
|
+
return () => _listeners.delete(callback)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function _notify() {
|
|
62
|
+
for (const cb of _listeners) cb(_active)
|
|
63
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isCommentModeActive,
|
|
3
|
+
toggleCommentMode,
|
|
4
|
+
setCommentMode,
|
|
5
|
+
subscribeToCommentMode,
|
|
6
|
+
} from './commentMode.js'
|
|
7
|
+
import { initCommentsConfig } from './config.js'
|
|
8
|
+
import { setToken, clearToken } from './auth.js'
|
|
9
|
+
|
|
10
|
+
describe('commentMode', () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
// Reset state
|
|
13
|
+
setCommentMode(false)
|
|
14
|
+
clearToken()
|
|
15
|
+
initCommentsConfig(null)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('starts inactive', () => {
|
|
19
|
+
setCommentMode(false)
|
|
20
|
+
expect(isCommentModeActive()).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('setCommentMode activates and deactivates', () => {
|
|
24
|
+
setCommentMode(true)
|
|
25
|
+
expect(isCommentModeActive()).toBe(true)
|
|
26
|
+
setCommentMode(false)
|
|
27
|
+
expect(isCommentModeActive()).toBe(false)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('toggleCommentMode returns false when comments not enabled', () => {
|
|
31
|
+
const result = toggleCommentMode()
|
|
32
|
+
expect(result).toBe(false)
|
|
33
|
+
expect(isCommentModeActive()).toBe(false)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('toggleCommentMode returns false when not authenticated', () => {
|
|
37
|
+
initCommentsConfig({
|
|
38
|
+
comments: { repo: { owner: 'o', name: 'r' } },
|
|
39
|
+
})
|
|
40
|
+
const result = toggleCommentMode()
|
|
41
|
+
expect(result).toBe(false)
|
|
42
|
+
expect(isCommentModeActive()).toBe(false)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('toggleCommentMode activates when enabled and authenticated', () => {
|
|
46
|
+
initCommentsConfig({
|
|
47
|
+
comments: { repo: { owner: 'o', name: 'r' } },
|
|
48
|
+
})
|
|
49
|
+
setToken('ghp_test')
|
|
50
|
+
const result = toggleCommentMode()
|
|
51
|
+
expect(result).toBe(true)
|
|
52
|
+
expect(isCommentModeActive()).toBe(true)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('toggleCommentMode toggles off when active', () => {
|
|
56
|
+
initCommentsConfig({
|
|
57
|
+
comments: { repo: { owner: 'o', name: 'r' } },
|
|
58
|
+
})
|
|
59
|
+
setToken('ghp_test')
|
|
60
|
+
toggleCommentMode() // on
|
|
61
|
+
const result = toggleCommentMode() // off
|
|
62
|
+
expect(result).toBe(false)
|
|
63
|
+
expect(isCommentModeActive()).toBe(false)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('subscribeToCommentMode calls callback on changes', () => {
|
|
67
|
+
const calls = []
|
|
68
|
+
subscribeToCommentMode((active) => calls.push(active))
|
|
69
|
+
|
|
70
|
+
setCommentMode(true)
|
|
71
|
+
setCommentMode(false)
|
|
72
|
+
setCommentMode(true)
|
|
73
|
+
|
|
74
|
+
expect(calls).toEqual([true, false, true])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('subscribeToCommentMode returns unsubscribe function', () => {
|
|
78
|
+
const calls = []
|
|
79
|
+
const unsub = subscribeToCommentMode((active) => calls.push(active))
|
|
80
|
+
|
|
81
|
+
setCommentMode(true)
|
|
82
|
+
unsub()
|
|
83
|
+
setCommentMode(false)
|
|
84
|
+
|
|
85
|
+
expect(calls).toEqual([true])
|
|
86
|
+
})
|
|
87
|
+
})
|