design-share 0.2.2 → 0.3.1
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 +4 -0
- package/bin/design-share.js +8 -1
- package/dist/DesignShare.app/Contents/CodeResources +0 -0
- package/dist/DesignShare.app/Contents/Info.plist +2 -2
- package/dist/DesignShare.app/Contents/MacOS/DesignShare +0 -0
- package/package.json +7 -1
- package/public/app.js +184 -6
- package/public/index.html +16 -0
- package/public/style.css +165 -5
- package/src/github.js +31 -0
- package/src/server.js +34 -1
- package/src/state.js +80 -1
package/README.md
CHANGED
|
@@ -62,3 +62,7 @@ Quitting with Ctrl+C in the terminal is respected as a deliberate stop. Closing
|
|
|
62
62
|
## Requirements
|
|
63
63
|
|
|
64
64
|
Node 18 or newer, git, and a repo with an origin remote (solo local mode works without one).
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
Built by [Callebe](https://github.com/callebe01). If design-share saves your team some friction, you can [buy me a coffee](https://buymeacoffee.com/callebe).
|
package/bin/design-share.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
3
|
+
if (major < 18) {
|
|
4
|
+
console.error(`\n design-share needs Node 18 or newer. You are on ${process.versions.node}.`);
|
|
5
|
+
console.error(' Update at https://nodejs.org and run npx design-share again.\n');
|
|
6
|
+
process.exit(1);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { run } = await import('../src/cli.js');
|
|
3
10
|
|
|
4
11
|
run(process.argv.slice(2)).catch((err) => {
|
|
5
12
|
console.error(`\n \x1b[31m✕\x1b[0m ${err.message}`);
|
|
Binary file
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
<key>CFBundleIdentifier</key>
|
|
10
10
|
<string>com.design-share.menubar</string>
|
|
11
11
|
<key>CFBundleVersion</key>
|
|
12
|
-
<string>0.
|
|
12
|
+
<string>0.3.0</string>
|
|
13
13
|
<key>CFBundleShortVersionString</key>
|
|
14
|
-
<string>0.
|
|
14
|
+
<string>0.3.0</string>
|
|
15
15
|
<key>CFBundleExecutable</key>
|
|
16
16
|
<string>DesignShare</string>
|
|
17
17
|
<key>CFBundlePackageType</key>
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "design-share",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Live branch previews and pinned comments for design teams. Zero config, no server, git is the backend.",
|
|
5
5
|
"keywords": ["design", "preview", "branches", "collaboration", "cli"],
|
|
6
6
|
"license": "MIT",
|
|
7
|
+
"funding": "https://buymeacoffee.com/callebe",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/callebe01/design-share.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/callebe01/design-share#readme",
|
|
7
13
|
"type": "module",
|
|
8
14
|
"bin": {
|
|
9
15
|
"design-share": "bin/design-share.js"
|
package/public/app.js
CHANGED
|
@@ -7,8 +7,124 @@ const icons = {
|
|
|
7
7
|
message: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg>',
|
|
8
8
|
check: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
|
|
9
9
|
plus: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>',
|
|
10
|
+
eye: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>',
|
|
10
11
|
};
|
|
11
12
|
|
|
13
|
+
/* ---------- PR panel (read only) ---------- */
|
|
14
|
+
|
|
15
|
+
function renderPrChip() {
|
|
16
|
+
const chip = el('pr-chip');
|
|
17
|
+
const sel = state.selected;
|
|
18
|
+
const pr = sel && state.board.prs ? state.board.prs[sel.branch] : null;
|
|
19
|
+
chip.hidden = !pr;
|
|
20
|
+
if (pr) {
|
|
21
|
+
const open = !el('pr-panel').hidden;
|
|
22
|
+
chip.innerHTML = `PR #${pr.number} <span class="chev">${open ? '▴' : '▾'}</span>`;
|
|
23
|
+
chip.title = pr.title || '';
|
|
24
|
+
} else {
|
|
25
|
+
el('pr-panel').hidden = true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function togglePrPanel(force) {
|
|
30
|
+
const panel = el('pr-panel');
|
|
31
|
+
const show = force !== undefined ? force : panel.hidden;
|
|
32
|
+
if (!show) { panel.hidden = true; renderPrChip(); return; }
|
|
33
|
+
const sel = state.selected;
|
|
34
|
+
if (!sel) return;
|
|
35
|
+
panel.innerHTML = '<div class="pr-loading"><div class="spinner"></div></div>';
|
|
36
|
+
panel.hidden = false;
|
|
37
|
+
renderPrChip();
|
|
38
|
+
const r = await fetch(`/api/pr?branch=${encodeURIComponent(sel.branch)}`).then((x) => x.json());
|
|
39
|
+
if (panel.hidden) return; // closed while loading
|
|
40
|
+
if (!r.pr) {
|
|
41
|
+
panel.innerHTML = '<div class="pr-loading">Could not load the PR right now.</div>';
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const pr = r.pr;
|
|
45
|
+
const stateCls = pr.state === 'OPEN' ? 'live' : pr.state === 'MERGED' ? 'merged' : 'closed';
|
|
46
|
+
panel.innerHTML = `
|
|
47
|
+
<div class="pr-head">
|
|
48
|
+
<span class="dot ${stateCls}"></span>
|
|
49
|
+
<span class="pr-title">${escapeHtml(pr.title)}</span>
|
|
50
|
+
<span class="pr-num">#${pr.number}</span>
|
|
51
|
+
<a class="pr-open" href="${escapeHtml(pr.url)}" target="_blank" rel="noopener">Open on GitHub</a>
|
|
52
|
+
</div>
|
|
53
|
+
<div class="pr-byline">${escapeHtml(pr.author)} · ${escapeHtml(pr.state.toLowerCase())} · ${timeAgo(Date.parse(pr.createdAt) / 1000)}</div>
|
|
54
|
+
${pr.body ? `<div class="pr-body">${escapeHtml(pr.body)}</div>` : '<div class="pr-body dim-note">No description.</div>'}
|
|
55
|
+
${pr.comments.length ? `
|
|
56
|
+
<div class="pr-comments-label">Comments (${pr.comments.length})</div>
|
|
57
|
+
${pr.comments.map((c) => `
|
|
58
|
+
<div class="pr-comment">
|
|
59
|
+
<div class="pr-comment-head">
|
|
60
|
+
<span class="avatar">${escapeHtml((c.author || '?').slice(0, 1))}</span>
|
|
61
|
+
<b>${escapeHtml(c.author)}</b>
|
|
62
|
+
<span class="pr-comment-time">${timeAgo(Date.parse(c.createdAt) / 1000)}</span>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="pr-comment-body">${escapeHtml(c.body)}</div>
|
|
65
|
+
</div>`).join('')}` : '<div class="dim-note" style="margin-top:8px">No comments on the PR yet.</div>'}
|
|
66
|
+
`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ---------- review requests ---------- */
|
|
70
|
+
|
|
71
|
+
function branchRequests(branch) {
|
|
72
|
+
if (!state.board) return [];
|
|
73
|
+
return (state.board.requests || [])
|
|
74
|
+
.filter((r) => r.branch === branch)
|
|
75
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function openRequestForMe(branch) {
|
|
79
|
+
const me = state.board.me.slug;
|
|
80
|
+
return branchRequests(branch).find((r) => r.toUser === me && !r.resolvedAt);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function renderRequestButton() {
|
|
84
|
+
const b = state.board;
|
|
85
|
+
const sel = state.selected;
|
|
86
|
+
const btn = el('btn-request');
|
|
87
|
+
const roster = (b.users || []).filter((u) => u.user !== b.me.slug);
|
|
88
|
+
const visible = !!sel && roster.length > 0;
|
|
89
|
+
btn.hidden = !visible;
|
|
90
|
+
if (visible) btn.innerHTML = `${icons.eye} <span>Request review</span>`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function toggleRequestPopover(force) {
|
|
94
|
+
const pop = el('request-popover');
|
|
95
|
+
const show = force !== undefined ? force : pop.hidden;
|
|
96
|
+
if (!show) { pop.hidden = true; return; }
|
|
97
|
+
const b = state.board;
|
|
98
|
+
const sel = state.selected;
|
|
99
|
+
if (!sel) return;
|
|
100
|
+
const roster = (b.users || [])
|
|
101
|
+
.filter((u) => u.user !== b.me.slug)
|
|
102
|
+
.sort((x, y) => (y.lastSeen || 0) - (x.lastSeen || 0));
|
|
103
|
+
pop.innerHTML = '';
|
|
104
|
+
const head = document.createElement('div');
|
|
105
|
+
head.className = 'popover-head';
|
|
106
|
+
head.textContent = `Ask someone to review ${sel.branch}`;
|
|
107
|
+
pop.appendChild(head);
|
|
108
|
+
for (const u of roster) {
|
|
109
|
+
const already = branchRequests(sel.branch).some((r) => r.toUser === u.user && !r.resolvedAt && r.fromUser === b.me.slug);
|
|
110
|
+
const row = document.createElement('button');
|
|
111
|
+
row.className = 'popover-row';
|
|
112
|
+
row.innerHTML = `
|
|
113
|
+
<span class="avatar">${escapeHtml((u.name || u.user).slice(0, 1))}</span>
|
|
114
|
+
<span class="popover-name">${escapeHtml(u.name || u.user)}</span>
|
|
115
|
+
<span class="popover-meta">${already ? 'asked' : timeAgo((u.lastSeen || 0) / 1000)}</span>`;
|
|
116
|
+
row.disabled = already;
|
|
117
|
+
row.onclick = async () => {
|
|
118
|
+
await api('/api/requests', { branch: sel.branch, toUser: u.user, toName: u.name });
|
|
119
|
+
toggleRequestPopover(false);
|
|
120
|
+
toast(`Asked ${u.name || u.user} to review ${sel.branch}`);
|
|
121
|
+
refresh();
|
|
122
|
+
};
|
|
123
|
+
pop.appendChild(row);
|
|
124
|
+
}
|
|
125
|
+
pop.hidden = false;
|
|
126
|
+
}
|
|
127
|
+
|
|
12
128
|
const el = (id) => document.getElementById(id);
|
|
13
129
|
const state = {
|
|
14
130
|
board: null,
|
|
@@ -189,9 +305,11 @@ function branchRow({ user, branch, time, dim, own }) {
|
|
|
189
305
|
|
|
190
306
|
const unresolved = openCount(branch);
|
|
191
307
|
const pr = b.prs && b.prs[branch];
|
|
308
|
+
const askedMe = openRequestForMe(branch);
|
|
192
309
|
btn.innerHTML = `
|
|
193
310
|
<span class="dot ${dotCls}"></span>
|
|
194
311
|
<span class="branch-label">${escapeHtml(branch)}</span>
|
|
312
|
+
${askedMe ? `<span class="badge review" title="${escapeHtml((askedMe.fromName || askedMe.fromUser) + ' asked for your review')}">review</span>` : ''}
|
|
195
313
|
${unresolved ? `<span class="badge">${unresolved}</span>` : ''}
|
|
196
314
|
${pr ? `<span class="pr-link" title="${escapeHtml(`PR #${pr.number}: ${pr.title || ''}`)}">#${pr.number}</span>` : ''}
|
|
197
315
|
<span class="meta">${timeAgo(time)}</span>`;
|
|
@@ -209,6 +327,7 @@ function branchRow({ user, branch, time, dim, own }) {
|
|
|
209
327
|
|
|
210
328
|
async function select(user, branch) {
|
|
211
329
|
state.selected = { user, branch };
|
|
330
|
+
el('pr-panel').hidden = true;
|
|
212
331
|
state.commentMode = false;
|
|
213
332
|
state.pendingPin = null;
|
|
214
333
|
state.focusedComment = null;
|
|
@@ -295,6 +414,9 @@ function renderStage() {
|
|
|
295
414
|
el('loading-log').textContent = ps && ps.logTail ? ps.logTail.trim() : '';
|
|
296
415
|
}
|
|
297
416
|
|
|
417
|
+
renderRequestButton();
|
|
418
|
+
renderPrChip();
|
|
419
|
+
|
|
298
420
|
const cm = el('btn-comment-mode');
|
|
299
421
|
cm.innerHTML = `${icons.message} <span>${state.commentMode ? 'Click an element to pin' : 'Comment'}</span>`;
|
|
300
422
|
cm.classList.toggle('active-mode', state.commentMode);
|
|
@@ -353,11 +475,13 @@ window.addEventListener('message', (e) => {
|
|
|
353
475
|
el('composer-text').value = '';
|
|
354
476
|
el('composer-text').focus();
|
|
355
477
|
} else if (d.ds === 'pin-click') {
|
|
356
|
-
state.focusedComment = d.id;
|
|
478
|
+
state.focusedComment = state.focusedComment === d.id ? null : d.id;
|
|
357
479
|
renderComments();
|
|
358
480
|
pushCommentsToFrame();
|
|
359
|
-
|
|
360
|
-
|
|
481
|
+
if (state.focusedComment) {
|
|
482
|
+
const card = document.querySelector(`[data-comment="${d.id}"]`);
|
|
483
|
+
if (card) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
484
|
+
}
|
|
361
485
|
}
|
|
362
486
|
});
|
|
363
487
|
|
|
@@ -459,6 +583,34 @@ function renderComments() {
|
|
|
459
583
|
el('comments-count').textContent = open.length ? `${open.length} open` : '';
|
|
460
584
|
|
|
461
585
|
const frag = document.createDocumentFragment();
|
|
586
|
+
|
|
587
|
+
const me = state.board.me.slug;
|
|
588
|
+
for (const r of branchRequests(sel.branch).filter((x) => !x.resolvedAt)) {
|
|
589
|
+
const card = document.createElement('div');
|
|
590
|
+
card.className = 'request-card' + (r.toUser === me ? ' for-me' : '');
|
|
591
|
+
if (r.toUser === me) {
|
|
592
|
+
card.innerHTML = `<span class="request-text"><b>${escapeHtml(r.fromName || r.fromUser)}</b> asked for your review</span>
|
|
593
|
+
<span class="request-meta">${timeAgo(r.createdAt / 1000)}</span>`;
|
|
594
|
+
const done = document.createElement('button');
|
|
595
|
+
done.className = 'btn small';
|
|
596
|
+
done.textContent = 'Mark reviewed';
|
|
597
|
+
done.onclick = async () => { await api(`/api/requests/${r.id}/resolve`, { how: 'done' }); refresh(); };
|
|
598
|
+
card.appendChild(done);
|
|
599
|
+
} else if (r.fromUser === me) {
|
|
600
|
+
card.innerHTML = `<span class="request-text">You asked <b>${escapeHtml(r.toName || r.toUser)}</b> to review</span>
|
|
601
|
+
<span class="request-meta">${timeAgo(r.createdAt / 1000)}</span>`;
|
|
602
|
+
const cancel = document.createElement('button');
|
|
603
|
+
cancel.className = 'link-btn';
|
|
604
|
+
cancel.textContent = 'Cancel';
|
|
605
|
+
cancel.onclick = async () => { await api(`/api/requests/${r.id}/resolve`, { how: 'cancelled' }); refresh(); };
|
|
606
|
+
card.appendChild(cancel);
|
|
607
|
+
} else {
|
|
608
|
+
card.innerHTML = `<span class="request-text"><b>${escapeHtml(r.fromName || r.fromUser)}</b> asked <b>${escapeHtml(r.toName || r.toUser)}</b> to review</span>
|
|
609
|
+
<span class="request-meta">${timeAgo(r.createdAt / 1000)}</span>`;
|
|
610
|
+
}
|
|
611
|
+
frag.appendChild(card);
|
|
612
|
+
}
|
|
613
|
+
|
|
462
614
|
if (!comments.length) {
|
|
463
615
|
const d = document.createElement('div');
|
|
464
616
|
d.className = 'empty-note';
|
|
@@ -539,11 +691,10 @@ function commentCard(c, num) {
|
|
|
539
691
|
|
|
540
692
|
card.onclick = (e) => {
|
|
541
693
|
if (e.target === reply || e.target.tagName === 'BUTTON') return;
|
|
542
|
-
state.focusedComment = c.id;
|
|
694
|
+
state.focusedComment = state.focusedComment === c.id ? null : c.id;
|
|
695
|
+
renderComments();
|
|
543
696
|
renderPins();
|
|
544
697
|
pushCommentsToFrame();
|
|
545
|
-
document.querySelectorAll('.comment-card').forEach((x) => x.classList.remove('focused'));
|
|
546
|
-
card.classList.add('focused');
|
|
547
698
|
};
|
|
548
699
|
return card;
|
|
549
700
|
}
|
|
@@ -575,7 +726,14 @@ el('btn-retry').onclick = () => pollPreview();
|
|
|
575
726
|
document.addEventListener('keydown', (e) => {
|
|
576
727
|
if (e.key === 'Escape') {
|
|
577
728
|
if (!el('composer').hidden) closeComposer();
|
|
729
|
+
else if (!el('pr-panel').hidden) togglePrPanel(false);
|
|
578
730
|
else if (state.commentMode) { state.commentMode = false; renderStage(); }
|
|
731
|
+
else if (state.focusedComment) {
|
|
732
|
+
state.focusedComment = null;
|
|
733
|
+
renderComments();
|
|
734
|
+
renderPins();
|
|
735
|
+
pushCommentsToFrame();
|
|
736
|
+
}
|
|
579
737
|
}
|
|
580
738
|
});
|
|
581
739
|
|
|
@@ -623,6 +781,26 @@ function parseHash() {
|
|
|
623
781
|
state.railFilter = e.target.value;
|
|
624
782
|
renderRail();
|
|
625
783
|
});
|
|
784
|
+
const setRailCollapsed = (collapsed) => {
|
|
785
|
+
document.getElementById('app').classList.toggle('rail-collapsed', collapsed);
|
|
786
|
+
el('btn-expand').hidden = !collapsed;
|
|
787
|
+
try { localStorage.setItem('ds-rail-collapsed', collapsed ? '1' : ''); } catch { /* private mode */ }
|
|
788
|
+
};
|
|
789
|
+
el('btn-collapse').onclick = () => setRailCollapsed(true);
|
|
790
|
+
el('btn-expand').onclick = () => setRailCollapsed(false);
|
|
791
|
+
try { if (localStorage.getItem('ds-rail-collapsed') === '1') setRailCollapsed(true); } catch { /* private mode */ }
|
|
792
|
+
document.addEventListener('keydown', (e) => {
|
|
793
|
+
if (e.key === '[' && !isTyping()) {
|
|
794
|
+
setRailCollapsed(!document.getElementById('app').classList.contains('rail-collapsed'));
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
el('btn-request').onclick = (e) => { e.stopPropagation(); toggleRequestPopover(); };
|
|
799
|
+
el('pr-chip').onclick = (e) => { e.stopPropagation(); togglePrPanel(); };
|
|
800
|
+
document.addEventListener('click', (e) => {
|
|
801
|
+
if (!el('request-popover').hidden && !e.target.closest('.request-wrap')) toggleRequestPopover(false);
|
|
802
|
+
if (!el('pr-panel').hidden && !e.target.closest('#pr-panel') && !e.target.closest('#pr-chip')) togglePrPanel(false);
|
|
803
|
+
});
|
|
626
804
|
renderViewportSeg();
|
|
627
805
|
await refresh();
|
|
628
806
|
const target = parseHash();
|
package/public/index.html
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
<div class="rail-head">
|
|
13
13
|
<span class="logo-mark"></span>
|
|
14
14
|
<span id="repo-name" class="repo-name">…</span>
|
|
15
|
+
<button id="btn-collapse" class="icon-btn" title="Collapse sidebar [">
|
|
16
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/></svg>
|
|
17
|
+
</button>
|
|
15
18
|
</div>
|
|
16
19
|
<div class="rail-filter">
|
|
17
20
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
|
@@ -20,20 +23,33 @@
|
|
|
20
23
|
<div id="rail-sections"></div>
|
|
21
24
|
<div class="rail-foot">
|
|
22
25
|
<div id="sync-status" class="sync-status"></div>
|
|
26
|
+
<a class="coffee-link" href="https://buymeacoffee.com/callebe" target="_blank" rel="noopener">
|
|
27
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 2v2"/><path d="M14 2v2"/><path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/><path d="M6 2v2"/></svg>
|
|
28
|
+
buy me a coffee
|
|
29
|
+
</a>
|
|
23
30
|
</div>
|
|
24
31
|
</aside>
|
|
25
32
|
|
|
26
33
|
<main id="stage">
|
|
27
34
|
<header id="stage-head">
|
|
28
35
|
<div class="stage-title-wrap">
|
|
36
|
+
<button id="btn-expand" class="icon-btn" title="Show sidebar [" hidden>
|
|
37
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/></svg>
|
|
38
|
+
</button>
|
|
29
39
|
<span id="stage-title" class="stage-title">pick a branch</span>
|
|
30
40
|
<span id="stage-meta" class="stage-meta"></span>
|
|
41
|
+
<button id="pr-chip" class="pr-chip" hidden></button>
|
|
31
42
|
</div>
|
|
32
43
|
<div class="stage-actions">
|
|
33
44
|
<div class="seg" id="viewport-seg"></div>
|
|
45
|
+
<div class="request-wrap">
|
|
46
|
+
<button class="btn ghost" id="btn-request"></button>
|
|
47
|
+
<div id="request-popover" hidden></div>
|
|
48
|
+
</div>
|
|
34
49
|
<button class="btn" id="btn-comment-mode"></button>
|
|
35
50
|
</div>
|
|
36
51
|
</header>
|
|
52
|
+
<div id="pr-panel" hidden></div>
|
|
37
53
|
<div id="frame-area">
|
|
38
54
|
<div id="frame-wrap">
|
|
39
55
|
<div id="frame-empty" class="frame-state">
|
package/public/style.css
CHANGED
|
@@ -65,7 +65,23 @@ button { font: inherit; cursor: pointer; }
|
|
|
65
65
|
background: #fff;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
.repo-name { font-size: 13.5px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
68
|
+
.repo-name { font-size: 13.5px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
|
69
|
+
|
|
70
|
+
.icon-btn {
|
|
71
|
+
display: inline-flex;
|
|
72
|
+
align-items: center;
|
|
73
|
+
justify-content: center;
|
|
74
|
+
width: 24px;
|
|
75
|
+
height: 24px;
|
|
76
|
+
border: 0;
|
|
77
|
+
border-radius: var(--radius);
|
|
78
|
+
background: transparent;
|
|
79
|
+
color: var(--text-3);
|
|
80
|
+
flex: none;
|
|
81
|
+
}
|
|
82
|
+
.icon-btn:hover { color: var(--text); background: #f2f2f4; }
|
|
83
|
+
|
|
84
|
+
#app.rail-collapsed #rail { display: none; }
|
|
69
85
|
|
|
70
86
|
.rail-filter {
|
|
71
87
|
display: flex;
|
|
@@ -135,6 +151,135 @@ button { font: inherit; cursor: pointer; }
|
|
|
135
151
|
}
|
|
136
152
|
.pr-link:hover { color: var(--accent); border-color: var(--accent); }
|
|
137
153
|
|
|
154
|
+
.badge.review { color: var(--amber); border-color: #ecd9b8; background: #fdf8ee; }
|
|
155
|
+
|
|
156
|
+
.pr-chip {
|
|
157
|
+
display: inline-flex;
|
|
158
|
+
align-items: center;
|
|
159
|
+
gap: 4px;
|
|
160
|
+
font-size: 10.5px;
|
|
161
|
+
color: var(--text-2);
|
|
162
|
+
border: 1px solid var(--border);
|
|
163
|
+
border-radius: 99px;
|
|
164
|
+
padding: 0 8px;
|
|
165
|
+
line-height: 17px;
|
|
166
|
+
background: #fff;
|
|
167
|
+
cursor: pointer;
|
|
168
|
+
flex: none;
|
|
169
|
+
}
|
|
170
|
+
.pr-chip:hover { color: var(--text); border-color: #d0d0d5; }
|
|
171
|
+
.pr-chip .chev { font-size: 8px; color: var(--text-3); }
|
|
172
|
+
|
|
173
|
+
#pr-panel {
|
|
174
|
+
position: absolute;
|
|
175
|
+
top: 46px;
|
|
176
|
+
left: 14px;
|
|
177
|
+
width: min(560px, calc(100% - 28px));
|
|
178
|
+
max-height: 55vh;
|
|
179
|
+
overflow-y: auto;
|
|
180
|
+
background: var(--panel);
|
|
181
|
+
border: 1px solid var(--border);
|
|
182
|
+
border-radius: 0 0 10px 10px;
|
|
183
|
+
border-top: 0;
|
|
184
|
+
box-shadow: var(--shadow);
|
|
185
|
+
padding: 12px 14px;
|
|
186
|
+
z-index: 15;
|
|
187
|
+
}
|
|
188
|
+
#stage { position: relative; }
|
|
189
|
+
|
|
190
|
+
.pr-loading { display: flex; justify-content: center; padding: 18px; color: var(--text-3); font-size: 12px; }
|
|
191
|
+
.pr-head { display: flex; align-items: center; gap: 8px; }
|
|
192
|
+
.pr-head .dot.merged { background: #8250df; }
|
|
193
|
+
.pr-head .dot.closed { background: var(--red); }
|
|
194
|
+
.pr-title { font-size: 13.5px; font-weight: 600; flex: 1; min-width: 0; }
|
|
195
|
+
.pr-num { font-size: 11.5px; color: var(--text-3); flex: none; }
|
|
196
|
+
.pr-open { font-size: 11.5px; flex: none; color: var(--text-2); text-decoration: none; border: 1px solid var(--border); border-radius: var(--radius); padding: 2px 8px; }
|
|
197
|
+
.pr-open:hover { color: var(--text); border-color: #d0d0d5; }
|
|
198
|
+
.pr-byline { font-size: 11.5px; color: var(--text-3); margin: 4px 0 8px; }
|
|
199
|
+
.pr-body {
|
|
200
|
+
font-size: 12.5px;
|
|
201
|
+
color: var(--text);
|
|
202
|
+
white-space: pre-wrap;
|
|
203
|
+
word-break: break-word;
|
|
204
|
+
border: 1px solid var(--border);
|
|
205
|
+
border-radius: 8px;
|
|
206
|
+
padding: 9px 11px;
|
|
207
|
+
background: #fafafa;
|
|
208
|
+
max-height: 220px;
|
|
209
|
+
overflow-y: auto;
|
|
210
|
+
}
|
|
211
|
+
.dim-note { font-size: 12px; color: var(--text-3); }
|
|
212
|
+
.pr-comments-label {
|
|
213
|
+
font-size: 10.5px;
|
|
214
|
+
font-weight: 600;
|
|
215
|
+
letter-spacing: 0.06em;
|
|
216
|
+
text-transform: uppercase;
|
|
217
|
+
color: var(--text-3);
|
|
218
|
+
margin: 12px 0 6px;
|
|
219
|
+
}
|
|
220
|
+
.pr-comment { border-top: 1px solid var(--border); padding: 8px 0; }
|
|
221
|
+
.pr-comment-head { display: flex; align-items: center; gap: 7px; font-size: 12px; margin-bottom: 3px; }
|
|
222
|
+
.pr-comment-time { font-size: 10.5px; color: var(--text-3); }
|
|
223
|
+
.pr-comment-body { font-size: 12.5px; color: var(--text-2); white-space: pre-wrap; word-break: break-word; }
|
|
224
|
+
|
|
225
|
+
.request-wrap { position: relative; }
|
|
226
|
+
#request-popover {
|
|
227
|
+
position: absolute;
|
|
228
|
+
top: 32px;
|
|
229
|
+
right: 0;
|
|
230
|
+
width: 240px;
|
|
231
|
+
background: var(--panel);
|
|
232
|
+
border: 1px solid var(--border);
|
|
233
|
+
border-radius: 8px;
|
|
234
|
+
box-shadow: var(--shadow);
|
|
235
|
+
padding: 6px;
|
|
236
|
+
z-index: 20;
|
|
237
|
+
}
|
|
238
|
+
.popover-head {
|
|
239
|
+
font-size: 11px;
|
|
240
|
+
color: var(--text-3);
|
|
241
|
+
padding: 5px 8px 7px;
|
|
242
|
+
border-bottom: 1px solid var(--border);
|
|
243
|
+
margin-bottom: 4px;
|
|
244
|
+
white-space: nowrap;
|
|
245
|
+
overflow: hidden;
|
|
246
|
+
text-overflow: ellipsis;
|
|
247
|
+
}
|
|
248
|
+
.popover-row {
|
|
249
|
+
display: flex;
|
|
250
|
+
align-items: center;
|
|
251
|
+
gap: 8px;
|
|
252
|
+
width: 100%;
|
|
253
|
+
border: 0;
|
|
254
|
+
background: transparent;
|
|
255
|
+
border-radius: var(--radius);
|
|
256
|
+
padding: 6px 8px;
|
|
257
|
+
font-size: 12.5px;
|
|
258
|
+
color: var(--text);
|
|
259
|
+
text-align: left;
|
|
260
|
+
}
|
|
261
|
+
.popover-row:hover:not(:disabled) { background: #f2f2f4; }
|
|
262
|
+
.popover-row:disabled { opacity: 0.5; cursor: default; }
|
|
263
|
+
.popover-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
264
|
+
.popover-meta { font-size: 10.5px; color: var(--text-3); flex: none; }
|
|
265
|
+
|
|
266
|
+
.request-card {
|
|
267
|
+
display: flex;
|
|
268
|
+
align-items: center;
|
|
269
|
+
gap: 8px;
|
|
270
|
+
flex-wrap: wrap;
|
|
271
|
+
border: 1px solid var(--border);
|
|
272
|
+
border-radius: 8px;
|
|
273
|
+
padding: 8px 10px;
|
|
274
|
+
margin-bottom: 8px;
|
|
275
|
+
font-size: 12px;
|
|
276
|
+
color: var(--text-2);
|
|
277
|
+
background: #fafafa;
|
|
278
|
+
}
|
|
279
|
+
.request-card.for-me { border-color: #ecd9b8; background: #fdf8ee; color: var(--text); }
|
|
280
|
+
.request-card .request-text { flex: 1; min-width: 120px; }
|
|
281
|
+
.request-card .request-meta { font-size: 10.5px; color: var(--text-3); }
|
|
282
|
+
|
|
138
283
|
.dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
|
|
139
284
|
.dot.live { background: var(--green); }
|
|
140
285
|
.dot.idle { background: #d4d4d8; }
|
|
@@ -164,9 +309,19 @@ button { font: inherit; cursor: pointer; }
|
|
|
164
309
|
}
|
|
165
310
|
.share-cta:hover { color: var(--text); border-color: #d0d0d5; }
|
|
166
311
|
|
|
167
|
-
.rail-foot { border-top: 1px solid var(--border); padding: 8px 16px; }
|
|
312
|
+
.rail-foot { border-top: 1px solid var(--border); padding: 8px 16px; display: flex; flex-direction: column; gap: 4px; }
|
|
168
313
|
.sync-status { font-size: 11px; color: var(--text-3); display: flex; align-items: center; gap: 6px; }
|
|
169
314
|
.sync-status .dot { width: 6px; height: 6px; }
|
|
315
|
+
.coffee-link {
|
|
316
|
+
display: inline-flex;
|
|
317
|
+
align-items: center;
|
|
318
|
+
gap: 5px;
|
|
319
|
+
font-size: 10.5px;
|
|
320
|
+
color: var(--text-3);
|
|
321
|
+
text-decoration: none;
|
|
322
|
+
width: fit-content;
|
|
323
|
+
}
|
|
324
|
+
.coffee-link:hover { color: var(--text-2); }
|
|
170
325
|
|
|
171
326
|
/* ---------- stage ---------- */
|
|
172
327
|
|
|
@@ -247,12 +402,17 @@ button { font: inherit; cursor: pointer; }
|
|
|
247
402
|
max-width: 100%;
|
|
248
403
|
background: var(--panel);
|
|
249
404
|
transition: max-width 0.18s ease;
|
|
405
|
+
min-height: 100%;
|
|
406
|
+
}
|
|
407
|
+
#frame-wrap.vw-tablet,
|
|
408
|
+
#frame-wrap.vw-mobile {
|
|
409
|
+
margin: 0 auto;
|
|
410
|
+
box-shadow: var(--shadow);
|
|
250
411
|
border-left: 1px solid var(--border);
|
|
251
412
|
border-right: 1px solid var(--border);
|
|
252
|
-
min-height: 100%;
|
|
253
413
|
}
|
|
254
|
-
#frame-wrap.vw-tablet { max-width: 834px;
|
|
255
|
-
#frame-wrap.vw-mobile { max-width: 390px;
|
|
414
|
+
#frame-wrap.vw-tablet { max-width: 834px; }
|
|
415
|
+
#frame-wrap.vw-mobile { max-width: 390px; }
|
|
256
416
|
|
|
257
417
|
#frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; background: #fff; }
|
|
258
418
|
|
package/src/github.js
CHANGED
|
@@ -3,6 +3,37 @@ import { execFile } from 'node:child_process';
|
|
|
3
3
|
// PR links come from the gh CLI so no tokens are stored or handled: gh uses
|
|
4
4
|
// whoever is already logged in. Machines without gh (or without auth) simply
|
|
5
5
|
// return an empty map and the dashboard shows no PR links.
|
|
6
|
+
// Read only PR detail for the header panel: description plus conversation.
|
|
7
|
+
export function prDetail(repoRoot, number) {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
execFile('gh', [
|
|
10
|
+
'pr', 'view', String(number),
|
|
11
|
+
'--json', 'number,title,body,state,author,url,createdAt,comments',
|
|
12
|
+
], { cwd: repoRoot, timeout: 15_000 }, (err, stdout) => {
|
|
13
|
+
if (err) return resolve(null);
|
|
14
|
+
try {
|
|
15
|
+
const d = JSON.parse(stdout);
|
|
16
|
+
resolve({
|
|
17
|
+
number: d.number,
|
|
18
|
+
title: d.title,
|
|
19
|
+
body: d.body || '',
|
|
20
|
+
state: d.state,
|
|
21
|
+
url: d.url,
|
|
22
|
+
author: (d.author && d.author.login) || '',
|
|
23
|
+
createdAt: d.createdAt,
|
|
24
|
+
comments: (d.comments || []).map((c) => ({
|
|
25
|
+
author: (c.author && c.author.login) || '',
|
|
26
|
+
body: c.body || '',
|
|
27
|
+
createdAt: c.createdAt,
|
|
28
|
+
})),
|
|
29
|
+
});
|
|
30
|
+
} catch {
|
|
31
|
+
resolve(null);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
6
37
|
export function listOpenPRs(repoRoot) {
|
|
7
38
|
return new Promise((resolve) => {
|
|
8
39
|
execFile('gh', [
|
package/src/server.js
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
import { tryGit } from './git.js';
|
|
6
6
|
import { currentBranch } from './detect.js';
|
|
7
7
|
import { serveStatic } from './previews.js';
|
|
8
|
-
import { listOpenPRs } from './github.js';
|
|
8
|
+
import { listOpenPRs, prDetail } from './github.js';
|
|
9
9
|
|
|
10
10
|
const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
|
11
11
|
const SYNC_INTERVAL_MS = 15_000;
|
|
@@ -89,6 +89,8 @@ export class DesignShareServer {
|
|
|
89
89
|
heads,
|
|
90
90
|
otherBranches: others,
|
|
91
91
|
comments: Object.values(state.comments || {}),
|
|
92
|
+
users: Object.values(state.users || {}),
|
|
93
|
+
requests: Object.values(state.requests || {}),
|
|
92
94
|
previews: this.previews.statuses(),
|
|
93
95
|
prs: this.prs,
|
|
94
96
|
sync: {
|
|
@@ -108,6 +110,19 @@ export class DesignShareServer {
|
|
|
108
110
|
if (req.method === 'GET' && p === '/api/board') {
|
|
109
111
|
return json(res, 200, await this.board());
|
|
110
112
|
}
|
|
113
|
+
if (req.method === 'GET' && p === '/api/pr') {
|
|
114
|
+
const branch = url.searchParams.get('branch') || '';
|
|
115
|
+
const pr = this.prs[branch];
|
|
116
|
+
if (!pr) return json(res, 200, { pr: null });
|
|
117
|
+
this._prDetailCache = this._prDetailCache || new Map();
|
|
118
|
+
const cached = this._prDetailCache.get(pr.number);
|
|
119
|
+
if (cached && Date.now() - cached.at < 120_000) {
|
|
120
|
+
return json(res, 200, { pr: cached.data });
|
|
121
|
+
}
|
|
122
|
+
const data = await prDetail(this.repo.root, pr.number);
|
|
123
|
+
if (data) this._prDetailCache.set(pr.number, { at: Date.now(), data });
|
|
124
|
+
return json(res, 200, { pr: data });
|
|
125
|
+
}
|
|
111
126
|
if (req.method === 'POST' && p === '/api/share') {
|
|
112
127
|
const body = await readBody(req);
|
|
113
128
|
const branch = body.branch || (await currentBranch(this.repo.root));
|
|
@@ -145,9 +160,27 @@ export class DesignShareServer {
|
|
|
145
160
|
commit,
|
|
146
161
|
text: String(body.text || '').slice(0, 4000),
|
|
147
162
|
});
|
|
163
|
+
this.store.autoResolveRequests(body.branch);
|
|
148
164
|
this.syncSoon();
|
|
149
165
|
return json(res, 200, { ok: true, id });
|
|
150
166
|
}
|
|
167
|
+
if (req.method === 'POST' && p === '/api/requests') {
|
|
168
|
+
const body = await readBody(req);
|
|
169
|
+
const id = this.store.requestReview({
|
|
170
|
+
branch: String(body.branch || ''),
|
|
171
|
+
toUser: String(body.toUser || ''),
|
|
172
|
+
toName: String(body.toName || body.toUser || ''),
|
|
173
|
+
});
|
|
174
|
+
this.syncSoon();
|
|
175
|
+
return json(res, 200, { ok: true, id });
|
|
176
|
+
}
|
|
177
|
+
const reqResolveMatch = p.match(/^\/api\/requests\/([a-z0-9]+)\/resolve$/);
|
|
178
|
+
if (req.method === 'POST' && reqResolveMatch) {
|
|
179
|
+
const body = await readBody(req);
|
|
180
|
+
this.store.resolveRequest(reqResolveMatch[1], body.how);
|
|
181
|
+
this.syncSoon();
|
|
182
|
+
return json(res, 200, { ok: true });
|
|
183
|
+
}
|
|
151
184
|
const replyMatch = p.match(/^\/api\/comments\/([a-z0-9]+)\/replies$/);
|
|
152
185
|
if (req.method === 'POST' && replyMatch) {
|
|
153
186
|
const body = await readBody(req);
|
package/src/state.js
CHANGED
|
@@ -5,7 +5,7 @@ const REF = 'refs/design-share/state';
|
|
|
5
5
|
const REMOTE_REF = 'refs/design-share/remote';
|
|
6
6
|
|
|
7
7
|
export function emptyState() {
|
|
8
|
-
return { version: 1, shares: {}, comments: {} };
|
|
8
|
+
return { version: 1, shares: {}, comments: {}, users: {}, requests: {} };
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export function newId() {
|
|
@@ -45,6 +45,18 @@ export function mergeStates(a, b) {
|
|
|
45
45
|
out.comments[k] = ca || cb;
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
+
const userKeys = new Set([...Object.keys(a.users || {}), ...Object.keys(b.users || {})]);
|
|
49
|
+
for (const k of userKeys) {
|
|
50
|
+
const ua = (a.users || {})[k];
|
|
51
|
+
const ub = (b.users || {})[k];
|
|
52
|
+
out.users[k] = ua && ub ? ((ua.lastSeen || 0) >= (ub.lastSeen || 0) ? ua : ub) : (ua || ub);
|
|
53
|
+
}
|
|
54
|
+
const reqKeys = new Set([...Object.keys(a.requests || {}), ...Object.keys(b.requests || {})]);
|
|
55
|
+
for (const k of reqKeys) {
|
|
56
|
+
const ra = (a.requests || {})[k];
|
|
57
|
+
const rb = (b.requests || {})[k];
|
|
58
|
+
out.requests[k] = ra && rb ? newer(ra, rb) : (ra || rb);
|
|
59
|
+
}
|
|
48
60
|
return out;
|
|
49
61
|
}
|
|
50
62
|
|
|
@@ -93,8 +105,22 @@ export class StateStore {
|
|
|
93
105
|
await git(this.repoRoot, ['update-ref', REF, commit]);
|
|
94
106
|
}
|
|
95
107
|
|
|
108
|
+
// Presence: mark this user as someone who runs design-share so the
|
|
109
|
+
// review request picker only offers reachable people. Throttled so it
|
|
110
|
+
// does not force a push on every sync cycle.
|
|
111
|
+
stampPresence() {
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
const u = (this.state.users || {})[this.identity.slug];
|
|
114
|
+
if (u && now - (u.lastSeen || 0) < 60 * 60 * 1000) return;
|
|
115
|
+
this.mutate((s) => {
|
|
116
|
+
s.users = s.users || {};
|
|
117
|
+
s.users[this.identity.slug] = { user: this.identity.slug, name: this.identity.name, lastSeen: now };
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
96
121
|
// Fetch remote state, merge, persist locally, push if anything moved.
|
|
97
122
|
async sync() {
|
|
123
|
+
this.stampPresence();
|
|
98
124
|
try {
|
|
99
125
|
let remoteState = null;
|
|
100
126
|
if (this.hasRemote) {
|
|
@@ -175,6 +201,59 @@ export class StateStore {
|
|
|
175
201
|
return id;
|
|
176
202
|
}
|
|
177
203
|
|
|
204
|
+
requestReview({ branch, toUser, toName }) {
|
|
205
|
+
const id = newId();
|
|
206
|
+
const now = Date.now();
|
|
207
|
+
this.mutate((s) => {
|
|
208
|
+
s.requests = s.requests || {};
|
|
209
|
+
// One open request per branch per person: refresh an existing one.
|
|
210
|
+
const existing = Object.values(s.requests).find(
|
|
211
|
+
(r) => r.branch === branch && r.toUser === toUser && !r.resolvedAt,
|
|
212
|
+
);
|
|
213
|
+
const key = existing ? existing.id : id;
|
|
214
|
+
s.requests[key] = {
|
|
215
|
+
id: key,
|
|
216
|
+
branch,
|
|
217
|
+
fromUser: this.identity.slug,
|
|
218
|
+
fromName: this.identity.name,
|
|
219
|
+
toUser,
|
|
220
|
+
toName,
|
|
221
|
+
createdAt: existing ? existing.createdAt : now,
|
|
222
|
+
updatedAt: now,
|
|
223
|
+
resolvedAt: null,
|
|
224
|
+
resolvedBy: null,
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
return id;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
resolveRequest(requestId, how) {
|
|
231
|
+
const now = Date.now();
|
|
232
|
+
this.mutate((s) => {
|
|
233
|
+
const r = (s.requests || {})[requestId];
|
|
234
|
+
if (!r) return;
|
|
235
|
+
r.resolvedAt = now;
|
|
236
|
+
r.resolvedBy = this.identity.slug;
|
|
237
|
+
r.resolvedHow = how || 'done';
|
|
238
|
+
r.updatedAt = now;
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// A comment from the requested person is the review happening; close the loop.
|
|
243
|
+
autoResolveRequests(branch) {
|
|
244
|
+
const now = Date.now();
|
|
245
|
+
this.mutate((s) => {
|
|
246
|
+
for (const r of Object.values(s.requests || {})) {
|
|
247
|
+
if (r.branch === branch && r.toUser === this.identity.slug && !r.resolvedAt) {
|
|
248
|
+
r.resolvedAt = now;
|
|
249
|
+
r.resolvedBy = this.identity.slug;
|
|
250
|
+
r.resolvedHow = 'commented';
|
|
251
|
+
r.updatedAt = now;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
178
257
|
reply(commentId, text) {
|
|
179
258
|
const now = Date.now();
|
|
180
259
|
this.mutate((s) => {
|