@crvouga/atlas 0.10.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +7 -0
- package/dist/server/src/settings-page.d.ts +4 -4
- package/dist/server/src/settings-page.js +17 -78
- package/docs/reference/server.md +4 -4
- package/docs/ui.md +142 -0
- package/docs/workspaces.md +130 -0
- package/llms-full.txt +278 -0
- package/llms.txt +2 -0
- package/package.json +17 -10
- package/packages/visualizer/src/admin/SettingsPage.tsx +456 -0
- package/packages/visualizer/src/admin/main.tsx +11 -0
- package/packages/visualizer/src/app/AppShell.tsx +62 -38
- package/packages/visualizer/src/app/gazetteer.tsx +90 -83
- package/packages/visualizer/src/app/router.tsx +33 -15
- package/packages/visualizer/src/components/ActivityIndicator.tsx +4 -5
- package/packages/visualizer/src/components/DataIssues.tsx +36 -54
- package/packages/visualizer/src/components/Header.tsx +197 -125
- package/packages/visualizer/src/components/Legend.tsx +13 -41
- package/packages/visualizer/src/components/ProjectPicker.tsx +61 -26
- package/packages/visualizer/src/components/Screen.tsx +80 -71
- package/packages/visualizer/src/components/SpecLogo.tsx +15 -0
- package/packages/visualizer/src/components/Status.tsx +20 -34
- package/packages/visualizer/src/components/labels.tsx +8 -3
- package/packages/visualizer/src/components/spec-logos/APACHE-2.0.txt +206 -0
- package/packages/visualizer/src/components/spec-logos/CAMUNDA-MIT.txt +21 -0
- package/packages/visualizer/src/components/spec-logos/FILE-ICONS-ISC.txt +13 -0
- package/packages/visualizer/src/components/spec-logos/README.md +23 -0
- package/packages/visualizer/src/components/spec-logos/SIMPLE-ICONS-CC0.txt +30 -0
- package/packages/visualizer/src/components/spec-logos/alloy.svg +1 -0
- package/packages/visualizer/src/components/spec-logos/dmn.svg +3 -0
- package/packages/visualizer/src/components/spec-logos/gherkin.svg +1 -0
- package/packages/visualizer/src/components/spec-logos/openslo.svg +63 -0
- package/packages/visualizer/src/components/spec-logos/quint.svg +1 -0
- package/packages/visualizer/src/evidence/artifacts.tsx +263 -110
- package/packages/visualizer/src/evidence/chart-source.tsx +18 -0
- package/packages/visualizer/src/evidence/check-detail.tsx +283 -139
- package/packages/visualizer/src/evidence/decision-table.tsx +35 -26
- package/packages/visualizer/src/evidence/document-page.tsx +51 -29
- package/packages/visualizer/src/evidence/feature-page.tsx +121 -53
- package/packages/visualizer/src/evidence/marks.tsx +112 -79
- package/packages/visualizer/src/evidence/model-page.tsx +77 -46
- package/packages/visualizer/src/evidence/model-trace.tsx +55 -35
- package/packages/visualizer/src/evidence/overview.tsx +137 -69
- package/packages/visualizer/src/evidence/recording-page.tsx +70 -31
- package/packages/visualizer/src/evidence/recording.tsx +135 -68
- package/packages/visualizer/src/evidence/snapshot.tsx +99 -43
- package/packages/visualizer/src/evidence/source-text.tsx +33 -0
- package/packages/visualizer/src/evidence/sources-panel.tsx +138 -144
- package/packages/visualizer/src/evidence/spec-header.tsx +52 -36
- package/packages/visualizer/src/evidence/spec-index.tsx +61 -32
- package/packages/visualizer/src/evidence/spec-page.tsx +56 -25
- package/packages/visualizer/src/evidence/test-filter.tsx +38 -14
- package/packages/visualizer/src/explorer/Explorer.tsx +688 -261
- package/packages/visualizer/src/explorer/ExplorerIndex.tsx +136 -68
- package/packages/visualizer/src/explorer/RunsView.tsx +307 -94
- package/packages/visualizer/src/explorer/TestDetail.tsx +393 -118
- package/packages/visualizer/src/explorer/parts.tsx +35 -13
- package/packages/visualizer/src/find/Finder.tsx +106 -90
- package/packages/visualizer/src/find/open.ts +17 -20
- package/packages/visualizer/src/live/test-progress.tsx +60 -22
- package/packages/visualizer/src/looks/Stage.tsx +223 -110
- package/packages/visualizer/src/main.tsx +9 -11
- package/packages/visualizer/src/map/ChartMap.tsx +125 -69
- package/packages/visualizer/src/map/EventEdge.tsx +59 -37
- package/packages/visualizer/src/map/map.module.css +12 -546
- package/packages/visualizer/src/map/nodes.tsx +124 -70
- package/packages/visualizer/src/reachable.test.ts +3 -2
- package/packages/visualizer/src/spec-kind.ts +2 -2
- package/packages/visualizer/src/ui/grammars/ALLOY-MIT.txt +21 -0
- package/packages/visualizer/src/ui/grammars/QUINT-APACHE-2.0.txt +206 -0
- package/packages/visualizer/src/ui/grammars/README.md +14 -0
- package/packages/visualizer/src/ui/grammars/alloy.json +71 -0
- package/packages/visualizer/src/ui/grammars/quint.json +152 -0
- package/packages/visualizer/src/ui/highlight.ts +50 -0
- package/packages/visualizer/src/ui/theme.tsx +109 -0
- package/packages/visualizer/src/views/ChartPage.tsx +46 -26
- package/packages/visualizer/src/views/ChartTests.tsx +59 -22
- package/packages/visualizer/src/views/ProductPage.tsx +16 -15
- package/packages/visualizer/src/views/SidePanel.tsx +241 -131
- package/packages/visualizer/src/app/gazetteer.module.css +0 -107
- package/packages/visualizer/src/components/ActivityIndicator.module.css +0 -4
- package/packages/visualizer/src/components/DataIssues.module.css +0 -136
- package/packages/visualizer/src/components/Header.module.css +0 -114
- package/packages/visualizer/src/components/Legend.module.css +0 -84
- package/packages/visualizer/src/components/Screen.module.css +0 -312
- package/packages/visualizer/src/components/Status.module.css +0 -116
- package/packages/visualizer/src/components/icons.tsx +0 -156
- package/packages/visualizer/src/evidence/artifacts.module.css +0 -213
- package/packages/visualizer/src/evidence/evidence.module.css +0 -907
- package/packages/visualizer/src/evidence/recording.module.css +0 -460
- package/packages/visualizer/src/evidence/sources.module.css +0 -168
- package/packages/visualizer/src/evidence/spec-snapshot.tsx +0 -26
- package/packages/visualizer/src/explorer/explorer.module.css +0 -1238
- package/packages/visualizer/src/find/Finder.module.css +0 -168
- package/packages/visualizer/src/looks/stage.module.css +0 -300
- package/packages/visualizer/src/styles/global.css +0 -105
- package/packages/visualizer/src/styles/tokens.css +0 -80
- package/packages/visualizer/src/views/views.module.css +0 -546
|
@@ -1,18 +1,15 @@
|
|
|
1
|
+
import { IconArrowLeft } from '@tabler/icons-react';
|
|
2
|
+
import { Alert, Box, Button, Flex, Group, Image, List, ScrollArea, Slider, Stack, Tabs, Text, Title } from '@mantine/core';
|
|
1
3
|
import { useEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
|
|
2
|
-
|
|
3
|
-
import { ChevronIcon } from '../components/icons';
|
|
4
|
+
import { IconChevronRight as ChevronIcon } from '@tabler/icons-react';
|
|
4
5
|
import { finderBlocksKeys } from '../find/open';
|
|
5
6
|
import type { FilmLook, Looks, MomentLook } from './collect';
|
|
6
|
-
import styles from './stage.module.css';
|
|
7
|
-
|
|
8
7
|
export type LookMode = 'pictures' | 'film' | 'timeline';
|
|
9
|
-
|
|
10
8
|
const MODE_LABEL: Record<LookMode, string> = {
|
|
11
9
|
pictures: 'Pictures',
|
|
12
10
|
film: 'Video',
|
|
13
11
|
timeline: 'Timeline'
|
|
14
12
|
};
|
|
15
|
-
|
|
16
13
|
type Props = {
|
|
17
14
|
looks: Looks;
|
|
18
15
|
/** Which kind to open when a run recorded more than one. */
|
|
@@ -31,7 +28,6 @@ type Props = {
|
|
|
31
28
|
failure?: boolean;
|
|
32
29
|
onFailure?: () => void;
|
|
33
30
|
};
|
|
34
|
-
|
|
35
31
|
function modesOf(looks: Looks): LookMode[] {
|
|
36
32
|
const modes: LookMode[] = [];
|
|
37
33
|
if (looks.pictures.length) modes.push('pictures');
|
|
@@ -39,25 +35,21 @@ function modesOf(looks: Looks): LookMode[] {
|
|
|
39
35
|
if (looks.moments.length) modes.push('timeline');
|
|
40
36
|
return modes;
|
|
41
37
|
}
|
|
42
|
-
|
|
43
38
|
function choose(prefer: LookMode, modes: readonly LookMode[], requested: LookMode | null | undefined): LookMode | null {
|
|
44
39
|
if (requested && modes.includes(requested)) return requested;
|
|
45
40
|
if (modes.includes(prefer)) return prefer;
|
|
46
41
|
return modes[0] ?? null;
|
|
47
42
|
}
|
|
48
|
-
|
|
49
43
|
function clock(ms: number): string {
|
|
50
44
|
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
51
45
|
const minutes = Math.floor(seconds / 60);
|
|
52
46
|
const rest = seconds % 60;
|
|
53
47
|
return minutes > 0 ? `${minutes}:${String(rest).padStart(2, '0')}` : `${rest}s`;
|
|
54
48
|
}
|
|
55
|
-
|
|
56
49
|
function pauseFor(mode: LookMode, moment: MomentLook | undefined): number {
|
|
57
50
|
if (mode === 'pictures') return 1400;
|
|
58
51
|
return Math.max(100, Math.min(moment?.pauseMs ?? 700, 2000));
|
|
59
52
|
}
|
|
60
|
-
|
|
61
53
|
/**
|
|
62
54
|
* One player for whatever a runner captured: stills, a video, or a timeline of moments.
|
|
63
55
|
* Kinds the run did not record are absent. When several were recorded, each is a full view of the same evidence.
|
|
@@ -66,7 +58,7 @@ export function Stage({ looks, prefer, empty, notice, moment, onMoment, renderMo
|
|
|
66
58
|
const modes = modesOf(looks);
|
|
67
59
|
const [localMode, setLocalMode] = useState<LookMode | null>(null);
|
|
68
60
|
const [playing, setPlaying] = useState(false);
|
|
69
|
-
const active = choose(prefer, modes, onMode ? mode ?? null : localMode);
|
|
61
|
+
const active = choose(prefer, modes, onMode ? (mode ?? null) : localMode);
|
|
70
62
|
const pickMode = (next: LookMode) => {
|
|
71
63
|
setPlaying(false);
|
|
72
64
|
if (onMode) onMode(next);
|
|
@@ -79,11 +71,9 @@ export function Stage({ looks, prefer, empty, notice, moment, onMoment, renderMo
|
|
|
79
71
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
80
72
|
const onMomentRef = useRef(onMoment);
|
|
81
73
|
onMomentRef.current = onMoment;
|
|
82
|
-
|
|
83
74
|
const count = active === 'pictures' ? looks.pictures.length : active === 'film' ? looks.films.length : looks.moments.length;
|
|
84
75
|
const timelineIndex = moment ?? localMoment;
|
|
85
76
|
const index = Math.max(0, Math.min(count - 1, active === 'pictures' ? picture : active === 'film' ? film : timelineIndex));
|
|
86
|
-
|
|
87
77
|
const select = (next: number) => {
|
|
88
78
|
const clamped = Math.max(0, Math.min(count - 1, next));
|
|
89
79
|
if (active === 'pictures') setPicture(clamped);
|
|
@@ -93,11 +83,9 @@ export function Stage({ looks, prefer, empty, notice, moment, onMoment, renderMo
|
|
|
93
83
|
};
|
|
94
84
|
const selectRef = useRef(select);
|
|
95
85
|
selectRef.current = select;
|
|
96
|
-
|
|
97
86
|
useEffect(() => {
|
|
98
87
|
setPlaying(false);
|
|
99
88
|
}, [active]);
|
|
100
|
-
|
|
101
89
|
useEffect(() => {
|
|
102
90
|
if (!playing || !active || active === 'film' || count < 2) return;
|
|
103
91
|
if (index >= count - 1) {
|
|
@@ -108,7 +96,6 @@ export function Stage({ looks, prefer, empty, notice, moment, onMoment, renderMo
|
|
|
108
96
|
const timer = setTimeout(() => selectRef.current(index + 1), pauseFor(active, current));
|
|
109
97
|
return () => clearTimeout(timer);
|
|
110
98
|
}, [playing, active, index, count, looks.moments]);
|
|
111
|
-
|
|
112
99
|
useEffect(() => {
|
|
113
100
|
if (!active || count < 2) return;
|
|
114
101
|
const onKey = (event: KeyboardEvent) => {
|
|
@@ -118,7 +105,16 @@ export function Stage({ looks, prefer, empty, notice, moment, onMoment, renderMo
|
|
|
118
105
|
if (!root || (focused instanceof HTMLElement && focused !== document.body && !root.contains(focused))) return;
|
|
119
106
|
const target = event.target;
|
|
120
107
|
if (target instanceof HTMLElement && (target.isContentEditable || target.closest('input, textarea, select'))) return;
|
|
121
|
-
const next =
|
|
108
|
+
const next =
|
|
109
|
+
event.key === 'ArrowRight'
|
|
110
|
+
? index + 1
|
|
111
|
+
: event.key === 'ArrowLeft'
|
|
112
|
+
? index - 1
|
|
113
|
+
: event.key === 'Home'
|
|
114
|
+
? 0
|
|
115
|
+
: event.key === 'End'
|
|
116
|
+
? count - 1
|
|
117
|
+
: null;
|
|
122
118
|
if (next === null || next === index || next < 0 || next >= count) return;
|
|
123
119
|
event.preventDefault();
|
|
124
120
|
setPlaying(false);
|
|
@@ -127,112 +123,178 @@ export function Stage({ looks, prefer, empty, notice, moment, onMoment, renderMo
|
|
|
127
123
|
window.addEventListener('keydown', onKey);
|
|
128
124
|
return () => window.removeEventListener('keydown', onKey);
|
|
129
125
|
}, [active, index, count]);
|
|
130
|
-
|
|
131
126
|
if (!active) {
|
|
132
127
|
return (
|
|
133
|
-
<
|
|
134
|
-
{notice &&
|
|
135
|
-
|
|
136
|
-
|
|
128
|
+
<Stack gap="sm">
|
|
129
|
+
{notice && (
|
|
130
|
+
<Alert color={'blue'} my="sm">
|
|
131
|
+
{notice}
|
|
132
|
+
</Alert>
|
|
133
|
+
)}
|
|
134
|
+
{empty ?? (
|
|
135
|
+
<Text component="div" c="dimmed">
|
|
136
|
+
Nothing was captured of this.
|
|
137
|
+
</Text>
|
|
138
|
+
)}
|
|
139
|
+
</Stack>
|
|
137
140
|
);
|
|
138
141
|
}
|
|
139
|
-
|
|
140
142
|
const showTransport = count > 1;
|
|
141
143
|
const showPlay = showTransport && active !== 'film';
|
|
142
144
|
const shot = looks.pictures[index];
|
|
143
145
|
const clip = looks.films[index];
|
|
144
146
|
const step = looks.moments[index];
|
|
145
147
|
const noun = active === 'pictures' ? 'Picture' : active === 'film' ? 'Video' : 'Moment';
|
|
146
|
-
|
|
147
148
|
return (
|
|
148
|
-
<
|
|
149
|
+
<Box ref={sectionRef} aria-label="What it looked like" component="section" mb="lg">
|
|
149
150
|
{modes.length > 1 && (
|
|
150
|
-
<
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
{
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
151
|
+
<Tabs value={active} onChange={(value) => pickMode(value as LookMode)} mb="md">
|
|
152
|
+
<Tabs.List aria-label="Captured evidence">
|
|
153
|
+
{modes.map((item) => (
|
|
154
|
+
<Tabs.Tab key={item} value={item} id={`look-${item}`}>
|
|
155
|
+
{MODE_LABEL[item]}
|
|
156
|
+
</Tabs.Tab>
|
|
157
|
+
))}
|
|
158
|
+
</Tabs.List>
|
|
159
|
+
</Tabs>
|
|
157
160
|
)}
|
|
158
|
-
{notice &&
|
|
159
|
-
|
|
161
|
+
{notice && (
|
|
162
|
+
<Alert color={'blue'} my="sm">
|
|
163
|
+
{notice}
|
|
164
|
+
</Alert>
|
|
165
|
+
)}
|
|
166
|
+
<Stack gap="sm" miw={0}>
|
|
160
167
|
{active === 'pictures' && shot && (
|
|
161
168
|
<>
|
|
162
|
-
<
|
|
163
|
-
<
|
|
169
|
+
<Image src={shot.src} alt={shot.alt} fit="contain" maw="100%" mah="60vh" />
|
|
170
|
+
<Flex gap="xs" align="center" wrap="wrap" c="dimmed">
|
|
171
|
+
{shot.label}
|
|
172
|
+
</Flex>
|
|
164
173
|
</>
|
|
165
174
|
)}
|
|
166
175
|
{active === 'film' && clip && <Film film={clip} videoRef={videoRef} />}
|
|
167
|
-
{active === 'timeline' &&
|
|
168
|
-
|
|
169
|
-
|
|
176
|
+
{active === 'timeline' &&
|
|
177
|
+
step &&
|
|
178
|
+
(renderMoment ? (
|
|
179
|
+
<Stack gap="sm" miw={0}>
|
|
180
|
+
{renderMoment(index)}
|
|
181
|
+
</Stack>
|
|
170
182
|
) : step.thumb ? (
|
|
171
|
-
<figure
|
|
183
|
+
<Box component="figure" m={0}>
|
|
172
184
|
<Still src={step.thumb} point={step.point} />
|
|
173
|
-
<
|
|
174
|
-
{step.caption &&
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
185
|
+
<Box data-status={step.status ?? undefined} component="figcaption">
|
|
186
|
+
{step.caption && (
|
|
187
|
+
<Text span inherit>
|
|
188
|
+
{step.caption}
|
|
189
|
+
</Text>
|
|
190
|
+
)}
|
|
191
|
+
<Text span inherit fw={600}>
|
|
192
|
+
{step.label}
|
|
193
|
+
</Text>
|
|
194
|
+
</Box>
|
|
195
|
+
</Box>
|
|
178
196
|
) : (
|
|
179
|
-
<
|
|
180
|
-
{step.caption && <
|
|
181
|
-
<
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
197
|
+
<Box data-status={step.status ?? undefined} component="article">
|
|
198
|
+
{step.caption && <Text component="div">{step.caption}</Text>}
|
|
199
|
+
<Title order={3} mb="sm">
|
|
200
|
+
{step.label}
|
|
201
|
+
</Title>
|
|
202
|
+
</Box>
|
|
203
|
+
))}
|
|
204
|
+
</Stack>
|
|
186
205
|
{showTransport && (
|
|
187
|
-
<
|
|
206
|
+
<Flex gap="xs" align="center" wrap="wrap">
|
|
188
207
|
{showPlay && (
|
|
189
|
-
<
|
|
208
|
+
<Button
|
|
190
209
|
type="button"
|
|
191
|
-
className={styles.control}
|
|
192
210
|
onClick={() => {
|
|
193
211
|
if (!playing && index === count - 1) select(0);
|
|
194
212
|
setPlaying((value) => !value);
|
|
195
213
|
}}
|
|
214
|
+
variant="light"
|
|
196
215
|
>
|
|
197
216
|
{playing ? 'Pause' : 'Play'}
|
|
198
|
-
</
|
|
217
|
+
</Button>
|
|
199
218
|
)}
|
|
200
|
-
<
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
219
|
+
<Button
|
|
220
|
+
type="button"
|
|
221
|
+
aria-label={`Previous ${noun.toLowerCase()}`}
|
|
222
|
+
disabled={index === 0}
|
|
223
|
+
onClick={() => {
|
|
224
|
+
setPlaying(false);
|
|
225
|
+
select(index - 1);
|
|
226
|
+
}}
|
|
227
|
+
variant="light"
|
|
228
|
+
>
|
|
229
|
+
<Text span aria-hidden="true">
|
|
230
|
+
<IconArrowLeft size={16} aria-hidden="true" />
|
|
231
|
+
</Text>
|
|
232
|
+
</Button>
|
|
233
|
+
<Button
|
|
234
|
+
type="button"
|
|
235
|
+
aria-label={`Next ${noun.toLowerCase()}`}
|
|
236
|
+
disabled={index >= count - 1}
|
|
237
|
+
onClick={() => {
|
|
238
|
+
setPlaying(false);
|
|
239
|
+
select(index + 1);
|
|
240
|
+
}}
|
|
241
|
+
variant="light"
|
|
242
|
+
>
|
|
243
|
+
<ChevronIcon aria-hidden="true" size={14} />
|
|
244
|
+
</Button>
|
|
245
|
+
<Slider
|
|
209
246
|
aria-label={noun}
|
|
210
247
|
min={0}
|
|
211
248
|
max={count - 1}
|
|
212
249
|
value={index}
|
|
213
|
-
onChange={(
|
|
250
|
+
onChange={(value) => {
|
|
214
251
|
setPlaying(false);
|
|
215
|
-
select(
|
|
252
|
+
select(value);
|
|
216
253
|
}}
|
|
254
|
+
flex={1}
|
|
217
255
|
/>
|
|
218
|
-
<span
|
|
256
|
+
<Text span inherit c="dimmed">
|
|
219
257
|
{noun} {index + 1} of {count}
|
|
220
|
-
</
|
|
258
|
+
</Text>
|
|
221
259
|
{active === 'timeline' && onFailure && (
|
|
222
|
-
<
|
|
260
|
+
<Button type="button" disabled={!failure} onClick={onFailure} variant="light">
|
|
223
261
|
Next failure
|
|
224
|
-
</
|
|
262
|
+
</Button>
|
|
225
263
|
)}
|
|
226
|
-
</
|
|
264
|
+
</Flex>
|
|
265
|
+
)}
|
|
266
|
+
{active === 'pictures' && (
|
|
267
|
+
<Strip
|
|
268
|
+
items={looks.pictures}
|
|
269
|
+
index={index}
|
|
270
|
+
onSelect={(next) => {
|
|
271
|
+
setPlaying(false);
|
|
272
|
+
select(next);
|
|
273
|
+
}}
|
|
274
|
+
/>
|
|
275
|
+
)}
|
|
276
|
+
{active === 'film' && (
|
|
277
|
+
<Strip
|
|
278
|
+
items={looks.films.map((item) => ({ id: item.id, label: item.label, thumb: item.poster }))}
|
|
279
|
+
index={index}
|
|
280
|
+
onSelect={(next) => select(next)}
|
|
281
|
+
/>
|
|
282
|
+
)}
|
|
283
|
+
{active === 'timeline' && (
|
|
284
|
+
<Strip
|
|
285
|
+
items={looks.moments}
|
|
286
|
+
index={index}
|
|
287
|
+
onSelect={(next) => {
|
|
288
|
+
setPlaying(false);
|
|
289
|
+
select(next);
|
|
290
|
+
}}
|
|
291
|
+
/>
|
|
227
292
|
)}
|
|
228
|
-
{active === 'pictures' && <Strip items={looks.pictures} index={index} onSelect={(next) => { setPlaying(false); select(next); }} />}
|
|
229
|
-
{active === 'film' && <Strip items={looks.films.map((item) => ({ id: item.id, label: item.label, thumb: item.poster }))} index={index} onSelect={(next) => select(next)} />}
|
|
230
|
-
{active === 'timeline' && <Strip items={looks.moments} index={index} onSelect={(next) => { setPlaying(false); select(next); }} />}
|
|
231
293
|
{active === 'film' && clip && clip.chapters.length > 0 && (
|
|
232
|
-
<
|
|
294
|
+
<List aria-label="Moments in the video" spacing="xs" listStyleType="none" type="ordered">
|
|
233
295
|
{clip.chapters.map((chapter) => (
|
|
234
|
-
<
|
|
235
|
-
<
|
|
296
|
+
<List.Item key={`${chapter.atMs}:${chapter.label}`}>
|
|
297
|
+
<Button
|
|
236
298
|
type="button"
|
|
237
299
|
onClick={() => {
|
|
238
300
|
const node = videoRef.current;
|
|
@@ -240,24 +302,23 @@ export function Stage({ looks, prefer, empty, notice, moment, onMoment, renderMo
|
|
|
240
302
|
node.currentTime = chapter.atMs / 1000;
|
|
241
303
|
void node.play().catch(() => undefined);
|
|
242
304
|
}}
|
|
305
|
+
variant="light"
|
|
243
306
|
>
|
|
244
307
|
{clock(chapter.atMs)} {chapter.label}
|
|
245
|
-
</
|
|
246
|
-
</
|
|
308
|
+
</Button>
|
|
309
|
+
</List.Item>
|
|
247
310
|
))}
|
|
248
|
-
</
|
|
311
|
+
</List>
|
|
249
312
|
)}
|
|
250
|
-
</
|
|
313
|
+
</Box>
|
|
251
314
|
);
|
|
252
315
|
}
|
|
253
|
-
|
|
254
316
|
function Film({ film, videoRef }: { film: FilmLook; videoRef: RefObject<HTMLVideoElement | null> }) {
|
|
255
317
|
return (
|
|
256
318
|
<>
|
|
257
|
-
<
|
|
319
|
+
<Box
|
|
258
320
|
key={film.src}
|
|
259
321
|
ref={videoRef}
|
|
260
|
-
className={styles.film}
|
|
261
322
|
src={film.src}
|
|
262
323
|
poster={film.poster ?? undefined}
|
|
263
324
|
controls
|
|
@@ -268,55 +329,107 @@ function Film({ film, videoRef }: { film: FilmLook; videoRef: RefObject<HTMLVide
|
|
|
268
329
|
const node = event.currentTarget;
|
|
269
330
|
if (node.videoWidth > 0 && node.videoHeight > 0) node.style.setProperty('--ar', String(node.videoWidth / node.videoHeight));
|
|
270
331
|
}}
|
|
332
|
+
component="video"
|
|
333
|
+
maw="100%"
|
|
334
|
+
w="100%"
|
|
271
335
|
>
|
|
272
336
|
{film.captions && <track kind="captions" src={film.captions} srcLang="en" label="Steps" default />}
|
|
273
|
-
</
|
|
274
|
-
<
|
|
337
|
+
</Box>
|
|
338
|
+
<Flex gap="xs" align="center" wrap="wrap" c="dimmed">
|
|
275
339
|
{film.label}
|
|
276
340
|
{film.durationMs !== null && ` · ${clock(film.durationMs)}`}
|
|
277
|
-
</
|
|
341
|
+
</Flex>
|
|
278
342
|
</>
|
|
279
343
|
);
|
|
280
344
|
}
|
|
281
|
-
|
|
282
345
|
/** A still, with a mark where the moment happened when the run recorded a point on it. */
|
|
283
|
-
function Still({
|
|
284
|
-
|
|
346
|
+
function Still({
|
|
347
|
+
src,
|
|
348
|
+
point
|
|
349
|
+
}: {
|
|
350
|
+
src: string;
|
|
351
|
+
point?: {
|
|
352
|
+
x: number;
|
|
353
|
+
y: number;
|
|
354
|
+
} | null;
|
|
355
|
+
}) {
|
|
356
|
+
const [size, setSize] = useState<{
|
|
357
|
+
w: number;
|
|
358
|
+
h: number;
|
|
359
|
+
} | null>(null);
|
|
285
360
|
const placed = point && size && point.x >= 0 && point.y >= 0 && point.x <= size.w && point.y <= size.h;
|
|
286
361
|
return (
|
|
287
|
-
<
|
|
288
|
-
<
|
|
289
|
-
className={styles.shot}
|
|
362
|
+
<Box pos="relative" w="fit-content" maw="100%" mx="auto">
|
|
363
|
+
<Image
|
|
290
364
|
src={src}
|
|
291
365
|
alt=""
|
|
292
366
|
onLoad={(event) => setSize({ w: event.currentTarget.naturalWidth, h: event.currentTarget.naturalHeight })}
|
|
367
|
+
fit="contain"
|
|
368
|
+
maw="100%"
|
|
369
|
+
mah="60vh"
|
|
293
370
|
/>
|
|
294
371
|
{placed && (
|
|
295
|
-
<
|
|
296
|
-
|
|
297
|
-
|
|
372
|
+
<Box
|
|
373
|
+
component="span"
|
|
374
|
+
pos="absolute"
|
|
375
|
+
left={`${(point.x / size.w) * 100}%`}
|
|
376
|
+
top={`${(point.y / size.h) * 100}%`}
|
|
377
|
+
w="1rem"
|
|
378
|
+
h="1rem"
|
|
379
|
+
bdrs="xl"
|
|
380
|
+
bg="blue.6"
|
|
381
|
+
opacity={0.6}
|
|
298
382
|
aria-hidden="true"
|
|
299
383
|
/>
|
|
300
384
|
)}
|
|
301
|
-
</
|
|
385
|
+
</Box>
|
|
302
386
|
);
|
|
303
387
|
}
|
|
304
|
-
|
|
305
|
-
|
|
388
|
+
function Strip({
|
|
389
|
+
items,
|
|
390
|
+
index,
|
|
391
|
+
onSelect
|
|
392
|
+
}: {
|
|
393
|
+
items: readonly {
|
|
394
|
+
id: string;
|
|
395
|
+
label: string;
|
|
396
|
+
thumb: string | null;
|
|
397
|
+
}[];
|
|
398
|
+
index: number;
|
|
399
|
+
onSelect: (index: number) => void;
|
|
400
|
+
}) {
|
|
306
401
|
const selected = useRef<HTMLButtonElement>(null);
|
|
307
402
|
useEffect(() => {
|
|
308
403
|
selected.current?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
|
309
404
|
}, [index]);
|
|
310
405
|
if (items.length < 2) return null;
|
|
311
406
|
return (
|
|
312
|
-
<
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
<
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
407
|
+
<ScrollArea type="auto" mt="sm">
|
|
408
|
+
<Group gap="xs" wrap="nowrap" aria-label="Captured frames">
|
|
409
|
+
{items.map((item, itemIndex) => (
|
|
410
|
+
<Box key={item.id}>
|
|
411
|
+
<Button
|
|
412
|
+
ref={itemIndex === index ? selected : undefined}
|
|
413
|
+
type="button"
|
|
414
|
+
aria-current={itemIndex === index}
|
|
415
|
+
aria-label={item.label}
|
|
416
|
+
title={item.label}
|
|
417
|
+
onClick={() => onSelect(itemIndex)}
|
|
418
|
+
variant={itemIndex === index ? 'light' : 'default'}
|
|
419
|
+
h="auto"
|
|
420
|
+
p="xs"
|
|
421
|
+
>
|
|
422
|
+
{item.thumb ? (
|
|
423
|
+
<Image src={item.thumb} alt="" fit="contain" w="4rem" h="3rem" />
|
|
424
|
+
) : (
|
|
425
|
+
<Text span inherit>
|
|
426
|
+
{item.label}
|
|
427
|
+
</Text>
|
|
428
|
+
)}
|
|
429
|
+
</Button>
|
|
430
|
+
</Box>
|
|
431
|
+
))}
|
|
432
|
+
</Group>
|
|
433
|
+
</ScrollArea>
|
|
321
434
|
);
|
|
322
435
|
}
|
|
@@ -1,18 +1,16 @@
|
|
|
1
|
-
import '
|
|
2
|
-
import '@
|
|
3
|
-
|
|
4
|
-
import * as Tooltip from '@radix-ui/react-tooltip';
|
|
1
|
+
import '@mantine/core/styles.css';
|
|
2
|
+
import '@mantine/code-highlight/styles.css';
|
|
3
|
+
import '@mantine/spotlight/styles.css';
|
|
5
4
|
import { QueryClientProvider } from '@tanstack/react-query';
|
|
6
5
|
import { RouterProvider } from '@tanstack/react-router';
|
|
6
|
+
import '@xyflow/react/dist/base.css';
|
|
7
7
|
import { StrictMode } from 'react';
|
|
8
8
|
import { createRoot } from 'react-dom/client';
|
|
9
|
-
|
|
10
|
-
import { router } from './app/router';
|
|
11
9
|
import { ReportSourcesProvider } from './app/report-sources';
|
|
12
|
-
import {
|
|
10
|
+
import { router } from './app/router';
|
|
13
11
|
import { createAdapter } from './data/adapters';
|
|
14
|
-
import { createQueryClient } from './data/queries';
|
|
15
|
-
|
|
12
|
+
import { AdapterContext, createQueryClient } from './data/queries';
|
|
13
|
+
import { AtlasThemeProvider } from './ui/theme';
|
|
16
14
|
const root = document.getElementById('root');
|
|
17
15
|
if (root) {
|
|
18
16
|
createRoot(root).render(
|
|
@@ -20,9 +18,9 @@ if (root) {
|
|
|
20
18
|
<AdapterContext.Provider value={createAdapter()}>
|
|
21
19
|
<QueryClientProvider client={createQueryClient()}>
|
|
22
20
|
<ReportSourcesProvider>
|
|
23
|
-
<
|
|
21
|
+
<AtlasThemeProvider>
|
|
24
22
|
<RouterProvider router={router} />
|
|
25
|
-
</
|
|
23
|
+
</AtlasThemeProvider>
|
|
26
24
|
</ReportSourcesProvider>
|
|
27
25
|
</QueryClientProvider>
|
|
28
26
|
</AdapterContext.Provider>
|