design-share 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 +64 -0
- package/bin/design-share.js +8 -0
- package/dist/DesignShare.app/Contents/CodeResources +0 -0
- package/dist/DesignShare.app/Contents/Info.plist +26 -0
- package/dist/DesignShare.app/Contents/MacOS/DesignShare +0 -0
- package/dist/DesignShare.app/Contents/_CodeSignature/CodeResources +115 -0
- package/package.json +18 -0
- package/public/app.js +526 -0
- package/public/index.html +75 -0
- package/public/style.css +435 -0
- package/src/cli.js +246 -0
- package/src/detect.js +76 -0
- package/src/git.js +47 -0
- package/src/menubar.js +48 -0
- package/src/previews.js +240 -0
- package/src/registry.js +42 -0
- package/src/server.js +214 -0
- package/src/state.js +204 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
/* design-share dashboard */
|
|
2
|
+
|
|
3
|
+
const icons = {
|
|
4
|
+
monitor: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg>',
|
|
5
|
+
tablet: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="16" height="20" x="4" y="2" rx="2"/><line x1="12" x2="12.01" y1="18" y2="18"/></svg>',
|
|
6
|
+
smartphone: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="20" x="5" y="2" rx="2"/><path d="M12 18h.01"/></svg>',
|
|
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
|
+
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
|
+
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
|
+
};
|
|
11
|
+
|
|
12
|
+
const el = (id) => document.getElementById(id);
|
|
13
|
+
const state = {
|
|
14
|
+
board: null,
|
|
15
|
+
selected: null, // { user, branch }
|
|
16
|
+
viewport: 'desktop',
|
|
17
|
+
commentMode: false,
|
|
18
|
+
pendingPin: null, // { xPct, yPct }
|
|
19
|
+
focusedComment: null,
|
|
20
|
+
previewStatus: null,
|
|
21
|
+
frameUrl: null,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const VIEWPORTS = [
|
|
25
|
+
{ key: 'desktop', icon: 'monitor', title: 'Desktop' },
|
|
26
|
+
{ key: 'tablet', icon: 'tablet', title: 'Tablet · 834' },
|
|
27
|
+
{ key: 'mobile', icon: 'smartphone', title: 'Mobile · 390' },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function timeAgo(tsSeconds) {
|
|
31
|
+
if (!tsSeconds) return '';
|
|
32
|
+
const s = Math.max(1, Math.floor(Date.now() / 1000 - tsSeconds));
|
|
33
|
+
if (s < 60) return 'now';
|
|
34
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
35
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
|
36
|
+
return `${Math.floor(s / 86400)}d`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toast(msg) {
|
|
40
|
+
const t = document.createElement('div');
|
|
41
|
+
t.className = 'toast';
|
|
42
|
+
t.textContent = msg;
|
|
43
|
+
document.body.appendChild(t);
|
|
44
|
+
setTimeout(() => t.remove(), 1800);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function api(path, body) {
|
|
48
|
+
const res = await fetch(path, body
|
|
49
|
+
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
|
|
50
|
+
: undefined);
|
|
51
|
+
return res.json();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isTyping() {
|
|
55
|
+
const a = document.activeElement;
|
|
56
|
+
return a && (a.tagName === 'TEXTAREA' || a.tagName === 'INPUT');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function slugify(name) {
|
|
60
|
+
return (name || '').toLowerCase().replace(/[^a-z0-9]+/g, '') || 'someone';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function branchComments(branch) {
|
|
64
|
+
if (!state.board) return [];
|
|
65
|
+
return state.board.comments
|
|
66
|
+
.filter((c) => c.branch === branch)
|
|
67
|
+
.sort((a, b) => a.createdAt - b.createdAt);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function openCount(branch) {
|
|
71
|
+
return branchComments(branch).filter((c) => !c.resolvedAt).length;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* ---------- rail ---------- */
|
|
75
|
+
|
|
76
|
+
function renderRail() {
|
|
77
|
+
const b = state.board;
|
|
78
|
+
el('repo-name').textContent = b.repo.name;
|
|
79
|
+
|
|
80
|
+
const sections = el('rail-sections');
|
|
81
|
+
const frag = document.createDocumentFragment();
|
|
82
|
+
|
|
83
|
+
const shares = b.shares.slice().sort((x, y) => (y.updatedAt || 0) - (x.updatedAt || 0));
|
|
84
|
+
const byUser = new Map();
|
|
85
|
+
for (const s of shares) {
|
|
86
|
+
if (!byUser.has(s.user)) byUser.set(s.user, { name: s.name, items: [] });
|
|
87
|
+
byUser.get(s.user).items.push(s);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Me first
|
|
91
|
+
const order = [...byUser.keys()].sort((x, y) => (x === b.me.slug ? -1 : y === b.me.slug ? 1 : 0));
|
|
92
|
+
|
|
93
|
+
const ownShared = byUser.has(b.me.slug) && byUser.get(b.me.slug).items.some((s) => s.branch === b.ownBranch);
|
|
94
|
+
if (b.ownBranch && !ownShared) {
|
|
95
|
+
const label = document.createElement('div');
|
|
96
|
+
label.className = 'rail-section-label';
|
|
97
|
+
label.textContent = 'you';
|
|
98
|
+
frag.appendChild(label);
|
|
99
|
+
frag.appendChild(branchRow({
|
|
100
|
+
user: b.me.slug, branch: b.ownBranch, own: true,
|
|
101
|
+
time: b.heads[b.ownBranch] ? b.heads[b.ownBranch].time : null,
|
|
102
|
+
}));
|
|
103
|
+
const cta = document.createElement('button');
|
|
104
|
+
cta.className = 'share-cta';
|
|
105
|
+
cta.innerHTML = `${icons.plus} share ${escapeHtml(b.ownBranch)} with the board`;
|
|
106
|
+
cta.onclick = async () => {
|
|
107
|
+
await api('/api/share', { branch: b.ownBranch });
|
|
108
|
+
toast('Shared with the team board');
|
|
109
|
+
refresh();
|
|
110
|
+
};
|
|
111
|
+
frag.appendChild(cta);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const user of order) {
|
|
115
|
+
const group = byUser.get(user);
|
|
116
|
+
const label = document.createElement('div');
|
|
117
|
+
label.className = 'rail-section-label';
|
|
118
|
+
label.textContent = user === b.me.slug ? `${group.name} (you)` : group.name;
|
|
119
|
+
frag.appendChild(label);
|
|
120
|
+
for (const s of group.items) {
|
|
121
|
+
frag.appendChild(branchRow({
|
|
122
|
+
user: s.user, branch: s.branch,
|
|
123
|
+
time: b.heads[s.branch] ? b.heads[s.branch].time : s.updatedAt / 1000,
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (b.otherBranches.length) {
|
|
129
|
+
const label = document.createElement('div');
|
|
130
|
+
label.className = 'rail-section-label';
|
|
131
|
+
label.textContent = 'other branches';
|
|
132
|
+
frag.appendChild(label);
|
|
133
|
+
for (const o of b.otherBranches.slice(0, 12)) {
|
|
134
|
+
frag.appendChild(branchRow({
|
|
135
|
+
user: slugify(o.author), branch: o.branch, time: o.time, dim: true,
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
sections.replaceChildren(frag);
|
|
141
|
+
|
|
142
|
+
const sync = el('sync-status');
|
|
143
|
+
if (!b.sync.hasRemote) {
|
|
144
|
+
sync.innerHTML = '<span class="dot idle"></span> local only · no origin remote';
|
|
145
|
+
} else if (b.sync.lastSyncError) {
|
|
146
|
+
sync.innerHTML = '<span class="dot starting"></span> sync retrying…';
|
|
147
|
+
sync.title = b.sync.lastSyncError;
|
|
148
|
+
} else {
|
|
149
|
+
sync.innerHTML = '<span class="dot live"></span> synced through git';
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function branchRow({ user, branch, time, dim, own }) {
|
|
154
|
+
const b = state.board;
|
|
155
|
+
const key = `${user}/${branch}`;
|
|
156
|
+
const btn = document.createElement('button');
|
|
157
|
+
btn.className = 'branch-row' + (dim ? ' dim' : '');
|
|
158
|
+
const sel = state.selected;
|
|
159
|
+
if (sel && sel.user === user && sel.branch === branch) btn.classList.add('selected');
|
|
160
|
+
|
|
161
|
+
const prev = b.previews[key];
|
|
162
|
+
const isOwnServing = own || (user === b.me.slug && branch === b.ownBranch);
|
|
163
|
+
let dotCls = 'idle';
|
|
164
|
+
if (prev && prev.status === 'ready') dotCls = 'live';
|
|
165
|
+
else if (prev && prev.status === 'starting') dotCls = 'starting';
|
|
166
|
+
else if (isOwnServing && prev && prev.status === 'ready') dotCls = 'live';
|
|
167
|
+
|
|
168
|
+
const unresolved = openCount(branch);
|
|
169
|
+
btn.innerHTML = `
|
|
170
|
+
<span class="dot ${dotCls}"></span>
|
|
171
|
+
<span class="branch-label">${escapeHtml(branch)}</span>
|
|
172
|
+
${unresolved ? `<span class="badge">${unresolved}</span>` : ''}
|
|
173
|
+
<span class="meta">${timeAgo(time)}</span>`;
|
|
174
|
+
btn.onclick = () => select(user, branch);
|
|
175
|
+
return btn;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* ---------- selection + preview ---------- */
|
|
179
|
+
|
|
180
|
+
async function select(user, branch) {
|
|
181
|
+
state.selected = { user, branch };
|
|
182
|
+
state.commentMode = false;
|
|
183
|
+
state.pendingPin = null;
|
|
184
|
+
state.focusedComment = null;
|
|
185
|
+
state.frameUrl = null;
|
|
186
|
+
location.hash = `#/u/${encodeURIComponent(user)}/${encodeURIComponent(branch)}`;
|
|
187
|
+
el('frame').hidden = true;
|
|
188
|
+
el('frame').src = 'about:blank';
|
|
189
|
+
renderAll();
|
|
190
|
+
pollPreview();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let previewPollToken = 0;
|
|
194
|
+
async function pollPreview() {
|
|
195
|
+
const token = ++previewPollToken;
|
|
196
|
+
const sel = state.selected;
|
|
197
|
+
if (!sel) return;
|
|
198
|
+
while (token === previewPollToken && state.selected === sel) {
|
|
199
|
+
const r = await api('/api/preview/start', { user: sel.user, branch: sel.branch });
|
|
200
|
+
if (token !== previewPollToken) return;
|
|
201
|
+
state.previewStatus = r;
|
|
202
|
+
renderStage();
|
|
203
|
+
if (r.status === 'ready') return;
|
|
204
|
+
if (r.status === 'error') return;
|
|
205
|
+
await new Promise((ok) => setTimeout(ok, 1500));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function renderStage() {
|
|
210
|
+
const sel = state.selected;
|
|
211
|
+
const b = state.board;
|
|
212
|
+
const frame = el('frame');
|
|
213
|
+
const wrap = el('frame-wrap');
|
|
214
|
+
|
|
215
|
+
wrap.className = state.viewport === 'desktop' ? '' : `vw-${state.viewport}`;
|
|
216
|
+
|
|
217
|
+
el('frame-empty').hidden = !!sel;
|
|
218
|
+
const ps = state.previewStatus;
|
|
219
|
+
|
|
220
|
+
if (!sel) {
|
|
221
|
+
el('stage-title').textContent = 'pick a branch';
|
|
222
|
+
el('stage-meta').textContent = '';
|
|
223
|
+
el('frame-loading').hidden = true;
|
|
224
|
+
el('frame-error').hidden = true;
|
|
225
|
+
frame.hidden = true;
|
|
226
|
+
renderPins();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const share = b.shares.find((s) => s.user === sel.user && s.branch === sel.branch);
|
|
231
|
+
const authorName = share ? share.name : (sel.user === b.me.slug ? b.me.name : sel.user);
|
|
232
|
+
const head = b.heads[sel.branch];
|
|
233
|
+
el('stage-title').textContent = sel.branch;
|
|
234
|
+
el('stage-meta').textContent = `${authorName}${head ? ' · updated ' + timeAgo(head.time) : ''}${sel.user === b.me.slug && sel.branch === b.ownBranch ? ' · your working copy' : ''}`;
|
|
235
|
+
|
|
236
|
+
const loading = el('frame-loading');
|
|
237
|
+
const error = el('frame-error');
|
|
238
|
+
|
|
239
|
+
if (ps && ps.status === 'ready' && ps.url) {
|
|
240
|
+
loading.hidden = true;
|
|
241
|
+
error.hidden = true;
|
|
242
|
+
if (state.frameUrl !== ps.url) {
|
|
243
|
+
state.frameUrl = ps.url;
|
|
244
|
+
frame.src = ps.url;
|
|
245
|
+
}
|
|
246
|
+
frame.hidden = false;
|
|
247
|
+
} else if (ps && ps.status === 'error') {
|
|
248
|
+
loading.hidden = true;
|
|
249
|
+
error.hidden = false;
|
|
250
|
+
frame.hidden = true;
|
|
251
|
+
el('error-sub').textContent = ps.error || 'The preview process did not start.';
|
|
252
|
+
el('error-log').textContent = (ps.logTail || '').trim();
|
|
253
|
+
} else {
|
|
254
|
+
loading.hidden = false;
|
|
255
|
+
error.hidden = true;
|
|
256
|
+
frame.hidden = true;
|
|
257
|
+
const own = sel.user === b.me.slug && sel.branch === b.ownBranch;
|
|
258
|
+
el('loading-title').textContent = own ? 'Starting your preview' : `Starting ${sel.branch}`;
|
|
259
|
+
el('loading-sub').textContent = own
|
|
260
|
+
? 'Booting the dev server for your working copy.'
|
|
261
|
+
: 'Checking the branch out into a hidden worktree and booting its preview. First open can take a moment.';
|
|
262
|
+
el('loading-log').textContent = ps && ps.logTail ? ps.logTail.trim() : '';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const cm = el('btn-comment-mode');
|
|
266
|
+
cm.innerHTML = `${icons.message} <span>${state.commentMode ? 'Click to place pin' : 'Comment'}</span>`;
|
|
267
|
+
cm.classList.toggle('active-mode', state.commentMode);
|
|
268
|
+
el('pin-layer').classList.toggle('capture', state.commentMode);
|
|
269
|
+
|
|
270
|
+
renderPins();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/* ---------- pins ---------- */
|
|
274
|
+
|
|
275
|
+
function renderPins() {
|
|
276
|
+
const layer = el('pin-layer');
|
|
277
|
+
layer.querySelectorAll('.pin').forEach((p) => p.remove());
|
|
278
|
+
const sel = state.selected;
|
|
279
|
+
if (!sel || el('frame').hidden) return;
|
|
280
|
+
const comments = branchComments(sel.branch);
|
|
281
|
+
comments.forEach((c, i) => {
|
|
282
|
+
if (c.resolvedAt && state.focusedComment !== c.id) return;
|
|
283
|
+
if (c.xPct == null) return;
|
|
284
|
+
const pin = document.createElement('div');
|
|
285
|
+
pin.className = 'pin' + (c.resolvedAt ? ' resolved' : '') + (state.focusedComment === c.id ? ' focused' : '');
|
|
286
|
+
pin.style.left = c.xPct + '%';
|
|
287
|
+
pin.style.top = c.yPct + '%';
|
|
288
|
+
pin.innerHTML = `<span>${i + 1}</span>`;
|
|
289
|
+
pin.onclick = (e) => {
|
|
290
|
+
e.stopPropagation();
|
|
291
|
+
state.focusedComment = c.id;
|
|
292
|
+
renderComments();
|
|
293
|
+
renderPins();
|
|
294
|
+
const card = document.querySelector(`[data-comment="${c.id}"]`);
|
|
295
|
+
if (card) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
296
|
+
};
|
|
297
|
+
layer.appendChild(pin);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
el('pin-layer').addEventListener('click', (e) => {
|
|
302
|
+
if (!state.commentMode || !state.selected) return;
|
|
303
|
+
const rect = el('pin-layer').getBoundingClientRect();
|
|
304
|
+
const xPct = ((e.clientX - rect.left) / rect.width) * 100;
|
|
305
|
+
const yPct = ((e.clientY - rect.top) / rect.height) * 100;
|
|
306
|
+
state.pendingPin = { xPct, yPct };
|
|
307
|
+
const composer = el('composer');
|
|
308
|
+
composer.hidden = false;
|
|
309
|
+
composer.style.left = Math.min(xPct, 72) + '%';
|
|
310
|
+
composer.style.top = Math.min(yPct + 2, 80) + '%';
|
|
311
|
+
el('composer-text').value = '';
|
|
312
|
+
el('composer-text').focus();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
async function postComment() {
|
|
316
|
+
const text = el('composer-text').value.trim();
|
|
317
|
+
const sel = state.selected;
|
|
318
|
+
if (!text || !sel || !state.pendingPin) return;
|
|
319
|
+
const rect = el('pin-layer').getBoundingClientRect();
|
|
320
|
+
await api('/api/comments', {
|
|
321
|
+
targetUser: sel.user,
|
|
322
|
+
branch: sel.branch,
|
|
323
|
+
route: state.frameUrl ? new URL(state.frameUrl).pathname : '/',
|
|
324
|
+
xPct: state.pendingPin.xPct,
|
|
325
|
+
yPct: state.pendingPin.yPct,
|
|
326
|
+
viewportLabel: state.viewport,
|
|
327
|
+
viewportW: Math.round(rect.width),
|
|
328
|
+
viewportH: Math.round(rect.height),
|
|
329
|
+
text,
|
|
330
|
+
});
|
|
331
|
+
closeComposer();
|
|
332
|
+
state.commentMode = false;
|
|
333
|
+
await refresh();
|
|
334
|
+
toast('Pinned. The author will see it on their board.');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function closeComposer() {
|
|
338
|
+
el('composer').hidden = true;
|
|
339
|
+
state.pendingPin = null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
el('composer-post').onclick = postComment;
|
|
343
|
+
el('composer-cancel').onclick = () => { closeComposer(); };
|
|
344
|
+
el('composer-text').addEventListener('keydown', (e) => {
|
|
345
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') postComment();
|
|
346
|
+
if (e.key === 'Escape') closeComposer();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
/* ---------- comments panel ---------- */
|
|
350
|
+
|
|
351
|
+
function renderComments() {
|
|
352
|
+
const sel = state.selected;
|
|
353
|
+
const list = el('comments-list');
|
|
354
|
+
if (!sel) {
|
|
355
|
+
el('comments-count').textContent = '';
|
|
356
|
+
list.innerHTML = '<div class="empty-note">Comments for the selected branch land here, pinned to the exact spot and commit.</div>';
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const comments = branchComments(sel.branch);
|
|
360
|
+
const open = comments.filter((c) => !c.resolvedAt);
|
|
361
|
+
const resolved = comments.filter((c) => c.resolvedAt);
|
|
362
|
+
el('comments-count').textContent = open.length ? `${open.length} open` : '';
|
|
363
|
+
|
|
364
|
+
const frag = document.createDocumentFragment();
|
|
365
|
+
if (!comments.length) {
|
|
366
|
+
const d = document.createElement('div');
|
|
367
|
+
d.className = 'empty-note';
|
|
368
|
+
d.textContent = 'No comments yet. Use Comment, then click anywhere on the preview to pin one.';
|
|
369
|
+
frag.appendChild(d);
|
|
370
|
+
}
|
|
371
|
+
comments.forEach((c, i) => {
|
|
372
|
+
if (!c.resolvedAt) frag.appendChild(commentCard(c, i + 1));
|
|
373
|
+
});
|
|
374
|
+
if (resolved.length) {
|
|
375
|
+
const note = document.createElement('div');
|
|
376
|
+
note.className = 'section-note';
|
|
377
|
+
note.textContent = `resolved (${resolved.length})`;
|
|
378
|
+
frag.appendChild(note);
|
|
379
|
+
comments.forEach((c, i) => {
|
|
380
|
+
if (c.resolvedAt) frag.appendChild(commentCard(c, i + 1));
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
list.replaceChildren(frag);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function commentCard(c, num) {
|
|
387
|
+
const b = state.board;
|
|
388
|
+
const card = document.createElement('div');
|
|
389
|
+
card.className = 'comment-card' + (c.resolvedAt ? ' resolved' : '') + (state.focusedComment === c.id ? ' focused' : '');
|
|
390
|
+
card.dataset.comment = c.id;
|
|
391
|
+
|
|
392
|
+
const head = b.heads[c.branch];
|
|
393
|
+
const changed = head && c.commit && head.sha !== c.commit;
|
|
394
|
+
const initials = (c.name || c.user || '?').slice(0, 1);
|
|
395
|
+
|
|
396
|
+
card.innerHTML = `
|
|
397
|
+
<div class="comment-head">
|
|
398
|
+
<span class="avatar">${escapeHtml(initials)}</span>
|
|
399
|
+
<span class="comment-author">${escapeHtml(c.name || c.user)}</span>
|
|
400
|
+
<span class="comment-num">${num}</span>
|
|
401
|
+
</div>
|
|
402
|
+
<div class="comment-text">${escapeHtml(c.text)}</div>
|
|
403
|
+
<div class="comment-meta">
|
|
404
|
+
<span>${timeAgo(c.createdAt / 1000)}</span>
|
|
405
|
+
${c.viewportLabel ? `<span>· ${escapeHtml(c.viewportLabel)}</span>` : ''}
|
|
406
|
+
${c.commit ? `<span>· ${escapeHtml(c.commit.slice(0, 7))}</span>` : ''}
|
|
407
|
+
${changed && !c.resolvedAt ? '<span class="chip changed"><span class="dot starting"></span>updated since pin</span>' : ''}
|
|
408
|
+
${c.resolvedAt ? `<span class="chip">${icons.check} resolved</span>` : ''}
|
|
409
|
+
</div>
|
|
410
|
+
${(c.replies || []).length ? `<div class="comment-replies">${c.replies.map((r) => `<div class="reply"><b>${escapeHtml(r.name || r.user)}</b> ${escapeHtml(r.text)}</div>`).join('')}</div>` : ''}
|
|
411
|
+
`;
|
|
412
|
+
|
|
413
|
+
const actions = document.createElement('div');
|
|
414
|
+
actions.className = 'comment-actions';
|
|
415
|
+
if (!c.resolvedAt) {
|
|
416
|
+
const resolveBtn = document.createElement('button');
|
|
417
|
+
resolveBtn.className = 'link-btn resolve';
|
|
418
|
+
resolveBtn.textContent = changed ? 'Mark fixed' : 'Resolve';
|
|
419
|
+
resolveBtn.onclick = async () => { await api(`/api/comments/${c.id}/resolve`, { resolved: true }); refresh(); };
|
|
420
|
+
actions.appendChild(resolveBtn);
|
|
421
|
+
} else {
|
|
422
|
+
const reopenBtn = document.createElement('button');
|
|
423
|
+
reopenBtn.className = 'link-btn';
|
|
424
|
+
reopenBtn.textContent = 'Reopen';
|
|
425
|
+
reopenBtn.onclick = async () => { await api(`/api/comments/${c.id}/resolve`, { resolved: false }); refresh(); };
|
|
426
|
+
actions.appendChild(reopenBtn);
|
|
427
|
+
}
|
|
428
|
+
card.appendChild(actions);
|
|
429
|
+
|
|
430
|
+
const reply = document.createElement('input');
|
|
431
|
+
reply.className = 'reply-input';
|
|
432
|
+
reply.placeholder = 'Reply…';
|
|
433
|
+
reply.addEventListener('keydown', async (e) => {
|
|
434
|
+
if (e.key === 'Enter' && reply.value.trim()) {
|
|
435
|
+
await api(`/api/comments/${c.id}/replies`, { text: reply.value.trim() });
|
|
436
|
+
reply.value = '';
|
|
437
|
+
refresh();
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
card.appendChild(reply);
|
|
441
|
+
|
|
442
|
+
card.onclick = (e) => {
|
|
443
|
+
if (e.target === reply || e.target.tagName === 'BUTTON') return;
|
|
444
|
+
state.focusedComment = c.id;
|
|
445
|
+
renderPins();
|
|
446
|
+
document.querySelectorAll('.comment-card').forEach((x) => x.classList.remove('focused'));
|
|
447
|
+
card.classList.add('focused');
|
|
448
|
+
};
|
|
449
|
+
return card;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/* ---------- header controls ---------- */
|
|
453
|
+
|
|
454
|
+
function renderViewportSeg() {
|
|
455
|
+
const seg = el('viewport-seg');
|
|
456
|
+
seg.innerHTML = '';
|
|
457
|
+
for (const v of VIEWPORTS) {
|
|
458
|
+
const btn = document.createElement('button');
|
|
459
|
+
btn.innerHTML = icons[v.icon];
|
|
460
|
+
btn.title = v.title;
|
|
461
|
+
btn.className = state.viewport === v.key ? 'active' : '';
|
|
462
|
+
btn.onclick = () => { state.viewport = v.key; renderViewportSeg(); renderStage(); };
|
|
463
|
+
seg.appendChild(btn);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
el('btn-comment-mode').onclick = () => {
|
|
468
|
+
if (!state.selected || el('frame').hidden) { toast('Open a preview first'); return; }
|
|
469
|
+
state.commentMode = !state.commentMode;
|
|
470
|
+
if (!state.commentMode) closeComposer();
|
|
471
|
+
renderStage();
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
el('btn-retry').onclick = () => pollPreview();
|
|
475
|
+
|
|
476
|
+
document.addEventListener('keydown', (e) => {
|
|
477
|
+
if (e.key === 'Escape') {
|
|
478
|
+
if (!el('composer').hidden) closeComposer();
|
|
479
|
+
else if (state.commentMode) { state.commentMode = false; renderStage(); }
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
/* ---------- boot + polling ---------- */
|
|
484
|
+
|
|
485
|
+
function escapeHtml(s) {
|
|
486
|
+
return String(s ?? '').replace(/[&<>"']/g, (ch) => ({
|
|
487
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
488
|
+
}[ch]));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function renderAll() {
|
|
492
|
+
if (!state.board) return;
|
|
493
|
+
renderRail();
|
|
494
|
+
renderStage();
|
|
495
|
+
renderComments();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function refresh() {
|
|
499
|
+
try {
|
|
500
|
+
const board = await fetch('/api/board').then((r) => r.json());
|
|
501
|
+
state.board = board;
|
|
502
|
+
if (!isTyping() && el('composer').hidden) {
|
|
503
|
+
renderAll();
|
|
504
|
+
} else {
|
|
505
|
+
renderStage();
|
|
506
|
+
}
|
|
507
|
+
} catch { /* server restarting */ }
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function parseHash() {
|
|
511
|
+
const m = location.hash.match(/^#\/u\/([^/]+)\/(.+)$/);
|
|
512
|
+
if (m) return { user: decodeURIComponent(m[1]), branch: decodeURIComponent(m[2]) };
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
(async function boot() {
|
|
517
|
+
renderViewportSeg();
|
|
518
|
+
await refresh();
|
|
519
|
+
const target = parseHash();
|
|
520
|
+
if (target) {
|
|
521
|
+
select(target.user, target.branch);
|
|
522
|
+
} else if (state.board && state.board.ownBranch) {
|
|
523
|
+
select(state.board.me.slug, state.board.ownBranch);
|
|
524
|
+
}
|
|
525
|
+
setInterval(refresh, 3000);
|
|
526
|
+
})();
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>design-share</title>
|
|
7
|
+
<link rel="stylesheet" href="/style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app">
|
|
11
|
+
<aside id="rail">
|
|
12
|
+
<div class="rail-head">
|
|
13
|
+
<span class="logo-mark"></span>
|
|
14
|
+
<span id="repo-name" class="repo-name">…</span>
|
|
15
|
+
</div>
|
|
16
|
+
<div id="rail-sections"></div>
|
|
17
|
+
<div class="rail-foot">
|
|
18
|
+
<div id="sync-status" class="sync-status"></div>
|
|
19
|
+
</div>
|
|
20
|
+
</aside>
|
|
21
|
+
|
|
22
|
+
<main id="stage">
|
|
23
|
+
<header id="stage-head">
|
|
24
|
+
<div class="stage-title-wrap">
|
|
25
|
+
<span id="stage-title" class="stage-title">pick a branch</span>
|
|
26
|
+
<span id="stage-meta" class="stage-meta"></span>
|
|
27
|
+
</div>
|
|
28
|
+
<div class="stage-actions">
|
|
29
|
+
<div class="seg" id="viewport-seg"></div>
|
|
30
|
+
<button class="btn" id="btn-comment-mode"></button>
|
|
31
|
+
</div>
|
|
32
|
+
</header>
|
|
33
|
+
<div id="frame-area">
|
|
34
|
+
<div id="frame-wrap">
|
|
35
|
+
<div id="frame-empty" class="frame-state">
|
|
36
|
+
<div class="frame-state-title">No branch selected</div>
|
|
37
|
+
<div class="frame-state-sub">Pick a branch from the left rail to open its live preview.</div>
|
|
38
|
+
</div>
|
|
39
|
+
<div id="frame-loading" class="frame-state" hidden>
|
|
40
|
+
<div class="spinner"></div>
|
|
41
|
+
<div class="frame-state-title" id="loading-title">Starting preview</div>
|
|
42
|
+
<div class="frame-state-sub" id="loading-sub">Checking out the branch and booting its dev server.</div>
|
|
43
|
+
<pre id="loading-log" class="log-tail"></pre>
|
|
44
|
+
</div>
|
|
45
|
+
<div id="frame-error" class="frame-state" hidden>
|
|
46
|
+
<div class="frame-state-title err">Preview could not start</div>
|
|
47
|
+
<div class="frame-state-sub" id="error-sub"></div>
|
|
48
|
+
<pre id="error-log" class="log-tail"></pre>
|
|
49
|
+
<button class="btn" id="btn-retry">Try again</button>
|
|
50
|
+
</div>
|
|
51
|
+
<iframe id="frame" title="branch preview" hidden></iframe>
|
|
52
|
+
<div id="pin-layer"></div>
|
|
53
|
+
<div id="composer" hidden>
|
|
54
|
+
<textarea id="composer-text" rows="2" placeholder="Leave a note for the author…"></textarea>
|
|
55
|
+
<div class="composer-actions">
|
|
56
|
+
<button class="btn small" id="composer-post">Post</button>
|
|
57
|
+
<span class="kbd-hint">⌘↵</span>
|
|
58
|
+
<button class="btn ghost small" id="composer-cancel">Cancel</button>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</main>
|
|
64
|
+
|
|
65
|
+
<aside id="comments-panel">
|
|
66
|
+
<div class="panel-head">
|
|
67
|
+
<span>Comments</span>
|
|
68
|
+
<span id="comments-count" class="count"></span>
|
|
69
|
+
</div>
|
|
70
|
+
<div id="comments-list"></div>
|
|
71
|
+
</aside>
|
|
72
|
+
</div>
|
|
73
|
+
<script src="/app.js"></script>
|
|
74
|
+
</body>
|
|
75
|
+
</html>
|