@milesroxas/design-hub 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +80 -2
- package/client.d.ts +6 -0
- package/configs/biome/biome.json +64 -0
- package/configs/tsconfig.project.json +17 -0
- package/core/bytes.test.ts +14 -0
- package/core/bytes.ts +14 -0
- package/core/catalog.test.ts +44 -0
- package/core/catalog.ts +43 -0
- package/core/checks.test.ts +23 -0
- package/core/checks.ts +72 -0
- package/core/contacts.test.ts +31 -0
- package/core/contacts.ts +38 -0
- package/core/doc.ts +4 -0
- package/core/feature.ts +6 -0
- package/core/fixtures.test.ts +33 -0
- package/core/fixtures.ts +69 -0
- package/core/frontmatter.ts +15 -0
- package/core/html.ts +3 -0
- package/core/image.test.ts +22 -0
- package/core/image.ts +37 -0
- package/core/jev.test.ts +66 -0
- package/core/jev.ts +77 -0
- package/core/review/model.ts +189 -0
- package/core/review/policy.ts +26 -0
- package/core/review/service.test.ts +163 -0
- package/core/review/service.ts +309 -0
- package/core/review/store.ts +51 -0
- package/core/review/triage.test.ts +41 -0
- package/core/review/triage.ts +58 -0
- package/core/states.test.ts +36 -0
- package/core/states.ts +34 -0
- package/dist/core/bytes.d.ts +5 -0
- package/dist/core/bytes.js +9 -0
- package/dist/core/catalog.d.ts +17 -0
- package/dist/core/catalog.js +24 -0
- package/dist/core/checks.d.ts +41 -0
- package/dist/core/checks.js +53 -0
- package/dist/core/contacts.d.ts +9 -0
- package/dist/core/contacts.js +34 -0
- package/dist/core/doc.d.ts +11 -0
- package/dist/core/doc.js +1 -0
- package/dist/core/feature.d.ts +3 -0
- package/dist/core/feature.js +4 -0
- package/dist/core/fixtures.d.ts +19 -0
- package/dist/core/fixtures.js +52 -0
- package/dist/core/frontmatter.d.ts +4 -0
- package/dist/core/frontmatter.js +15 -0
- package/dist/core/html.d.ts +2 -0
- package/dist/core/html.js +2 -0
- package/dist/core/image.d.ts +6 -0
- package/dist/core/image.js +34 -0
- package/dist/core/jev.d.ts +54 -0
- package/dist/core/jev.js +52 -0
- package/dist/core/review/model.d.ts +85 -0
- package/dist/core/review/model.js +112 -0
- package/dist/core/review/policy.d.ts +3 -0
- package/dist/core/review/policy.js +19 -0
- package/dist/core/review/service.d.ts +79 -0
- package/dist/core/review/service.js +245 -0
- package/dist/core/review/store.d.ts +20 -0
- package/dist/core/review/store.js +37 -0
- package/dist/core/review/triage.d.ts +6 -0
- package/dist/core/review/triage.js +45 -0
- package/dist/core/states.d.ts +6 -0
- package/dist/core/states.js +29 -0
- package/dist/scripts/bootstrap.d.ts +51 -0
- package/dist/scripts/bootstrap.js +278 -0
- package/dist/scripts/calibrate.d.ts +127 -0
- package/dist/scripts/calibrate.js +164 -0
- package/dist/scripts/check.d.ts +25 -0
- package/dist/scripts/check.js +30 -0
- package/dist/scripts/cli.d.ts +2 -0
- package/dist/scripts/cli.js +73 -0
- package/dist/scripts/commands.d.ts +25 -0
- package/dist/scripts/commands.js +49 -0
- package/dist/scripts/comments.d.ts +63 -0
- package/dist/scripts/comments.js +152 -0
- package/dist/scripts/dev.d.ts +46 -0
- package/dist/scripts/dev.js +84 -0
- package/dist/scripts/elyx.d.ts +61 -0
- package/dist/scripts/elyx.js +195 -0
- package/dist/scripts/fixtures-pull.d.ts +60 -0
- package/dist/scripts/fixtures-pull.js +121 -0
- package/dist/scripts/guard.d.ts +58 -0
- package/dist/scripts/guard.js +201 -0
- package/dist/scripts/hook.d.ts +18 -0
- package/dist/scripts/hook.js +46 -0
- package/dist/scripts/lib/bin.d.ts +4 -0
- package/dist/scripts/lib/bin.js +20 -0
- package/dist/scripts/lib/catalog.d.ts +7 -0
- package/dist/scripts/lib/catalog.js +18 -0
- package/dist/scripts/lib/cli.d.ts +75 -0
- package/dist/scripts/lib/cli.js +88 -0
- package/dist/scripts/lib/css-tokens.d.ts +13 -0
- package/dist/scripts/lib/css-tokens.js +67 -0
- package/dist/scripts/lib/judge.d.ts +24 -0
- package/dist/scripts/lib/judge.js +60 -0
- package/dist/scripts/lib/ports.d.ts +35 -0
- package/dist/scripts/lib/ports.js +113 -0
- package/dist/scripts/lib/review.d.ts +7 -0
- package/dist/scripts/lib/review.js +22 -0
- package/dist/scripts/lib/rules.d.ts +12 -0
- package/dist/scripts/lib/rules.js +20 -0
- package/dist/scripts/lib/vercel.d.ts +17 -0
- package/dist/scripts/lib/vercel.js +53 -0
- package/dist/scripts/new.d.ts +47 -0
- package/dist/scripts/new.js +113 -0
- package/dist/scripts/people.d.ts +59 -0
- package/dist/scripts/people.js +73 -0
- package/dist/scripts/preflight.d.ts +35 -0
- package/dist/scripts/preflight.js +251 -0
- package/dist/scripts/release-check.d.ts +19 -0
- package/dist/scripts/release-check.js +39 -0
- package/dist/scripts/skill-hint.d.ts +32 -0
- package/dist/scripts/skill-hint.js +103 -0
- package/dist/scripts/skills.d.ts +13 -0
- package/dist/scripts/skills.js +34 -0
- package/dist/scripts/visual-match.d.ts +46 -0
- package/dist/scripts/visual-match.js +93 -0
- package/dist/server/api.d.ts +22 -0
- package/dist/server/api.js +143 -0
- package/dist/server/checks.d.ts +4 -0
- package/dist/server/checks.js +22 -0
- package/dist/server/dev.d.ts +2 -0
- package/dist/server/dev.js +54 -0
- package/dist/server/env.d.ts +8 -0
- package/dist/server/env.js +57 -0
- package/dist/server/fake-upstash.d.ts +1 -0
- package/dist/server/fake-upstash.js +37 -0
- package/dist/server/file-kv.d.ts +2 -0
- package/dist/server/file-kv.js +24 -0
- package/dist/server/local.d.ts +4 -0
- package/dist/server/local.js +25 -0
- package/dist/server/paths.d.ts +22 -0
- package/dist/server/paths.js +81 -0
- package/dist/server/session.d.ts +22 -0
- package/dist/server/session.js +110 -0
- package/dist/server/upstash.d.ts +7 -0
- package/dist/server/upstash.js +41 -0
- package/dist/vercel/index.d.ts +5 -0
- package/dist/vercel/index.js +12 -0
- package/dist/vercel/proxy.d.ts +11 -0
- package/dist/vercel/proxy.js +238 -0
- package/dist/vite/kit.d.ts +4 -0
- package/dist/vite/kit.js +35 -0
- package/dist/vite/markdown.d.ts +2 -0
- package/dist/vite/markdown.js +212 -0
- package/dist/vite/plugin.d.ts +15 -0
- package/dist/vite/plugin.js +84 -0
- package/dist/vite/registry.d.ts +7 -0
- package/dist/vite/registry.js +70 -0
- package/kit/Icon.tsx +39 -0
- package/kit/Space.tsx +25 -0
- package/kit/Variants.tsx +134 -0
- package/kit/ViewTools.tsx +34 -0
- package/kit/base.css +27 -0
- package/kit/dials.css +98 -0
- package/kit/dials.tsx +322 -0
- package/kit/fixtures.ts +3 -0
- package/kit/format.ts +13 -0
- package/kit/harness.css +99 -0
- package/kit/hooks.ts +35 -0
- package/kit/icons.ts +329 -0
- package/kit/motion.ts +18 -0
- package/kit/prefs.ts +111 -0
- package/kit/space.css +12 -0
- package/kit/ui/Button.tsx +135 -0
- package/kit/ui/Menu.tsx +386 -0
- package/kit/ui/Overlay.tsx +300 -0
- package/kit/ui/Toast.tsx +187 -0
- package/kit/ui/index.ts +13 -0
- package/kit/ui/layer.ts +51 -0
- package/kit/ui/ui.css +238 -0
- package/package.json +87 -3
- package/shell/Guide.tsx +160 -0
- package/shell/Index.tsx +301 -0
- package/shell/Notice.tsx +27 -0
- package/shell/Prototype.tsx +31 -0
- package/shell/a11y.ts +50 -0
- package/shell/doc.d.ts +7 -0
- package/shell/doc.ts +3 -0
- package/shell/guide.css +133 -0
- package/shell/index.html +17 -0
- package/shell/main.tsx +53 -0
- package/shell/public/favicon.svg +1 -0
- package/shell/registry.ts +21 -0
- package/shell/registry.virtual.d.ts +10 -0
- package/shell/review/Review.tsx +989 -0
- package/shell/review/anchor.ts +132 -0
- package/shell/review/client.ts +165 -0
- package/shell/review/mount.tsx +12 -0
- package/shell/review/review.css +296 -0
- package/shell/shell.css +160 -0
- package/shell/system.css +238 -0
- package/skills/miles-conductor/SKILL.md +37 -0
- package/skills/miles-deploy/SKILL.md +67 -0
- package/skills/miles-design-hub/SKILL.md +62 -0
- package/skills/miles-get-hostile/SKILL.md +61 -0
- package/skills/miles-new-feature/SKILL.md +40 -0
- package/skills/miles-paper-to-prototype/SKILL.md +46 -0
- package/skills/miles-port/SKILL.md +59 -0
- package/skills/miles-port/references/astro.md +35 -0
- package/skills/miles-port/references/next.md +36 -0
- package/skills/miles-prototype/SKILL.md +83 -0
- package/skills/miles-prototype/references/accessibility.md +48 -0
- package/skills/miles-prototype/references/motion.md +59 -0
- package/skills/miles-prototype/references/performance.md +45 -0
- package/skills/miles-review/SKILL.md +39 -0
- package/templates/app/README.md +41 -0
- package/templates/app/index.tsx +32 -0
- package/templates/app/styles.css +1 -0
- package/templates/conductor/settings.toml +29 -0
- package/templates/guide.md +38 -0
- package/templates/space/README.md +37 -0
- package/templates/space/index.tsx +43 -0
- package/templates/space/styles.css +13 -0
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
// The review layer on a deployed prototype: a Comments button, a side panel
|
|
2
|
+
// with the threads for this prototype, pins on the elements they are about,
|
|
3
|
+
// and, for editors, the people who may comment. It is harness, not product:
|
|
4
|
+
// it wears the harness material and never borrows the prototype's chrome.
|
|
5
|
+
import { Icon } from '@kit/Icon';
|
|
6
|
+
import {
|
|
7
|
+
type FormEvent,
|
|
8
|
+
type KeyboardEvent,
|
|
9
|
+
type RefObject,
|
|
10
|
+
useEffect,
|
|
11
|
+
useMemo,
|
|
12
|
+
useReducer,
|
|
13
|
+
useRef,
|
|
14
|
+
useState,
|
|
15
|
+
} from 'react';
|
|
16
|
+
import {
|
|
17
|
+
type Anchor,
|
|
18
|
+
type Comment,
|
|
19
|
+
INVITE_DAYS,
|
|
20
|
+
KINDS,
|
|
21
|
+
type Kind,
|
|
22
|
+
LIMITS,
|
|
23
|
+
type Role,
|
|
24
|
+
} from '../../core/review/model';
|
|
25
|
+
import { captureAnchor, currentUrl, isHarness, openAnchor, pinPoint, resumeAnchor } from './anchor';
|
|
26
|
+
import { createReviewStore, type Me, type ReviewStore, useReviewState } from './client';
|
|
27
|
+
import './review.css';
|
|
28
|
+
|
|
29
|
+
type Props = { prototype: string; feature: string; states: string[] };
|
|
30
|
+
type Draft = { target: Element | null; point: { x: number; y: number } | null };
|
|
31
|
+
|
|
32
|
+
const KIND_LABEL: Record<Kind, string> = { bug: 'Bug', taste: 'Taste', copy: 'Copy' };
|
|
33
|
+
const relative = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
|
|
34
|
+
const STEPS: [Intl.RelativeTimeFormatUnit, number][] = [
|
|
35
|
+
['year', 31_536_000],
|
|
36
|
+
['month', 2_592_000],
|
|
37
|
+
['week', 604_800],
|
|
38
|
+
['day', 86_400],
|
|
39
|
+
['hour', 3600],
|
|
40
|
+
['minute', 60],
|
|
41
|
+
];
|
|
42
|
+
function ago(iso: string): string {
|
|
43
|
+
const seconds = (Date.parse(iso) - Date.now()) / 1000;
|
|
44
|
+
for (const [unit, size] of STEPS)
|
|
45
|
+
if (Math.abs(seconds) >= size) return relative.format(Math.round(seconds / size), unit);
|
|
46
|
+
return 'just now';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const describe = (el: Element | null) => {
|
|
50
|
+
if (!el) return 'the whole page';
|
|
51
|
+
const text = (el.getAttribute('aria-label') || el.textContent || '').trim().replace(/\s+/g, ' ');
|
|
52
|
+
return text ? `“${text.length > 40 ? `${text.slice(0, 40)}…` : text}”` : `a ${el.localName} element`;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// The store shows every failure in the panel, so a click handler has nothing left to do with one.
|
|
56
|
+
const act = (pending: Promise<unknown>) => void pending.catch(() => {});
|
|
57
|
+
|
|
58
|
+
const submitOnModEnter = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
59
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
e.currentTarget.form?.requestSubmit();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* What the layer shows: closed, the panel, or the page while an element is
|
|
67
|
+
* being picked. One reducer, so a transition (pick, back to the panel, close)
|
|
68
|
+
* changes every field it touches at once and no two fields can disagree.
|
|
69
|
+
*/
|
|
70
|
+
type View = { mode: 'closed' | 'panel' | 'picking'; tab: Tab; draft: Draft | null; focus: string | null };
|
|
71
|
+
type ViewAction =
|
|
72
|
+
| { type: 'open'; focus?: string }
|
|
73
|
+
| { type: 'close' }
|
|
74
|
+
| { type: 'pick' }
|
|
75
|
+
| { type: 'picked'; draft: Draft | null }
|
|
76
|
+
| { type: 'tab'; tab: Tab }
|
|
77
|
+
| { type: 'drafted' };
|
|
78
|
+
|
|
79
|
+
function view(state: View, action: ViewAction): View {
|
|
80
|
+
switch (action.type) {
|
|
81
|
+
case 'open':
|
|
82
|
+
return { ...state, mode: 'panel', focus: action.focus ?? state.focus };
|
|
83
|
+
case 'close':
|
|
84
|
+
return { ...state, mode: 'closed', draft: null };
|
|
85
|
+
case 'pick':
|
|
86
|
+
return { ...state, mode: 'picking' };
|
|
87
|
+
case 'picked':
|
|
88
|
+
return { ...state, mode: 'panel', draft: action.draft };
|
|
89
|
+
case 'tab':
|
|
90
|
+
return { ...state, tab: action.tab };
|
|
91
|
+
case 'drafted':
|
|
92
|
+
return { ...state, draft: null };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const INITIAL_VIEW: View = { mode: 'closed', tab: 'comments', draft: null, focus: null };
|
|
97
|
+
|
|
98
|
+
export function Review({ prototype, feature, states }: Props) {
|
|
99
|
+
const store = useMemo(() => createReviewStore(prototype), [prototype]);
|
|
100
|
+
const s = useReviewState(store);
|
|
101
|
+
const [v, dispatch] = useReducer(view, INITIAL_VIEW);
|
|
102
|
+
const url = useCurrentUrl();
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
void store.load();
|
|
106
|
+
resumeAnchor((id) => dispatch({ type: 'open', focus: id }));
|
|
107
|
+
}, [store]);
|
|
108
|
+
|
|
109
|
+
const shown = s.status !== 'off' && s.status !== 'loading';
|
|
110
|
+
useToastLift(shown);
|
|
111
|
+
if (!shown) return null;
|
|
112
|
+
|
|
113
|
+
const close = () => dispatch({ type: 'close' });
|
|
114
|
+
const roots = s.comments.filter((c) => c.thread === c.id);
|
|
115
|
+
const number = new Map(roots.map((c, i) => [c.id, i + 1]));
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div data-harness="review" className="rv">
|
|
119
|
+
{v.mode === 'picking' ? (
|
|
120
|
+
<PickLayer
|
|
121
|
+
onPick={(target, point) => dispatch({ type: 'picked', draft: { target, point } })}
|
|
122
|
+
onPage={() => dispatch({ type: 'picked', draft: { target: null, point: null } })}
|
|
123
|
+
onCancel={() => dispatch({ type: 'picked', draft: null })}
|
|
124
|
+
/>
|
|
125
|
+
) : (
|
|
126
|
+
<Launcher
|
|
127
|
+
open={v.mode === 'panel'}
|
|
128
|
+
openCount={roots.filter((c) => c.status === 'open').length}
|
|
129
|
+
onClick={() => (v.mode === 'panel' ? close() : dispatch({ type: 'open' }))}
|
|
130
|
+
/>
|
|
131
|
+
)}
|
|
132
|
+
{s.status === 'ready' && (
|
|
133
|
+
<Pins
|
|
134
|
+
comments={roots.filter((c) => c.status === 'open' && c.anchor?.url === url)}
|
|
135
|
+
number={number}
|
|
136
|
+
draft={v.draft}
|
|
137
|
+
onOpen={(id) => dispatch({ type: 'open', focus: id })}
|
|
138
|
+
/>
|
|
139
|
+
)}
|
|
140
|
+
{v.mode === 'panel' && (
|
|
141
|
+
<Panel
|
|
142
|
+
store={store}
|
|
143
|
+
state={s}
|
|
144
|
+
tab={s.me?.role === 'editor' ? v.tab : null}
|
|
145
|
+
onTab={(tab) => {
|
|
146
|
+
dispatch({ type: 'tab', tab });
|
|
147
|
+
if (tab === 'people' && !s.people) act(store.loadPeople());
|
|
148
|
+
}}
|
|
149
|
+
onClose={close}
|
|
150
|
+
threads={{
|
|
151
|
+
store,
|
|
152
|
+
me: s.me,
|
|
153
|
+
roots,
|
|
154
|
+
replies: s.comments.filter((c) => c.thread !== c.id),
|
|
155
|
+
number,
|
|
156
|
+
states,
|
|
157
|
+
focus: v.focus,
|
|
158
|
+
draft: v.draft,
|
|
159
|
+
onNew: () => dispatch({ type: 'pick' }),
|
|
160
|
+
onCancelDraft: () => dispatch({ type: 'drafted' }),
|
|
161
|
+
onPost: async (body) => {
|
|
162
|
+
const { target = null, point = null } = v.draft ?? {};
|
|
163
|
+
await store.add(body, captureAnchor({ prototype, feature }, target, point), states);
|
|
164
|
+
dispatch({ type: 'drafted' });
|
|
165
|
+
},
|
|
166
|
+
// A changed hash reaches useCurrentUrl through hashchange; the panel only moves its focus.
|
|
167
|
+
onShow: (anchor, id) => openAnchor(anchor, id, () => dispatch({ type: 'open', focus: id })),
|
|
168
|
+
}}
|
|
169
|
+
/>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The page's path, query and hash, kept current as hash routes change. */
|
|
176
|
+
function useCurrentUrl(): string {
|
|
177
|
+
const [url, setUrl] = useState(currentUrl);
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
const onRoute = () => setUrl(currentUrl());
|
|
180
|
+
addEventListener('hashchange', onRoute);
|
|
181
|
+
return () => removeEventListener('hashchange', onRoute);
|
|
182
|
+
}, []);
|
|
183
|
+
return url;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The launcher sits where toasts land; lift them above it while it is on the page.
|
|
187
|
+
function useToastLift(active: boolean) {
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (!active) return;
|
|
190
|
+
document.documentElement.style.setProperty('--toast-lift', '56px');
|
|
191
|
+
return () => {
|
|
192
|
+
document.documentElement.style.removeProperty('--toast-lift');
|
|
193
|
+
};
|
|
194
|
+
}, [active]);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Focuses the panel's first autofocus control, preferring the body's, whenever `when` turns true. */
|
|
198
|
+
function useAutofocus(panel: RefObject<HTMLElement | null>, when: boolean) {
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (!when) return;
|
|
201
|
+
const p = panel.current;
|
|
202
|
+
(
|
|
203
|
+
p?.querySelector<HTMLElement>('.rv-body [data-autofocus]') ??
|
|
204
|
+
p?.querySelector<HTMLElement>('[data-autofocus]')
|
|
205
|
+
)?.focus();
|
|
206
|
+
}, [panel, when]);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
type Tab = 'comments' | 'people';
|
|
210
|
+
|
|
211
|
+
function Launcher({ open, openCount, onClick }: { open: boolean; openCount: number; onClick: () => void }) {
|
|
212
|
+
const ref = useRef<HTMLButtonElement>(null);
|
|
213
|
+
const wasOpen = useRef(open);
|
|
214
|
+
// Closing the panel hands focus back to the button that opened it.
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
if (wasOpen.current && !open) ref.current?.focus();
|
|
217
|
+
wasOpen.current = open;
|
|
218
|
+
}, [open]);
|
|
219
|
+
return (
|
|
220
|
+
<button
|
|
221
|
+
ref={ref}
|
|
222
|
+
type="button"
|
|
223
|
+
className="rv-launch"
|
|
224
|
+
aria-expanded={open}
|
|
225
|
+
aria-controls="rv-panel"
|
|
226
|
+
onClick={onClick}
|
|
227
|
+
>
|
|
228
|
+
<Icon name="message-text-square-01" size={16} />
|
|
229
|
+
<span>Comments</span>
|
|
230
|
+
{openCount > 0 && (
|
|
231
|
+
<span className="rv-count">
|
|
232
|
+
{openCount}
|
|
233
|
+
<span className="rv-sr"> open</span>
|
|
234
|
+
</span>
|
|
235
|
+
)}
|
|
236
|
+
</button>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Tabs for an editor (tab is set), a plain title for everyone else (tab is null). */
|
|
241
|
+
function PanelHead({
|
|
242
|
+
tab,
|
|
243
|
+
onTab,
|
|
244
|
+
onClose,
|
|
245
|
+
}: {
|
|
246
|
+
tab: Tab | null;
|
|
247
|
+
onTab: (t: Tab) => void;
|
|
248
|
+
onClose: () => void;
|
|
249
|
+
}) {
|
|
250
|
+
return (
|
|
251
|
+
<header className="rv-head">
|
|
252
|
+
{tab ? (
|
|
253
|
+
<div className="rv-tabs" role="tablist" aria-label="Review">
|
|
254
|
+
{(['comments', 'people'] as const).map((t) => (
|
|
255
|
+
<button
|
|
256
|
+
key={t}
|
|
257
|
+
type="button"
|
|
258
|
+
role="tab"
|
|
259
|
+
aria-selected={tab === t}
|
|
260
|
+
data-autofocus={tab === t ? '' : undefined}
|
|
261
|
+
onClick={() => onTab(t)}
|
|
262
|
+
>
|
|
263
|
+
{t === 'comments' ? 'Comments' : 'People'}
|
|
264
|
+
</button>
|
|
265
|
+
))}
|
|
266
|
+
</div>
|
|
267
|
+
) : (
|
|
268
|
+
<h2 className="rv-title">Comments</h2>
|
|
269
|
+
)}
|
|
270
|
+
<button
|
|
271
|
+
type="button"
|
|
272
|
+
className="rv-icon"
|
|
273
|
+
aria-label="Close review"
|
|
274
|
+
onClick={onClose}
|
|
275
|
+
data-autofocus={tab ? undefined : ''}
|
|
276
|
+
>
|
|
277
|
+
<Icon name="x-close" size={16} />
|
|
278
|
+
</button>
|
|
279
|
+
</header>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
type ThreadsProps = Parameters<typeof Threads>[0];
|
|
284
|
+
|
|
285
|
+
function Panel({
|
|
286
|
+
store,
|
|
287
|
+
state,
|
|
288
|
+
tab,
|
|
289
|
+
onTab,
|
|
290
|
+
onClose,
|
|
291
|
+
threads,
|
|
292
|
+
}: {
|
|
293
|
+
store: ReviewStore;
|
|
294
|
+
state: ReturnType<ReviewStore['get']>;
|
|
295
|
+
tab: Tab | null;
|
|
296
|
+
onTab: (t: Tab) => void;
|
|
297
|
+
onClose: () => void;
|
|
298
|
+
threads: ThreadsProps;
|
|
299
|
+
}) {
|
|
300
|
+
const ref = useRef<HTMLElement>(null);
|
|
301
|
+
// The panel mounts again after every pick, so focus goes into it each time; a draft focuses its own text box.
|
|
302
|
+
useAutofocus(ref, !threads.draft);
|
|
303
|
+
return (
|
|
304
|
+
<aside
|
|
305
|
+
id="rv-panel"
|
|
306
|
+
ref={ref}
|
|
307
|
+
className="rv-panel"
|
|
308
|
+
aria-label="Review"
|
|
309
|
+
onKeyDown={(e) => {
|
|
310
|
+
// Esc closes the panel, except from a text box with words in it, where it would lose them.
|
|
311
|
+
if (e.key === 'Escape' && !(e.target instanceof HTMLTextAreaElement && e.target.value)) onClose();
|
|
312
|
+
}}
|
|
313
|
+
>
|
|
314
|
+
<PanelHead tab={tab} onTab={onTab} onClose={onClose} />
|
|
315
|
+
{state.error && (
|
|
316
|
+
<p className="rv-error" role="alert">
|
|
317
|
+
{state.error}
|
|
318
|
+
<button type="button" className="rv-link" onClick={store.clearError}>
|
|
319
|
+
Dismiss
|
|
320
|
+
</button>
|
|
321
|
+
</p>
|
|
322
|
+
)}
|
|
323
|
+
{state.status === 'guest' ? (
|
|
324
|
+
<p className="rv-empty">
|
|
325
|
+
You are viewing with the shared password. Ask the team for an invite link to read and leave
|
|
326
|
+
comments.
|
|
327
|
+
</p>
|
|
328
|
+
) : tab === 'people' ? (
|
|
329
|
+
<People store={store} me={state.me} people={state.people} />
|
|
330
|
+
) : (
|
|
331
|
+
<Threads {...threads} />
|
|
332
|
+
)}
|
|
333
|
+
</aside>
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function PickLayer({
|
|
338
|
+
onPick,
|
|
339
|
+
onPage,
|
|
340
|
+
onCancel,
|
|
341
|
+
}: {
|
|
342
|
+
onPick: (target: Element, point: { x: number; y: number }) => void;
|
|
343
|
+
onPage: () => void;
|
|
344
|
+
onCancel: () => void;
|
|
345
|
+
}) {
|
|
346
|
+
const box = useRef<HTMLDivElement>(null);
|
|
347
|
+
const pageButton = useRef<HTMLButtonElement>(null);
|
|
348
|
+
const under = (x: number, y: number) =>
|
|
349
|
+
document
|
|
350
|
+
.elementsFromPoint(x, y)
|
|
351
|
+
.find((el) => !isHarness(el) && el !== document.body && el !== document.documentElement) ?? null;
|
|
352
|
+
|
|
353
|
+
useEffect(() => {
|
|
354
|
+
pageButton.current?.focus();
|
|
355
|
+
const onKey = (e: globalThis.KeyboardEvent) => e.key === 'Escape' && onCancel();
|
|
356
|
+
addEventListener('keydown', onKey);
|
|
357
|
+
return () => removeEventListener('keydown', onKey);
|
|
358
|
+
}, [onCancel]);
|
|
359
|
+
|
|
360
|
+
return (
|
|
361
|
+
<>
|
|
362
|
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: a pointer-only picking surface; the bar's buttons are the keyboard path */}
|
|
363
|
+
{/* biome-ignore lint/a11y/useKeyWithClickEvents: as above, the keyboard picks "The whole page" in the bar */}
|
|
364
|
+
<div
|
|
365
|
+
className="rv-pick"
|
|
366
|
+
onPointerMove={(e) => {
|
|
367
|
+
const el = under(e.clientX, e.clientY);
|
|
368
|
+
const b = box.current;
|
|
369
|
+
if (!b) return;
|
|
370
|
+
if (!el) {
|
|
371
|
+
b.hidden = true;
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const r = el.getBoundingClientRect();
|
|
375
|
+
b.hidden = false;
|
|
376
|
+
b.style.transform = `translate(${r.left}px, ${r.top}px)`;
|
|
377
|
+
b.style.width = `${r.width}px`;
|
|
378
|
+
b.style.height = `${r.height}px`;
|
|
379
|
+
}}
|
|
380
|
+
onClick={(e) => {
|
|
381
|
+
const el = under(e.clientX, e.clientY);
|
|
382
|
+
if (el) onPick(el, { x: e.clientX, y: e.clientY });
|
|
383
|
+
}}
|
|
384
|
+
/>
|
|
385
|
+
<div ref={box} className="rv-pick-box" hidden />
|
|
386
|
+
<section className="rv-pick-bar" aria-label="Choose what to comment on">
|
|
387
|
+
<span>Click the element your comment is about.</span>
|
|
388
|
+
<button ref={pageButton} type="button" className="rv-button" onClick={onPage}>
|
|
389
|
+
The whole page
|
|
390
|
+
</button>
|
|
391
|
+
<button type="button" className="rv-link" onClick={onCancel}>
|
|
392
|
+
Cancel
|
|
393
|
+
</button>
|
|
394
|
+
</section>
|
|
395
|
+
</>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function Pins({
|
|
400
|
+
comments,
|
|
401
|
+
number,
|
|
402
|
+
draft,
|
|
403
|
+
onOpen,
|
|
404
|
+
}: {
|
|
405
|
+
comments: Comment[];
|
|
406
|
+
number: Map<string, number>;
|
|
407
|
+
draft: Draft | null;
|
|
408
|
+
onOpen: (id: string) => void;
|
|
409
|
+
}) {
|
|
410
|
+
const layer = useRef<HTMLDivElement>(null);
|
|
411
|
+
|
|
412
|
+
// Positions are written straight to the pins' transforms on scroll, resize
|
|
413
|
+
// and DOM changes, at most once a frame, so a scroll re-renders nothing.
|
|
414
|
+
useEffect(() => {
|
|
415
|
+
let frame = 0;
|
|
416
|
+
const place = () => {
|
|
417
|
+
frame = 0;
|
|
418
|
+
for (const pin of layer.current?.querySelectorAll<HTMLElement>('[data-pin]') ?? []) {
|
|
419
|
+
const c = comments.find((x) => x.id === pin.dataset.pin);
|
|
420
|
+
const at = c?.anchor ? pinPoint(c.anchor) : null;
|
|
421
|
+
pin.hidden = !at;
|
|
422
|
+
if (at) pin.style.transform = `translate(${at.x}px, ${at.y}px)`;
|
|
423
|
+
}
|
|
424
|
+
const d = layer.current?.querySelector<HTMLElement>('[data-draft]');
|
|
425
|
+
if (d && draft?.point) d.style.transform = `translate(${draft.point.x}px, ${draft.point.y}px)`;
|
|
426
|
+
};
|
|
427
|
+
const schedule = () => {
|
|
428
|
+
if (!frame) frame = requestAnimationFrame(place);
|
|
429
|
+
};
|
|
430
|
+
place();
|
|
431
|
+
const observer = new MutationObserver(schedule);
|
|
432
|
+
observer.observe(document.body, {
|
|
433
|
+
subtree: true,
|
|
434
|
+
childList: true,
|
|
435
|
+
attributes: true,
|
|
436
|
+
attributeFilter: ['class', 'style', 'hidden'],
|
|
437
|
+
});
|
|
438
|
+
addEventListener('scroll', schedule, { capture: true, passive: true });
|
|
439
|
+
addEventListener('resize', schedule);
|
|
440
|
+
return () => {
|
|
441
|
+
cancelAnimationFrame(frame);
|
|
442
|
+
observer.disconnect();
|
|
443
|
+
removeEventListener('scroll', schedule, { capture: true });
|
|
444
|
+
removeEventListener('resize', schedule);
|
|
445
|
+
};
|
|
446
|
+
}, [comments, draft]);
|
|
447
|
+
|
|
448
|
+
return (
|
|
449
|
+
// The list in the panel is the accessible path to every comment; pins are a pointer shortcut.
|
|
450
|
+
<div ref={layer} className="rv-pins" aria-hidden="true">
|
|
451
|
+
{comments.map((c) => (
|
|
452
|
+
<button
|
|
453
|
+
key={c.id}
|
|
454
|
+
type="button"
|
|
455
|
+
tabIndex={-1}
|
|
456
|
+
className="rv-pin"
|
|
457
|
+
data-pin={c.id}
|
|
458
|
+
hidden
|
|
459
|
+
onClick={() => onOpen(c.id)}
|
|
460
|
+
>
|
|
461
|
+
{number.get(c.id)}
|
|
462
|
+
</button>
|
|
463
|
+
))}
|
|
464
|
+
{draft?.point && <span className="rv-pin" data-draft="" data-new="" />}
|
|
465
|
+
</div>
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function Threads({
|
|
470
|
+
store,
|
|
471
|
+
me,
|
|
472
|
+
roots,
|
|
473
|
+
replies,
|
|
474
|
+
number,
|
|
475
|
+
states,
|
|
476
|
+
focus,
|
|
477
|
+
draft,
|
|
478
|
+
onNew,
|
|
479
|
+
onCancelDraft,
|
|
480
|
+
onPost,
|
|
481
|
+
onShow,
|
|
482
|
+
}: {
|
|
483
|
+
store: ReviewStore;
|
|
484
|
+
me: Me | null;
|
|
485
|
+
roots: Comment[];
|
|
486
|
+
replies: Comment[];
|
|
487
|
+
number: Map<string, number>;
|
|
488
|
+
states: string[];
|
|
489
|
+
focus: string | null;
|
|
490
|
+
draft: Draft | null;
|
|
491
|
+
onNew: () => void;
|
|
492
|
+
onCancelDraft: () => void;
|
|
493
|
+
onPost: (body: string) => Promise<void>;
|
|
494
|
+
onShow: (anchor: Anchor, id: string) => void;
|
|
495
|
+
}) {
|
|
496
|
+
const [filter, setFilter] = useState<'open' | 'resolved'>('open');
|
|
497
|
+
const list = roots.filter((c) => c.status === filter).reverse();
|
|
498
|
+
const counts = {
|
|
499
|
+
open: roots.filter((c) => c.status === 'open').length,
|
|
500
|
+
resolved: roots.filter((c) => c.status === 'resolved').length,
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
useEffect(() => {
|
|
504
|
+
if (!focus) return;
|
|
505
|
+
const target = roots.find((c) => c.id === focus);
|
|
506
|
+
if (target && target.status !== filter) setFilter(target.status);
|
|
507
|
+
requestAnimationFrame(() => document.getElementById(`rv-${focus}`)?.scrollIntoView({ block: 'nearest' }));
|
|
508
|
+
}, [focus, roots, filter]);
|
|
509
|
+
|
|
510
|
+
return (
|
|
511
|
+
<div className="rv-body">
|
|
512
|
+
{draft ? (
|
|
513
|
+
<Composer
|
|
514
|
+
label={`New comment on ${describe(draft.target)}`}
|
|
515
|
+
submit="Post"
|
|
516
|
+
onSubmit={onPost}
|
|
517
|
+
onCancel={onCancelDraft}
|
|
518
|
+
/>
|
|
519
|
+
) : (
|
|
520
|
+
<button type="button" className="rv-button rv-new" onClick={onNew} data-autofocus="">
|
|
521
|
+
<Icon name="plus" size={16} /> New comment
|
|
522
|
+
</button>
|
|
523
|
+
)}
|
|
524
|
+
<fieldset className="rv-filter">
|
|
525
|
+
<legend className="rv-sr">Show</legend>
|
|
526
|
+
{(['open', 'resolved'] as const).map((f) => (
|
|
527
|
+
<button key={f} type="button" aria-pressed={filter === f} onClick={() => setFilter(f)}>
|
|
528
|
+
{f === 'open' ? 'Open' : 'Resolved'} <span className="rv-muted">{counts[f]}</span>
|
|
529
|
+
</button>
|
|
530
|
+
))}
|
|
531
|
+
</fieldset>
|
|
532
|
+
{list.length === 0 ? (
|
|
533
|
+
<p className="rv-empty">
|
|
534
|
+
{filter === 'open' ? 'No open comments on this prototype.' : 'Nothing resolved yet.'}
|
|
535
|
+
</p>
|
|
536
|
+
) : (
|
|
537
|
+
<ol className="rv-list">
|
|
538
|
+
{list.map((c) => {
|
|
539
|
+
const { anchor } = c;
|
|
540
|
+
return (
|
|
541
|
+
<Thread
|
|
542
|
+
key={c.id}
|
|
543
|
+
store={store}
|
|
544
|
+
me={me}
|
|
545
|
+
root={c}
|
|
546
|
+
replies={replies.filter((r) => r.thread === c.id)}
|
|
547
|
+
n={number.get(c.id) ?? 0}
|
|
548
|
+
states={states}
|
|
549
|
+
focused={focus === c.id}
|
|
550
|
+
onShow={anchor ? () => onShow(anchor, c.id) : undefined}
|
|
551
|
+
/>
|
|
552
|
+
);
|
|
553
|
+
})}
|
|
554
|
+
</ol>
|
|
555
|
+
)}
|
|
556
|
+
</div>
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
type TriageChange = { kind: string | null; state: string | null };
|
|
561
|
+
|
|
562
|
+
function TriageSelect({
|
|
563
|
+
label,
|
|
564
|
+
empty,
|
|
565
|
+
value,
|
|
566
|
+
options,
|
|
567
|
+
onChange,
|
|
568
|
+
}: {
|
|
569
|
+
label: string;
|
|
570
|
+
empty: string;
|
|
571
|
+
value: string | null;
|
|
572
|
+
options: [string, string][];
|
|
573
|
+
onChange: (value: string | null) => void;
|
|
574
|
+
}) {
|
|
575
|
+
return (
|
|
576
|
+
<label className="rv-select">
|
|
577
|
+
<span className="rv-sr">{label}</span>
|
|
578
|
+
<select value={value ?? ''} onChange={(e) => onChange(e.target.value || null)}>
|
|
579
|
+
<option value="">{empty}</option>
|
|
580
|
+
{options.map(([v, text]) => (
|
|
581
|
+
<option key={v} value={v}>
|
|
582
|
+
{text}
|
|
583
|
+
</option>
|
|
584
|
+
))}
|
|
585
|
+
</select>
|
|
586
|
+
</label>
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Editors correct the kind and state; everyone else reads them. */
|
|
591
|
+
function TriageTags({
|
|
592
|
+
editor,
|
|
593
|
+
triage,
|
|
594
|
+
states,
|
|
595
|
+
onChange,
|
|
596
|
+
}: {
|
|
597
|
+
editor: boolean;
|
|
598
|
+
triage: Comment['triage'];
|
|
599
|
+
states: string[];
|
|
600
|
+
onChange: (next: TriageChange) => void;
|
|
601
|
+
}) {
|
|
602
|
+
const { kind = null, state = null } = triage ?? {};
|
|
603
|
+
return (
|
|
604
|
+
<div className="rv-tags">
|
|
605
|
+
{editor ? (
|
|
606
|
+
<>
|
|
607
|
+
<TriageSelect
|
|
608
|
+
label="Kind"
|
|
609
|
+
empty="No kind"
|
|
610
|
+
value={kind}
|
|
611
|
+
options={KINDS.map((k) => [k, KIND_LABEL[k]])}
|
|
612
|
+
onChange={(next) => onChange({ kind: next, state })}
|
|
613
|
+
/>
|
|
614
|
+
{states.length > 0 && (
|
|
615
|
+
<TriageSelect
|
|
616
|
+
label="State"
|
|
617
|
+
empty="No state"
|
|
618
|
+
value={state}
|
|
619
|
+
options={states.map((st) => [st, st])}
|
|
620
|
+
onChange={(next) => onChange({ kind, state: next })}
|
|
621
|
+
/>
|
|
622
|
+
)}
|
|
623
|
+
</>
|
|
624
|
+
) : (
|
|
625
|
+
[kind && KIND_LABEL[kind], state].filter(Boolean).map((tag) => (
|
|
626
|
+
<span key={tag} className="rv-tag">
|
|
627
|
+
{tag}
|
|
628
|
+
</span>
|
|
629
|
+
))
|
|
630
|
+
)}
|
|
631
|
+
{triage?.by === 'jev' && <span className="rv-muted">sorted automatically</span>}
|
|
632
|
+
</div>
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function ThreadActions({
|
|
637
|
+
store,
|
|
638
|
+
root,
|
|
639
|
+
canClose,
|
|
640
|
+
onReply,
|
|
641
|
+
onShow,
|
|
642
|
+
}: {
|
|
643
|
+
store: ReviewStore;
|
|
644
|
+
root: Comment;
|
|
645
|
+
canClose: boolean;
|
|
646
|
+
onReply: () => void;
|
|
647
|
+
onShow?: () => void;
|
|
648
|
+
}) {
|
|
649
|
+
const [confirming, setConfirming] = useState(false);
|
|
650
|
+
if (confirming)
|
|
651
|
+
return (
|
|
652
|
+
<div className="rv-actions">
|
|
653
|
+
<span>Delete the thread and its replies?</span>
|
|
654
|
+
<button type="button" className="rv-link rv-danger" onClick={() => act(store.remove(root.id))}>
|
|
655
|
+
Delete
|
|
656
|
+
</button>
|
|
657
|
+
<button type="button" className="rv-link" onClick={() => setConfirming(false)}>
|
|
658
|
+
Keep
|
|
659
|
+
</button>
|
|
660
|
+
</div>
|
|
661
|
+
);
|
|
662
|
+
return (
|
|
663
|
+
<div className="rv-actions">
|
|
664
|
+
{onShow && (
|
|
665
|
+
<button type="button" className="rv-link" onClick={onShow}>
|
|
666
|
+
Show state
|
|
667
|
+
</button>
|
|
668
|
+
)}
|
|
669
|
+
<button type="button" className="rv-link" onClick={onReply}>
|
|
670
|
+
Reply
|
|
671
|
+
</button>
|
|
672
|
+
{canClose && (
|
|
673
|
+
<>
|
|
674
|
+
<button
|
|
675
|
+
type="button"
|
|
676
|
+
className="rv-link"
|
|
677
|
+
onClick={() => act(store.resolve(root.id, root.status === 'open'))}
|
|
678
|
+
>
|
|
679
|
+
{root.status === 'open' ? 'Resolve' : 'Reopen'}
|
|
680
|
+
</button>
|
|
681
|
+
<button type="button" className="rv-link" onClick={() => setConfirming(true)}>
|
|
682
|
+
Delete
|
|
683
|
+
</button>
|
|
684
|
+
</>
|
|
685
|
+
)}
|
|
686
|
+
</div>
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function Thread({
|
|
691
|
+
store,
|
|
692
|
+
me,
|
|
693
|
+
root,
|
|
694
|
+
replies,
|
|
695
|
+
n,
|
|
696
|
+
states,
|
|
697
|
+
focused,
|
|
698
|
+
onShow,
|
|
699
|
+
}: {
|
|
700
|
+
store: ReviewStore;
|
|
701
|
+
me: Me | null;
|
|
702
|
+
root: Comment;
|
|
703
|
+
replies: Comment[];
|
|
704
|
+
n: number;
|
|
705
|
+
states: string[];
|
|
706
|
+
focused: boolean;
|
|
707
|
+
onShow?: () => void;
|
|
708
|
+
}) {
|
|
709
|
+
const [replying, setReplying] = useState(false);
|
|
710
|
+
const editor = me?.role === 'editor';
|
|
711
|
+
return (
|
|
712
|
+
<li
|
|
713
|
+
id={`rv-${root.id}`}
|
|
714
|
+
className="rv-thread"
|
|
715
|
+
data-focused={focused ? '' : undefined}
|
|
716
|
+
data-resolved={root.status === 'resolved' ? '' : undefined}
|
|
717
|
+
>
|
|
718
|
+
<Entry store={store} me={me} comment={root} n={n} />
|
|
719
|
+
<TriageTags
|
|
720
|
+
editor={editor}
|
|
721
|
+
triage={root.triage}
|
|
722
|
+
states={states}
|
|
723
|
+
onChange={(next) => act(store.triage(root.id, next, states))}
|
|
724
|
+
/>
|
|
725
|
+
{replies.length > 0 && (
|
|
726
|
+
<ol className="rv-replies">
|
|
727
|
+
{replies.map((r) => (
|
|
728
|
+
<li key={r.id}>
|
|
729
|
+
<Entry store={store} me={me} comment={r} />
|
|
730
|
+
</li>
|
|
731
|
+
))}
|
|
732
|
+
</ol>
|
|
733
|
+
)}
|
|
734
|
+
{replying ? (
|
|
735
|
+
<Composer
|
|
736
|
+
label="Reply"
|
|
737
|
+
submit="Reply"
|
|
738
|
+
onSubmit={async (body) => {
|
|
739
|
+
await store.reply(root.id, body);
|
|
740
|
+
setReplying(false);
|
|
741
|
+
}}
|
|
742
|
+
onCancel={() => setReplying(false)}
|
|
743
|
+
/>
|
|
744
|
+
) : (
|
|
745
|
+
<ThreadActions
|
|
746
|
+
store={store}
|
|
747
|
+
root={root}
|
|
748
|
+
canClose={editor || me?.id === root.author.id}
|
|
749
|
+
onReply={() => setReplying(true)}
|
|
750
|
+
onShow={onShow}
|
|
751
|
+
/>
|
|
752
|
+
)}
|
|
753
|
+
</li>
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function Entry({
|
|
758
|
+
store,
|
|
759
|
+
me,
|
|
760
|
+
comment,
|
|
761
|
+
n,
|
|
762
|
+
}: {
|
|
763
|
+
store: ReviewStore;
|
|
764
|
+
me: Me | null;
|
|
765
|
+
comment: Comment;
|
|
766
|
+
n?: number;
|
|
767
|
+
}) {
|
|
768
|
+
const [editing, setEditing] = useState(false);
|
|
769
|
+
const own = me?.id === comment.author.id;
|
|
770
|
+
return (
|
|
771
|
+
<article className="rv-entry">
|
|
772
|
+
<header>
|
|
773
|
+
{n !== undefined && <span className="rv-no">{n}</span>}
|
|
774
|
+
<strong>{comment.author.name}</strong>
|
|
775
|
+
<span className="rv-muted">
|
|
776
|
+
{comment.author.team} · <time dateTime={comment.createdAt}>{ago(comment.createdAt)}</time>
|
|
777
|
+
{comment.editedAt && ' · edited'}
|
|
778
|
+
</span>
|
|
779
|
+
{own && !editing && (
|
|
780
|
+
<button type="button" className="rv-link rv-edit" onClick={() => setEditing(true)}>
|
|
781
|
+
Edit
|
|
782
|
+
</button>
|
|
783
|
+
)}
|
|
784
|
+
</header>
|
|
785
|
+
{editing ? (
|
|
786
|
+
<Composer
|
|
787
|
+
label="Edit comment"
|
|
788
|
+
submit="Save"
|
|
789
|
+
initial={comment.body}
|
|
790
|
+
onSubmit={async (body) => {
|
|
791
|
+
await store.edit(comment.id, body);
|
|
792
|
+
setEditing(false);
|
|
793
|
+
}}
|
|
794
|
+
onCancel={() => setEditing(false)}
|
|
795
|
+
/>
|
|
796
|
+
) : (
|
|
797
|
+
<p className="rv-text">{comment.body}</p>
|
|
798
|
+
)}
|
|
799
|
+
</article>
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function Composer({
|
|
804
|
+
label,
|
|
805
|
+
submit,
|
|
806
|
+
initial = '',
|
|
807
|
+
onSubmit,
|
|
808
|
+
onCancel,
|
|
809
|
+
}: {
|
|
810
|
+
label: string;
|
|
811
|
+
submit: string;
|
|
812
|
+
initial?: string;
|
|
813
|
+
onSubmit: (body: string) => Promise<void>;
|
|
814
|
+
onCancel: () => void;
|
|
815
|
+
}) {
|
|
816
|
+
const [body, setBody] = useState(initial);
|
|
817
|
+
const [busy, setBusy] = useState(false);
|
|
818
|
+
const area = useRef<HTMLTextAreaElement>(null);
|
|
819
|
+
useEffect(() => area.current?.focus(), []);
|
|
820
|
+
const send = async (e: FormEvent) => {
|
|
821
|
+
e.preventDefault();
|
|
822
|
+
if (!body.trim() || busy) return;
|
|
823
|
+
setBusy(true);
|
|
824
|
+
try {
|
|
825
|
+
await onSubmit(body);
|
|
826
|
+
} catch {
|
|
827
|
+
// The store shows the error; the text stays so nothing typed is lost.
|
|
828
|
+
} finally {
|
|
829
|
+
setBusy(false);
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
return (
|
|
833
|
+
<form className="rv-composer" onSubmit={send} aria-busy={busy}>
|
|
834
|
+
<label className="rv-label">
|
|
835
|
+
{label}
|
|
836
|
+
<textarea
|
|
837
|
+
ref={area}
|
|
838
|
+
value={body}
|
|
839
|
+
rows={3}
|
|
840
|
+
maxLength={LIMITS.body}
|
|
841
|
+
onChange={(e) => setBody(e.target.value)}
|
|
842
|
+
onKeyDown={submitOnModEnter}
|
|
843
|
+
/>
|
|
844
|
+
</label>
|
|
845
|
+
<div className="rv-actions">
|
|
846
|
+
<button type="submit" className="rv-button" disabled={!body.trim() || busy}>
|
|
847
|
+
{submit}
|
|
848
|
+
</button>
|
|
849
|
+
<button type="button" className="rv-link" onClick={onCancel}>
|
|
850
|
+
Cancel
|
|
851
|
+
</button>
|
|
852
|
+
<span className="rv-muted rv-hint">⌘ Enter</span>
|
|
853
|
+
</div>
|
|
854
|
+
</form>
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function People({ store, me, people }: { store: ReviewStore; me: Me | null; people: Me[] | null }) {
|
|
859
|
+
const [link, setLink] = useState<{ name: string; href: string } | null>(null);
|
|
860
|
+
const [copied, setCopied] = useState(false);
|
|
861
|
+
const [confirm, setConfirm] = useState<string | null>(null);
|
|
862
|
+
const linkField = useRef<HTMLInputElement>(null);
|
|
863
|
+
const share = (name: string, href: string) => {
|
|
864
|
+
setLink({ name, href });
|
|
865
|
+
setCopied(false);
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
return (
|
|
869
|
+
<div className="rv-body">
|
|
870
|
+
<form
|
|
871
|
+
className="rv-invite"
|
|
872
|
+
onSubmit={(e) => {
|
|
873
|
+
e.preventDefault();
|
|
874
|
+
const f = new FormData(e.currentTarget);
|
|
875
|
+
const name = String(f.get('name') ?? '');
|
|
876
|
+
const form = e.currentTarget;
|
|
877
|
+
act(
|
|
878
|
+
store.invite(name, String(f.get('team') ?? ''), String(f.get('role')) as Role).then((href) => {
|
|
879
|
+
share(name, href);
|
|
880
|
+
form.reset();
|
|
881
|
+
}),
|
|
882
|
+
);
|
|
883
|
+
}}
|
|
884
|
+
>
|
|
885
|
+
<label className="rv-label">
|
|
886
|
+
Name
|
|
887
|
+
<input name="name" required maxLength={LIMITS.name} autoComplete="off" data-autofocus="" />
|
|
888
|
+
</label>
|
|
889
|
+
<label className="rv-label">
|
|
890
|
+
Team
|
|
891
|
+
<input
|
|
892
|
+
name="team"
|
|
893
|
+
required
|
|
894
|
+
maxLength={LIMITS.team}
|
|
895
|
+
autoComplete="off"
|
|
896
|
+
placeholder="Studio, or the client's company"
|
|
897
|
+
/>
|
|
898
|
+
</label>
|
|
899
|
+
<label className="rv-label">
|
|
900
|
+
Role
|
|
901
|
+
<select name="role" defaultValue="reviewer">
|
|
902
|
+
<option value="reviewer">Reviewer: comments and replies</option>
|
|
903
|
+
<option value="editor">Editor: also resolves, sorts and invites</option>
|
|
904
|
+
</select>
|
|
905
|
+
</label>
|
|
906
|
+
<button type="submit" className="rv-button">
|
|
907
|
+
Create invite link
|
|
908
|
+
</button>
|
|
909
|
+
</form>
|
|
910
|
+
{link && (
|
|
911
|
+
<div className="rv-share" role="status">
|
|
912
|
+
<p>
|
|
913
|
+
Send this link to {link.name}. It works for {INVITE_DAYS} days, and a new link replaces it.
|
|
914
|
+
</p>
|
|
915
|
+
<div className="rv-share-row">
|
|
916
|
+
<input
|
|
917
|
+
ref={linkField}
|
|
918
|
+
readOnly
|
|
919
|
+
value={link.href}
|
|
920
|
+
aria-label={`Invite link for ${link.name}`}
|
|
921
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
922
|
+
/>
|
|
923
|
+
<button
|
|
924
|
+
type="button"
|
|
925
|
+
className="rv-button"
|
|
926
|
+
onClick={() =>
|
|
927
|
+
navigator.clipboard.writeText(link.href).then(
|
|
928
|
+
() => setCopied(true),
|
|
929
|
+
// No clipboard access (an insecure origin, a denied permission): select the link to copy by hand.
|
|
930
|
+
() => linkField.current?.select(),
|
|
931
|
+
)
|
|
932
|
+
}
|
|
933
|
+
>
|
|
934
|
+
{copied ? 'Copied' : 'Copy'}
|
|
935
|
+
</button>
|
|
936
|
+
</div>
|
|
937
|
+
</div>
|
|
938
|
+
)}
|
|
939
|
+
{people === null ? (
|
|
940
|
+
<p className="rv-empty">Loading people…</p>
|
|
941
|
+
) : (
|
|
942
|
+
<ul className="rv-people">
|
|
943
|
+
{people.map((p) => (
|
|
944
|
+
<li key={p.id} data-revoked={p.revokedAt ? '' : undefined}>
|
|
945
|
+
<div>
|
|
946
|
+
<strong>{p.name}</strong>
|
|
947
|
+
<span className="rv-muted">
|
|
948
|
+
{p.team} · {p.role}
|
|
949
|
+
{p.revokedAt && ' · revoked'}
|
|
950
|
+
</span>
|
|
951
|
+
</div>
|
|
952
|
+
{p.id !== me?.id && !p.revokedAt && (
|
|
953
|
+
<div className="rv-actions">
|
|
954
|
+
{confirm === p.id ? (
|
|
955
|
+
<>
|
|
956
|
+
<button
|
|
957
|
+
type="button"
|
|
958
|
+
className="rv-link rv-danger"
|
|
959
|
+
onClick={() => act(store.revoke(p.id).then(() => setConfirm(null)))}
|
|
960
|
+
>
|
|
961
|
+
Revoke
|
|
962
|
+
</button>
|
|
963
|
+
<button type="button" className="rv-link" onClick={() => setConfirm(null)}>
|
|
964
|
+
Keep
|
|
965
|
+
</button>
|
|
966
|
+
</>
|
|
967
|
+
) : (
|
|
968
|
+
<>
|
|
969
|
+
<button
|
|
970
|
+
type="button"
|
|
971
|
+
className="rv-link"
|
|
972
|
+
onClick={() => act(store.reissue(p.id).then((href) => share(p.name, href)))}
|
|
973
|
+
>
|
|
974
|
+
New link
|
|
975
|
+
</button>
|
|
976
|
+
<button type="button" className="rv-link" onClick={() => setConfirm(p.id)}>
|
|
977
|
+
Revoke
|
|
978
|
+
</button>
|
|
979
|
+
</>
|
|
980
|
+
)}
|
|
981
|
+
</div>
|
|
982
|
+
)}
|
|
983
|
+
</li>
|
|
984
|
+
))}
|
|
985
|
+
</ul>
|
|
986
|
+
)}
|
|
987
|
+
</div>
|
|
988
|
+
);
|
|
989
|
+
}
|