rvlib-shipkit 2.2.0 → 2.4.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 +47 -0
- package/package.json +1 -1
- package/src/agent-time/mask.ts +43 -0
- package/src/agent-time/render.ts +187 -0
- package/src/agent-time/report.ts +335 -0
- package/src/agent-time/shape.ts +271 -0
- package/src/agent-time/signals.ts +84 -0
- package/src/agent-time/store.ts +379 -0
- package/src/agent-time/timeline.ts +218 -0
- package/src/agent-time/turns.ts +135 -0
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +9 -0
- package/src/cli/cmd_Review.ts +974 -0
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Time.ts +112 -0
- package/src/cli/cmd_Trailer.ts +264 -6
- package/src/cli/kit/verb.ts +22 -4
- package/src/cli.ts +8 -4
- package/src/collections/collection-types.ts +6 -1
- package/src/collections/providers.ts +3 -0
- package/src/collections/review.ts +70 -0
- package/src/core/app-icon.ts +10 -6
- package/src/core/layout.ts +2 -0
- package/src/reflect/prompt-corpus.ts +15 -119
- package/src/reflect/transcript.ts +254 -0
- package/src/repo-config.ts +3 -0
- package/src/review/anim.ts +228 -0
- package/src/review/auto.ts +141 -0
- package/src/review/format.ts +158 -0
- package/src/review/http.ts +23 -0
- package/src/review/marks.ts +43 -0
- package/src/review/media.ts +45 -0
- package/src/review/model.ts +896 -0
- package/src/review/project.ts +126 -0
- package/src/review/role.ts +48 -0
- package/src/review/routes.ts +239 -0
- package/src/review/store.ts +1011 -0
- package/src/review/threads.ts +237 -0
- package/src/review/trailer-desk.ts +385 -0
- package/src/review/trailer-model.ts +183 -0
- package/src/review/trailer-routes.ts +136 -0
- package/src/review/unit.ts +183 -0
- package/src/review/watchers.ts +54 -0
- package/src/serve/embed.ts +35 -0
- package/src/serve/hub-app/activity-view.tsx +1 -1
- package/src/serve/hub-app/activity.css +0 -4
- package/src/serve/hub-app/admin-svc.ts +1 -1
- package/src/serve/hub-app/admin-view.tsx +1 -1
- package/src/serve/hub-app/dock-view.tsx +5 -4
- package/src/serve/hub-app/hash-route.ts +36 -1
- package/src/serve/hub-app/header-bar.tsx +3 -1
- package/src/serve/hub-app/home-st.ts +17 -3
- package/src/serve/hub-app/home-strip.tsx +3 -2
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +80 -11
- package/src/serve/hub-app/hub.css +83 -14
- package/src/serve/hub-app/icons.tsx +15 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-view.tsx +4 -3
- package/src/serve/hub-app/keys.ts +14 -10
- package/src/serve/hub-app/layout-st.ts +63 -2
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/planned-view.tsx +3 -2
- package/src/serve/hub-app/planned.css +0 -1
- package/src/serve/hub-app/project-list.css +26 -6
- package/src/serve/hub-app/project-list.tsx +46 -10
- package/src/serve/hub-app/projects-svc.ts +7 -2
- package/src/serve/hub-app/repo-sections.ts +15 -3
- package/src/serve/hub-app/repo-st.ts +61 -4
- package/src/serve/hub-app/review/clip-st.ts +258 -0
- package/src/serve/hub-app/review/kind-view.tsx +240 -0
- package/src/serve/hub-app/review/kit.tsx +113 -0
- package/src/serve/hub-app/review/review-answer.tsx +269 -0
- package/src/serve/hub-app/review/review-board.tsx +46 -0
- package/src/serve/hub-app/review/review-list.tsx +156 -0
- package/src/serve/hub-app/review/review-page.tsx +96 -0
- package/src/serve/hub-app/review/review-st.ts +902 -0
- package/src/serve/hub-app/review/review-views.tsx +648 -0
- package/src/serve/hub-app/review/trailer-page.tsx +416 -0
- package/src/serve/hub-app/review/trailer-st.ts +602 -0
- package/src/serve/hub-app/review/trailer-timeline.tsx +207 -0
- package/src/serve/hub-app/review.css +389 -0
- package/src/serve/hub-app/theme-st.ts +56 -0
- package/src/serve/hub-app/tree-build.ts +4 -0
- package/src/serve/hub-app/tree-row.tsx +1 -1
- package/src/serve/hub-app/tree-svc.ts +4 -0
- package/src/serve/hub-app/wiki-st.ts +190 -0
- package/src/serve/hub-app/wikis-view.tsx +134 -5
- package/src/serve/hub-runtime.ts +9 -7
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/same-origin.ts +5 -1
- package/src/serve/serve-hub.ts +15 -0
- package/src/shipkit-files.ts +9 -5
- package/src/shot/client.ts +17 -5
- package/src/shot/shooter.ts +19 -3
- package/src/shot/shot-ask.ts +9 -1
- package/src/shot/stepped-clip.ts +53 -0
- package/src/trailer/adapter.ts +90 -6
- package/src/trailer/build.ts +459 -59
- package/src/trailer/ffmpeg.ts +22 -0
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +112 -43
- package/src/trailer/grammar.ts +864 -127
- package/src/trailer/graph.ts +308 -49
- package/src/trailer/holes.ts +72 -0
- package/src/trailer/load-unit.ts +20 -3
- package/src/trailer/look.ts +20 -3
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/passage.ts +129 -0
- package/src/trailer/plan.ts +82 -23
- package/src/trailer/prune.ts +204 -0
- package/src/trailer/score/compose.ts +256 -0
- package/src/trailer/score/dsp.ts +272 -0
- package/src/trailer/score/gm-render.ts +43 -0
- package/src/trailer/score/gm.ts +90 -0
- package/src/trailer/score/midi.ts +143 -0
- package/src/trailer/score/palette.ts +188 -0
- package/src/trailer/score/pattern.ts +98 -0
- package/src/trailer/score/score.ts +228 -0
- package/src/trailer/score/sound.ts +123 -0
- package/src/trailer/score/synth.ts +383 -0
- package/src/trailer/sound/audio.ts +148 -0
- package/src/trailer/sound/brand.ts +42 -0
- package/src/trailer/sound/comfy.ts +38 -0
- package/src/trailer/sound/eleven.ts +72 -0
- package/src/trailer/sound/fluid.ts +71 -0
- package/src/trailer/sound/gen.ts +139 -0
- package/src/trailer/sound/index.ts +60 -0
- package/src/trailer/sound/presets.ts +69 -0
- package/src/trailer/sound/run.ts +189 -0
- package/src/trailer/states.ts +76 -0
- package/src/trailer/stitch.ts +92 -9
- package/src/trailer/titles.ts +471 -150
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +34 -4
- package/src/trailer/words.ts +275 -0
- package/src/wiki/graph-mount.ts +3 -0
- package/src/wiki/graph-page.ts +45 -9
- package/src/wiki/graph-pages.ts +128 -0
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
// a repo's review page as state (wiki build/shipkit/review.md, The review page): the cards on disk, the open one, the
|
|
2
|
+
// filters, and his draft per card (rectangles, reactions and comments not sent yet), kept in the browser until it is
|
|
3
|
+
// sent. The list is read while the page shows, every 2 s, answered 304 when the store did not move
|
|
4
|
+
import { makeAutoObservable, reaction, runInAction } from 'mobx'
|
|
5
|
+
import {
|
|
6
|
+
asOptions,
|
|
7
|
+
isApproved,
|
|
8
|
+
isChoice,
|
|
9
|
+
isGallery,
|
|
10
|
+
isPair,
|
|
11
|
+
laneOf,
|
|
12
|
+
moveOf,
|
|
13
|
+
reworkOf,
|
|
14
|
+
STARS,
|
|
15
|
+
STATUS_LABEL,
|
|
16
|
+
stateOf,
|
|
17
|
+
unreadBy,
|
|
18
|
+
verdictOf,
|
|
19
|
+
type MediaView,
|
|
20
|
+
type Message,
|
|
21
|
+
type Move,
|
|
22
|
+
type Rect,
|
|
23
|
+
type ReviewItem,
|
|
24
|
+
type ReviewReaction,
|
|
25
|
+
type ReviewStatus,
|
|
26
|
+
type ShownStatus,
|
|
27
|
+
type Side,
|
|
28
|
+
type Spec,
|
|
29
|
+
} from '../../../review/model.ts'
|
|
30
|
+
import { answererOf, chainOf, childrenOf, conversationOf, followedIds, isActive, membersOf, orderedThreads, statusOf, threadOfItem, waits, type Thread } from '../../../review/threads.ts'
|
|
31
|
+
import { reach } from '../reach.ts'
|
|
32
|
+
import type { RepoSt } from '../repo-st.ts'
|
|
33
|
+
import type { Role } from '../../../review/role.ts'
|
|
34
|
+
import { ClipSt } from './clip-st.ts'
|
|
35
|
+
|
|
36
|
+
interface Draft {
|
|
37
|
+
rects: Rect[]
|
|
38
|
+
comment: string
|
|
39
|
+
reactions?: ReviewReaction[]
|
|
40
|
+
/** the option he picked, on a choice card */
|
|
41
|
+
pick?: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** a live `sk review watch`: its process, the session or lane it keeps, when it started */
|
|
45
|
+
export interface Watcher {
|
|
46
|
+
pid: number
|
|
47
|
+
session: string | null
|
|
48
|
+
lane: string | null
|
|
49
|
+
started: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface Listing {
|
|
53
|
+
items: ReviewItem[]
|
|
54
|
+
broken: { id: string; error: string }[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** the zoom of a view: fit the column (and the window's height), or a share of the image's own pixels */
|
|
58
|
+
export const ZOOMS = ['fit', '1', '1.5', '2'] as const
|
|
59
|
+
export type Zoom = (typeof ZOOMS)[number]
|
|
60
|
+
|
|
61
|
+
/** how a pair of images shows: side by side, the curtain, one over the other, the after alone */
|
|
62
|
+
export type CompareMode = 'side' | 'swipe' | 'onion' | 'mod'
|
|
63
|
+
export const COMPARE_MODES: readonly CompareMode[] = ['side', 'swipe', 'onion', 'mod']
|
|
64
|
+
/** the side a rectangle lands on: the after alone draws on the after */
|
|
65
|
+
export const drawnSide = (mode: CompareMode, drawn: Side): Side => (mode === 'mod' ? 'after' : drawn)
|
|
66
|
+
|
|
67
|
+
/** below this width the page is the phone's: one column at a time, the verdicts in a bar under the thumb */
|
|
68
|
+
/** a repo's review unit as the page reads it: the words of its kinds and topics, its dispatcher and who holds the role */
|
|
69
|
+
export type RepoSpec = Spec & { role: Role | null; sources: string[]; auto: { id: string; every: string }[] }
|
|
70
|
+
|
|
71
|
+
export const PHONE_PX = 768
|
|
72
|
+
/** below this width of the review page itself (the project list and the sidebar already took theirs), `auto` stacks
|
|
73
|
+
* the card over the answer in one column beside the list */
|
|
74
|
+
export const STACK_PX = 900
|
|
75
|
+
/** where the card sits against its answer: on top of it, left of it, or by the page's width */
|
|
76
|
+
export const CARD_PLACES = ['top', 'left', 'auto'] as const
|
|
77
|
+
export type CardPlace = (typeof CARD_PLACES)[number]
|
|
78
|
+
export type PhonePane = 'list' | 'card' | 'answer'
|
|
79
|
+
export type PairSide = 'before' | 'after' | 'swipe'
|
|
80
|
+
|
|
81
|
+
const POLL_MS = 2000
|
|
82
|
+
/** a sound's clock steps a tenth of a second */
|
|
83
|
+
const SOUND_FPS = 10
|
|
84
|
+
/** ← → on a sound jump this many seconds */
|
|
85
|
+
const SOUND_JUMP = 2
|
|
86
|
+
|
|
87
|
+
const sameSpot = (r: ReviewReaction, view: number, tile: number | undefined): boolean => r.view === view && r.tile === tile
|
|
88
|
+
|
|
89
|
+
/** what a reload must not lose, under keys of this repo: a blocked storage only loses persistence */
|
|
90
|
+
const store = {
|
|
91
|
+
get(k: string): string | null {
|
|
92
|
+
try {
|
|
93
|
+
return localStorage.getItem(k)
|
|
94
|
+
} catch {
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
put(k: string, v: string | null): void {
|
|
99
|
+
try {
|
|
100
|
+
if (v === null) localStorage.removeItem(k)
|
|
101
|
+
else localStorage.setItem(k, v)
|
|
102
|
+
} catch {
|
|
103
|
+
// as above
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const draftOf = (s: string | null): Draft | null => {
|
|
109
|
+
try {
|
|
110
|
+
const d = s === null ? null : (JSON.parse(s) as Partial<Draft>)
|
|
111
|
+
if (!d || !Array.isArray(d.rects) || typeof d.comment !== 'string') return null
|
|
112
|
+
return { rects: d.rects, comment: d.comment, ...(Array.isArray(d.reactions) ? { reactions: d.reactions } : {}), ...(typeof d.pick === 'number' ? { pick: d.pick } : {}) }
|
|
113
|
+
} catch {
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** a loader for a polled list: it sends the tag of its last answer, and on « not modified » gives that answer again */
|
|
119
|
+
function unchangedAware<T>(url: () => string): () => Promise<T> {
|
|
120
|
+
let last: { tag: string; value: T } | null = null
|
|
121
|
+
return async () => {
|
|
122
|
+
const res = await reach(url(), { cache: 'no-store', headers: last ? { 'If-None-Match': last.tag } : {} })
|
|
123
|
+
if (res.status === 304 && last) return last.value
|
|
124
|
+
if (!res.ok) throw new Error(`${url()}: HTTP ${res.status} ${await res.text().catch(() => '')}`.trim())
|
|
125
|
+
const value = (await res.json()) as T
|
|
126
|
+
const tag = res.headers.get('ETag')
|
|
127
|
+
last = tag ? { tag, value } : null
|
|
128
|
+
return value
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function postJson<T>(url: string, body: unknown): Promise<T> {
|
|
133
|
+
const res = await reach(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
134
|
+
const text = await res.text()
|
|
135
|
+
if (!res.ok) {
|
|
136
|
+
let msg = text
|
|
137
|
+
try {
|
|
138
|
+
msg = (JSON.parse(text) as { error?: string }).error ?? text
|
|
139
|
+
} catch {
|
|
140
|
+
// a plain text answer says it already
|
|
141
|
+
}
|
|
142
|
+
throw new Error(msg || `HTTP ${res.status}`)
|
|
143
|
+
}
|
|
144
|
+
return JSON.parse(text) as T
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** the pair's compare mode, the curtain's line and the onion's opacity, shared by every pair of the page */
|
|
148
|
+
export class CompareSt {
|
|
149
|
+
mode: CompareMode = 'side'
|
|
150
|
+
split = 0.5
|
|
151
|
+
opacity = 0.5
|
|
152
|
+
constructor(private readonly key: string) {
|
|
153
|
+
const m = store.get(key)
|
|
154
|
+
if (m && (COMPARE_MODES as readonly string[]).includes(m)) this.mode = m as CompareMode
|
|
155
|
+
makeAutoObservable<this, 'key'>(this, { key: false })
|
|
156
|
+
}
|
|
157
|
+
setMode(m: CompareMode): void {
|
|
158
|
+
this.mode = m
|
|
159
|
+
store.put(this.key, m)
|
|
160
|
+
}
|
|
161
|
+
setSplit(v: number): void {
|
|
162
|
+
this.split = Math.max(0, Math.min(1, v))
|
|
163
|
+
}
|
|
164
|
+
setOpacity(v: number): void {
|
|
165
|
+
this.opacity = Math.max(0, Math.min(1, v))
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** the phone's state: which pane shows, « circle » drawing on, the side of a pair */
|
|
170
|
+
export class PhoneSt {
|
|
171
|
+
on = false
|
|
172
|
+
pane: PhonePane = 'list'
|
|
173
|
+
draw = false
|
|
174
|
+
pair: PairSide = 'after'
|
|
175
|
+
constructor() {
|
|
176
|
+
makeAutoObservable(this)
|
|
177
|
+
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
|
178
|
+
const mq = window.matchMedia(`(max-width: ${PHONE_PX - 1}px)`)
|
|
179
|
+
this.on = mq.matches
|
|
180
|
+
mq.addEventListener('change', (e) => runInAction(() => (this.on = e.matches)))
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
show(p: PhonePane): void {
|
|
184
|
+
this.pane = p
|
|
185
|
+
}
|
|
186
|
+
toggleDraw(): void {
|
|
187
|
+
this.draw = !this.draw
|
|
188
|
+
}
|
|
189
|
+
setPair(s: PairSide): void {
|
|
190
|
+
this.pair = s
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const isShownStatus = (s: string): s is ShownStatus => Object.hasOwn(STATUS_LABEL, s)
|
|
195
|
+
|
|
196
|
+
export class ReviewSt {
|
|
197
|
+
list: Listing | null = null
|
|
198
|
+
listError: string | null = null
|
|
199
|
+
/** the repo's review unit in words (its kinds, topics, dispatcher and its holder), read when the page starts */
|
|
200
|
+
spec: RepoSpec | null = null
|
|
201
|
+
/** the dispatcher's board in place of the cards */
|
|
202
|
+
board = false
|
|
203
|
+
watchers: Watcher[] = []
|
|
204
|
+
/** each clip view's own clock on the open card, by view index, made on first use */
|
|
205
|
+
private readonly clips = new Map<number, ClipSt>()
|
|
206
|
+
private clipsCard = ''
|
|
207
|
+
selectedId: string | null = null
|
|
208
|
+
query = ''
|
|
209
|
+
subject: string | null = null
|
|
210
|
+
whose: Move | null = null
|
|
211
|
+
status: ShownStatus | null = null
|
|
212
|
+
drafts: Record<string, Draft> = {}
|
|
213
|
+
/** the rectangle of the open card picked on the image, its index */
|
|
214
|
+
rect: number | null = null
|
|
215
|
+
/** the subject rings over the image (M) */
|
|
216
|
+
marksOn = true
|
|
217
|
+
/** the open card's active view, its index: the one last drawn on or picked, whose clip the keys drive */
|
|
218
|
+
view = 0
|
|
219
|
+
zoom: Zoom = 'fit'
|
|
220
|
+
viewZoom: Record<number, Zoom> = {}
|
|
221
|
+
/** the gallery tile shown large across the column, `<view>/<tile>` */
|
|
222
|
+
bigTile: string | null = null
|
|
223
|
+
/** a card of options shown full, every view stacked, by id: his switch, this visit */
|
|
224
|
+
full: Record<string, boolean> = {}
|
|
225
|
+
/** the option rows unfolded to their full view, by view index */
|
|
226
|
+
openRows: Record<number, boolean> = {}
|
|
227
|
+
replies: Record<string, string> = {}
|
|
228
|
+
private reading = new Set<string>()
|
|
229
|
+
/** « hide handled »: only the active threads in the list */
|
|
230
|
+
hideHandled = true
|
|
231
|
+
/** « by lane »: the list grouped under one header per lane */
|
|
232
|
+
byLane = false
|
|
233
|
+
/** the threads whose older versions are unfolded, by head id */
|
|
234
|
+
unfolded: Record<string, boolean> = {}
|
|
235
|
+
busy = false
|
|
236
|
+
error: string | null = null
|
|
237
|
+
/** the card against its answer, his pick, kept with the prefs */
|
|
238
|
+
place: CardPlace = 'auto'
|
|
239
|
+
/** the page is narrower than STACK_PX */
|
|
240
|
+
narrow = false
|
|
241
|
+
readonly compare: CompareSt
|
|
242
|
+
readonly phone = new PhoneSt()
|
|
243
|
+
private timer: ReturnType<typeof setInterval> | null = null
|
|
244
|
+
private readonly loadList: () => Promise<Listing>
|
|
245
|
+
private readonly loadWatchers: () => Promise<Watcher[]>
|
|
246
|
+
private readonly prefix: string
|
|
247
|
+
|
|
248
|
+
/** the page measured its width */
|
|
249
|
+
fit(px: number): void {
|
|
250
|
+
const on = px > 0 && px < STACK_PX
|
|
251
|
+
if (this.narrow !== on) this.narrow = on
|
|
252
|
+
}
|
|
253
|
+
/** the card over its answer in one column: picked, or `auto` on a narrow page */
|
|
254
|
+
get stacked(): boolean {
|
|
255
|
+
return this.place === 'top' || (this.place === 'auto' && this.narrow)
|
|
256
|
+
}
|
|
257
|
+
setPlace(p: CardPlace): void {
|
|
258
|
+
this.place = p
|
|
259
|
+
}
|
|
260
|
+
/** the open card as thin option rows: a choice, or views that each play alone, unless he asked them full */
|
|
261
|
+
get rows(): boolean {
|
|
262
|
+
const x = this.open
|
|
263
|
+
return x !== null && asOptions(x.item) && this.full[x.item.id] !== true
|
|
264
|
+
}
|
|
265
|
+
setFull(on: boolean): void {
|
|
266
|
+
const id = this.open?.item.id
|
|
267
|
+
if (id) this.full = { ...this.full, [id]: on }
|
|
268
|
+
}
|
|
269
|
+
get choice(): boolean {
|
|
270
|
+
const x = this.open
|
|
271
|
+
return x !== null && isChoice(x.item)
|
|
272
|
+
}
|
|
273
|
+
/** the card's header (title, state, what, and in one column the verdicts) sits over its views: one column, the
|
|
274
|
+
* phone's card pane, a choice's options */
|
|
275
|
+
get headInViews(): boolean {
|
|
276
|
+
return this.stacked || this.phone.on || this.choice
|
|
277
|
+
}
|
|
278
|
+
toggleRow(i: number): void {
|
|
279
|
+
this.openRows = { ...this.openRows, [i]: !this.openRows[i] }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
constructor(private readonly st: RepoSt) {
|
|
283
|
+
this.prefix = `shipkit.review.${st.key}.`
|
|
284
|
+
this.loadList = unchangedAware(() => st.url('/api/review'))
|
|
285
|
+
this.loadWatchers = unchangedAware(() => st.url('/api/review/watchers'))
|
|
286
|
+
this.compare = new CompareSt(`${this.prefix}compare`)
|
|
287
|
+
try {
|
|
288
|
+
const p = JSON.parse(store.get(`${this.prefix}prefs`) ?? '{}') as { hideHandled?: boolean; byLane?: boolean; zoom?: string; marks?: boolean; place?: string }
|
|
289
|
+
if (p.hideHandled === false) this.hideHandled = false
|
|
290
|
+
if (p.byLane === true) this.byLane = true
|
|
291
|
+
if (p.marks === false) this.marksOn = false
|
|
292
|
+
if ((ZOOMS as readonly string[]).includes(p.zoom ?? '')) this.zoom = p.zoom as Zoom
|
|
293
|
+
if ((CARD_PLACES as readonly string[]).includes(p.place ?? '')) this.place = p.place as CardPlace
|
|
294
|
+
} catch {
|
|
295
|
+
// a broken pref is the default
|
|
296
|
+
}
|
|
297
|
+
makeAutoObservable<this, 'st' | 'clips' | 'clipsCard' | 'reading' | 'timer' | 'loadList' | 'loadWatchers' | 'prefix'>(this, {
|
|
298
|
+
st: false,
|
|
299
|
+
clips: false,
|
|
300
|
+
clipsCard: false,
|
|
301
|
+
reading: false,
|
|
302
|
+
timer: false,
|
|
303
|
+
loadList: false,
|
|
304
|
+
loadWatchers: false,
|
|
305
|
+
prefix: false,
|
|
306
|
+
compare: false,
|
|
307
|
+
phone: false,
|
|
308
|
+
})
|
|
309
|
+
// opening a card reads the agent's messages on its whole thread
|
|
310
|
+
reaction(
|
|
311
|
+
() => (this.openThread ? membersOf(this.openThread) : []).filter((x) => unreadBy(x, 'owner').length > 0).map((x) => x.item.id),
|
|
312
|
+
(ids) => {
|
|
313
|
+
for (const id of ids) void this.markRead(id)
|
|
314
|
+
},
|
|
315
|
+
)
|
|
316
|
+
reaction(
|
|
317
|
+
() => JSON.stringify({ hideHandled: this.hideHandled, byLane: this.byLane, zoom: this.zoom, marks: this.marksOn, place: this.place }),
|
|
318
|
+
(s) => store.put(`${this.prefix}prefs`, s),
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** the list and the watchers, read now and every 2 s while the page shows; the unit's words once a start */
|
|
323
|
+
start(): void {
|
|
324
|
+
void this.refresh()
|
|
325
|
+
void fetch(this.st.url('/api/review/spec'))
|
|
326
|
+
.then((r) => (r.ok ? (r.json() as Promise<RepoSpec>) : null))
|
|
327
|
+
.catch(() => null)
|
|
328
|
+
.then((spec) => runInAction(() => (this.spec = spec)))
|
|
329
|
+
if (this.timer === null) this.timer = setInterval(() => void this.refresh(), POLL_MS)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
stop(): void {
|
|
333
|
+
if (this.timer !== null) clearInterval(this.timer)
|
|
334
|
+
this.timer = null
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async refresh(): Promise<void> {
|
|
338
|
+
try {
|
|
339
|
+
const [list, watchers] = await Promise.all([this.loadList(), this.loadWatchers().catch(() => this.watchers)])
|
|
340
|
+
runInAction(() => {
|
|
341
|
+
if (list !== this.list) this.list = list
|
|
342
|
+
if (watchers !== this.watchers) this.watchers = watchers
|
|
343
|
+
this.listError = null
|
|
344
|
+
})
|
|
345
|
+
} catch (e) {
|
|
346
|
+
runInAction(() => (this.listError = (e as Error).message))
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** the card's folder, or one of its files, shown in the Finder of the machine the hub runs on */
|
|
351
|
+
async reveal(id: string, file?: string): Promise<void> {
|
|
352
|
+
try {
|
|
353
|
+
await postJson(this.st.url(`/api/review/${id}/reveal`), file ? { file } : {})
|
|
354
|
+
} catch (e) {
|
|
355
|
+
runInAction(() => (this.error = (e as Error).message))
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
setBoard(on: boolean): void {
|
|
360
|
+
this.board = on
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
fileUrl(id: string, name: string): string {
|
|
364
|
+
return this.st.url(`/api/review/${id}/${name}`)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
thumbUrl(x: ReviewItem, name: string): string {
|
|
368
|
+
return /\.(png|jpe?g|webp)$/.test(name) ? this.st.url(`/api/review/${x.item.id}/thumb/${name}`) : this.fileUrl(x.item.id, name)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
threadImageUrl(id: string, name: string): string {
|
|
372
|
+
return this.st.url(`/api/review/${id}/thread/${name}`)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
get items(): ReviewItem[] {
|
|
376
|
+
return this.list?.items ?? []
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
get broken(): Listing['broken'] {
|
|
380
|
+
return this.list?.broken ?? []
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
statusOf(x: ReviewItem): ShownStatus {
|
|
384
|
+
return statusOf(x, this.items)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
get followed(): Set<string> {
|
|
388
|
+
return followedIds(this.items)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
reworkOf(x: ReviewItem): ReturnType<typeof reworkOf> {
|
|
392
|
+
return reworkOf(x, this.followed.has(x.item.id), new Date())
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** the live watches that read his answer on any of these cards */
|
|
396
|
+
listenersOf(cards: readonly ReviewItem[]): Watcher[] {
|
|
397
|
+
return this.watchers.filter((w) =>
|
|
398
|
+
cards.some((x) => (w.session === null || (x.item.origin.by === 'agent' && x.item.origin.session === w.session)) && (w.lane === null || laneOf(x) === w.lane)),
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
get threads(): Thread[] {
|
|
403
|
+
return orderedThreads(this.items)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** what waits on him: the cards waiting for the owner's verdict, and what he raised that asks him */
|
|
407
|
+
get pendingCount(): number {
|
|
408
|
+
return this.threads.filter((t) => waits(t.head) || (t.head.item.origin.by !== 'agent' && this.moveOf(t.head) === 'owner')).length
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
get activeCount(): number {
|
|
412
|
+
return this.threads.filter((t) => isActive(t)).length
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** the subject kinds this store holds, for the filter */
|
|
416
|
+
get subjects(): string[] {
|
|
417
|
+
return [...new Set(this.items.map((x) => x.item.subject.kind))].sort()
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** the list as the switch, the filters and the search show it: one thread per head */
|
|
421
|
+
get visible(): Thread[] {
|
|
422
|
+
const q = this.query.trim().toLowerCase()
|
|
423
|
+
return this.threads.filter(
|
|
424
|
+
(t) =>
|
|
425
|
+
(!this.hideHandled || isActive(t)) &&
|
|
426
|
+
(this.subject === null || t.head.item.subject.kind === this.subject) &&
|
|
427
|
+
(this.whose === null || this.moveOf(t.head) === this.whose) &&
|
|
428
|
+
(this.status === null || this.statusOf(t.head) === this.status) &&
|
|
429
|
+
(q === '' || membersOf(t).some((x) => `${x.item.title} ${x.item.what ?? ''} ${x.item.id}`.toLowerCase().includes(q))),
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** the visible threads under one header per lane; null: the cards with no lane */
|
|
434
|
+
get laneGroups(): { lane: string | null; threads: Thread[] }[] {
|
|
435
|
+
const groups = new Map<string | null, Thread[]>()
|
|
436
|
+
for (const t of this.visible) {
|
|
437
|
+
const lane = laneOf(t.head) ?? null
|
|
438
|
+
groups.set(lane, [...(groups.get(lane) ?? []), t])
|
|
439
|
+
}
|
|
440
|
+
const all = [...groups].map(([lane, threads]) => ({ lane, threads }))
|
|
441
|
+
return [...all.filter((g) => g.lane !== null), ...all.filter((g) => g.lane === null)]
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
get open(): ReviewItem | null {
|
|
445
|
+
return this.items.find((x) => x.item.id === this.selectedId) ?? this.visible[0]?.head ?? this.threads[0]?.head ?? null
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
get openThread(): Thread | null {
|
|
449
|
+
const id = this.open?.item.id
|
|
450
|
+
return id === undefined ? null : threadOfItem(id, this.threads)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** the reports and notes nested in the open card */
|
|
454
|
+
get answered(): ReviewItem[] {
|
|
455
|
+
const t = this.openThread
|
|
456
|
+
const id = this.open?.item.id
|
|
457
|
+
return t && t.versions.some((x) => x.item.id === id) ? t.answers : []
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
get answeredBy(): ReviewItem | null {
|
|
461
|
+
const x = this.open
|
|
462
|
+
return x && x.item.origin.by !== 'agent' ? answererOf(x.item.id, this.items) : null
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** the older versions of the open card, the newest of them first */
|
|
466
|
+
get history(): ReviewItem[] {
|
|
467
|
+
const x = this.open
|
|
468
|
+
return x ? chainOf(x, this.items).slice(1) : []
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
get newer(): ReviewItem[] {
|
|
472
|
+
const x = this.open
|
|
473
|
+
return x ? childrenOf(x.item.id, this.items) : []
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
showsVersions(t: Thread): boolean {
|
|
477
|
+
return this.unfolded[t.head.item.id] === true || t.versions.slice(1).some((x) => x.item.id === this.selectedId)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
fold(headId: string): void {
|
|
481
|
+
this.unfolded = { ...this.unfolded, [headId]: !this.unfolded[headId] }
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
setHideHandled(on: boolean): void {
|
|
485
|
+
this.hideHandled = on
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
setByLane(on: boolean): void {
|
|
489
|
+
this.byLane = on
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
setQuery(q: string): void {
|
|
493
|
+
this.query = q
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
setFilter(f: { subject?: string | null; whose?: Move | null; status?: ShownStatus | null }): void {
|
|
497
|
+
if (f.subject !== undefined) this.subject = f.subject
|
|
498
|
+
if (f.whose !== undefined) this.whose = f.whose
|
|
499
|
+
if (f.status !== undefined) this.status = f.status
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
moveOf(x: ReviewItem): Move {
|
|
503
|
+
return moveOf(stateOf(x, this.followed.has(x.item.id)).state)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** what the form edits: the draft, else the sent feedback, else nothing yet */
|
|
507
|
+
get draft(): Draft {
|
|
508
|
+
const x = this.open
|
|
509
|
+
if (!x) return { rects: [], comment: '' }
|
|
510
|
+
const v = verdictOf(x)
|
|
511
|
+
const sent = v?.reactions
|
|
512
|
+
return (
|
|
513
|
+
this.drafts[x.item.id] ?? {
|
|
514
|
+
rects: v?.rects ?? [],
|
|
515
|
+
comment: v?.comment ?? '',
|
|
516
|
+
...(sent && sent.length > 0 ? { reactions: sent } : {}),
|
|
517
|
+
...(v?.pick !== undefined ? { pick: v.pick } : {}),
|
|
518
|
+
}
|
|
519
|
+
)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
get dirty(): boolean {
|
|
523
|
+
const x = this.open
|
|
524
|
+
return x !== null && this.drafts[x.item.id] !== undefined
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private edit(f: (d: Draft) => Draft): void {
|
|
528
|
+
const x = this.open
|
|
529
|
+
if (!x) return
|
|
530
|
+
const d = this.draft
|
|
531
|
+
const { reactions, ...rest } = f({ ...d, rects: [...d.rects], reactions: [...(d.reactions ?? [])] })
|
|
532
|
+
const next: Draft = reactions && reactions.length > 0 ? { ...rest, reactions } : rest
|
|
533
|
+
this.drafts = { ...this.drafts, [x.item.id]: next }
|
|
534
|
+
store.put(`${this.prefix}draft.${x.item.id}`, JSON.stringify(next))
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** opens a card from the page: its address follows */
|
|
538
|
+
pick(id: string): void {
|
|
539
|
+
this.st.showReview(id)
|
|
540
|
+
if (this.phone.on) this.phone.show('card')
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** what the address carries beside the card: its view and the list's filters, `view=1&subject=place`, each only
|
|
544
|
+
* off its default (`handled=1` shows the handled threads, `lanes=1` groups by lane) */
|
|
545
|
+
get address(): string {
|
|
546
|
+
const q = new URLSearchParams()
|
|
547
|
+
if (this.view > 0) q.set('view', String(this.view))
|
|
548
|
+
if (this.subject !== null) q.set('subject', this.subject)
|
|
549
|
+
if (this.whose !== null) q.set('whose', this.whose)
|
|
550
|
+
if (this.status !== null) q.set('status', this.status)
|
|
551
|
+
if (!this.hideHandled) q.set('handled', '1')
|
|
552
|
+
if (this.byLane) q.set('lanes', '1')
|
|
553
|
+
return q.toString()
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** an address's view and filters read back: a key it leaves out keeps what the page has; an unknown value is dropped */
|
|
557
|
+
applyAddress(raw: string): void {
|
|
558
|
+
const q = new URLSearchParams(raw)
|
|
559
|
+
const view = Number(q.get('view'))
|
|
560
|
+
if (Number.isInteger(view) && view > 0) this.view = view
|
|
561
|
+
const subject = q.get('subject')
|
|
562
|
+
if (subject) this.subject = subject
|
|
563
|
+
const whose = q.get('whose')
|
|
564
|
+
if (whose === 'owner' || whose === 'agents' || whose === 'none') this.whose = whose
|
|
565
|
+
const status = q.get('status')
|
|
566
|
+
if (status !== null && isShownStatus(status)) this.status = status
|
|
567
|
+
if (q.get('handled') === '1') this.hideHandled = false
|
|
568
|
+
if (q.get('lanes') === '1') this.byLane = true
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** the card the address names; its draft is read again from storage, the truth over every window */
|
|
572
|
+
select(id: string | null): void {
|
|
573
|
+
if (id !== this.selectedId) {
|
|
574
|
+
this.view = 0
|
|
575
|
+
this.viewZoom = {}
|
|
576
|
+
this.bigTile = null
|
|
577
|
+
this.openRows = {}
|
|
578
|
+
// a link to a card on the phone opens the card, never the list
|
|
579
|
+
if (id !== null && this.phone.on) this.phone.show('card')
|
|
580
|
+
}
|
|
581
|
+
this.selectedId = id
|
|
582
|
+
if (id !== null) {
|
|
583
|
+
const stored = draftOf(store.get(`${this.prefix}draft.${id}`))
|
|
584
|
+
if (stored) this.drafts = { ...this.drafts, [id]: stored }
|
|
585
|
+
else if (this.drafts[id]) {
|
|
586
|
+
const { [id]: _, ...rest } = this.drafts
|
|
587
|
+
this.drafts = rest
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
this.rect = null
|
|
591
|
+
this.error = null
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** ↑↓ on the page: the next thread head the list shows */
|
|
595
|
+
move(delta: 1 | -1): void {
|
|
596
|
+
const list = this.visible
|
|
597
|
+
const at = list.findIndex((t) => t.head.item.id === this.openThread?.head.item.id)
|
|
598
|
+
const next = list[Math.max(0, Math.min(list.length - 1, at + delta))]
|
|
599
|
+
if (next) this.pick(next.head.item.id)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** N: the next pending thread head after the open one, wrapping */
|
|
603
|
+
nextPending(): void {
|
|
604
|
+
const pend = this.threads.filter((t) => waits(t.head))
|
|
605
|
+
if (pend.length === 0) return
|
|
606
|
+
const at = pend.findIndex((t) => t.head.item.id === this.open?.item.id)
|
|
607
|
+
const next = pend[(at + 1) % pend.length]
|
|
608
|
+
if (next) this.pick(next.head.item.id)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** a rectangle on a view, which becomes the active one; on a clip it keeps the time of the frame shown */
|
|
612
|
+
addRect(r: { x: number; y: number; w: number; h: number }, side: Side | undefined, on: number = this.view): void {
|
|
613
|
+
if (on !== this.view) this.setView(on)
|
|
614
|
+
const view = this.view > 0 ? { view: this.view } : {}
|
|
615
|
+
const t = this.clipKey ? { t: Math.round(this.clip.time * 1000) / 1000 } : {}
|
|
616
|
+
this.edit((d) => ({ ...d, rects: [...d.rects, { ...r, comment: '', ...view, ...(side ? { side } : {}), ...t }] }))
|
|
617
|
+
this.rect = this.draft.rects.length - 1
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
setView(i: number): void {
|
|
621
|
+
this.view = i
|
|
622
|
+
this.rect = null
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** the playable view an option row or a clip shows: a clip, or a sound shown as a row */
|
|
626
|
+
private playableAt(i: number): MediaView | null {
|
|
627
|
+
const v = this.open?.item.views[i]
|
|
628
|
+
if (!v || isGallery(v)) return null
|
|
629
|
+
return v.clip || (v.audio && this.rows && !isPair(v.files)) ? v : null
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
get openView(): MediaView | null {
|
|
633
|
+
const v = this.open?.item.views[this.view]
|
|
634
|
+
return v && !isGallery(v) ? v : null
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
zoomOf(i: number): Zoom {
|
|
638
|
+
return this.viewZoom[i] ?? this.zoom
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
setZoom(z: Zoom): void {
|
|
642
|
+
this.zoom = z
|
|
643
|
+
this.viewZoom = {}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
setViewZoom(i: number, z: Zoom): void {
|
|
647
|
+
this.viewZoom = { ...this.viewZoom, [i]: z }
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
toggleTile(view: number, tile: number): void {
|
|
651
|
+
const k = `${view}/${tile}`
|
|
652
|
+
this.bigTile = this.bigTile === k ? null : k
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
reactionOf(view: number, tile?: number): ReviewReaction | null {
|
|
656
|
+
return (this.draft.reactions ?? []).find((r) => sameSpot(r, view, tile)) ?? null
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** a reaction's toggle: sets that status, or clears it when it is already the one */
|
|
660
|
+
react(view: number, tile: number | undefined, status: ReviewStatus): void {
|
|
661
|
+
const cur = this.reactionOf(view, tile)
|
|
662
|
+
this.putReaction(view, tile, { ...cur, status: cur?.status === status ? undefined : status, comment: cur?.comment ?? '' })
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
setReactionComment(view: number, tile: number | undefined, comment: string): void {
|
|
666
|
+
this.putReaction(view, tile, { ...this.reactionOf(view, tile), comment })
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** an option's stars; the same number again clears them */
|
|
670
|
+
setStars(view: number, n: number): void {
|
|
671
|
+
const cur = this.reactionOf(view)
|
|
672
|
+
this.putReaction(view, undefined, { ...cur, comment: cur?.comment ?? '', stars: cur?.stars === n ? undefined : n })
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
removeReaction(view: number, tile: number | undefined): void {
|
|
676
|
+
this.putReaction(view, tile, { comment: '' })
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** « pick »: that option is his answer, sent at once as an approval; his stars and words go with it */
|
|
680
|
+
async choose(view: number): Promise<void> {
|
|
681
|
+
if (!this.choice) return
|
|
682
|
+
this.edit((d) => ({ ...d, pick: view }))
|
|
683
|
+
await this.submit('approved')
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** the reactions that go to the agent with the verdict, in the card's order */
|
|
687
|
+
get outgoingReactions(): ReviewReaction[] {
|
|
688
|
+
return [...(this.draft.reactions ?? [])].sort((a, b) => a.view - b.view || (a.tile ?? -1) - (b.tile ?? -1))
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
private putReaction(view: number, tile: number | undefined, r: { status?: ReviewStatus | undefined; comment: string; stars?: number | undefined }): void {
|
|
692
|
+
const next: ReviewReaction = { view, ...(tile !== undefined ? { tile } : {}), ...(r.status ? { status: r.status } : {}), comment: r.comment, ...(r.stars ? { stars: r.stars } : {}) }
|
|
693
|
+
const empty = !r.status && r.comment === '' && !r.stars
|
|
694
|
+
this.edit((d) => {
|
|
695
|
+
const rest = (d.reactions ?? []).filter((q) => !sameSpot(q, view, tile))
|
|
696
|
+
return { ...d, reactions: empty ? rest : [...rest, next] }
|
|
697
|
+
})
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** the open view's clip identity (a clip, or a sound on a row), empty when it shows images */
|
|
701
|
+
get clipKey(): string {
|
|
702
|
+
const x = this.open
|
|
703
|
+
return x && this.playableAt(this.view) ? `${x.item.id}#${this.view}` : ''
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** a row's ▶: that option plays with its sound, the one playing before it stops; again, it pauses */
|
|
707
|
+
listen(i: number): void {
|
|
708
|
+
this.setView(i)
|
|
709
|
+
const c = this.clipOf(i)
|
|
710
|
+
if (c.paused) c.listen()
|
|
711
|
+
else c.pause()
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** the clock of a clip view of the open card: every clip view plays and steps on its own */
|
|
715
|
+
clipOf(view: number): ClipSt {
|
|
716
|
+
const id = this.open?.item.id ?? ''
|
|
717
|
+
if (id !== this.clipsCard) {
|
|
718
|
+
this.clips.clear()
|
|
719
|
+
this.clipsCard = id
|
|
720
|
+
}
|
|
721
|
+
let c = this.clips.get(view)
|
|
722
|
+
if (!c) {
|
|
723
|
+
c = new ClipSt()
|
|
724
|
+
const views = this.open?.item.views ?? []
|
|
725
|
+
const v = views[view]
|
|
726
|
+
const media = v && !isGallery(v) ? v : undefined
|
|
727
|
+
// one thing plays at a time: the first clip of a card loops on its own, the others and every option wait
|
|
728
|
+
const first = views.findIndex((w) => !isGallery(w) && w.clip !== undefined)
|
|
729
|
+
const still = this.rows || view !== first
|
|
730
|
+
if (media?.audio) c.load(`${id}#${view}`, media.audio.duration, SOUND_FPS, true)
|
|
731
|
+
else c.load(`${id}#${view}`, media?.clip?.duration ?? 0, media?.clip?.fps ?? 20, still)
|
|
732
|
+
this.clips.set(view, c)
|
|
733
|
+
}
|
|
734
|
+
return c
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
get clip(): ClipSt {
|
|
738
|
+
return this.clipOf(this.view)
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
get conversation(): ReturnType<typeof conversationOf> {
|
|
742
|
+
const t = this.openThread
|
|
743
|
+
return t ? conversationOf(t) : []
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** a thread holds an agent message he has not read: its card shows a dot */
|
|
747
|
+
unread(t: Thread): boolean {
|
|
748
|
+
return membersOf(t).some((x) => unreadBy(x, 'owner').length > 0)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
get reply(): string {
|
|
752
|
+
const id = this.open?.item.id
|
|
753
|
+
return id ? (this.replies[id] ?? store.get(`${this.prefix}reply.${id}`) ?? '') : ''
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
setReply(text: string): void {
|
|
757
|
+
const id = this.open?.item.id
|
|
758
|
+
if (!id) return
|
|
759
|
+
this.replies = { ...this.replies, [id]: text }
|
|
760
|
+
store.put(`${this.prefix}reply.${id}`, text || null)
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** ⌘Enter or « reply »: his message goes to the open card */
|
|
764
|
+
async sendReply(): Promise<void> {
|
|
765
|
+
const x = this.open
|
|
766
|
+
const text = this.reply.trim()
|
|
767
|
+
if (!x || !text || this.busy) return
|
|
768
|
+
const id = x.item.id
|
|
769
|
+
this.busy = true
|
|
770
|
+
this.error = null
|
|
771
|
+
try {
|
|
772
|
+
await postJson(this.st.url(`/api/review/${id}/reply`), { text })
|
|
773
|
+
runInAction(() => {
|
|
774
|
+
const { [id]: _, ...rest } = this.replies
|
|
775
|
+
this.replies = rest
|
|
776
|
+
store.put(`${this.prefix}reply.${id}`, null)
|
|
777
|
+
})
|
|
778
|
+
void this.refresh()
|
|
779
|
+
} catch (e) {
|
|
780
|
+
runInAction(() => (this.error = (e as Error).message))
|
|
781
|
+
} finally {
|
|
782
|
+
runInAction(() => (this.busy = false))
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
private async markRead(id: string): Promise<void> {
|
|
787
|
+
if (this.reading.has(id)) return
|
|
788
|
+
this.reading.add(id)
|
|
789
|
+
try {
|
|
790
|
+
await postJson(this.st.url(`/api/review/${id}/read`), {})
|
|
791
|
+
void this.refresh()
|
|
792
|
+
} catch (e) {
|
|
793
|
+
runInAction(() => (this.error = (e as Error).message))
|
|
794
|
+
} finally {
|
|
795
|
+
this.reading.delete(id)
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
toggleMarks(): void {
|
|
800
|
+
this.marksOn = !this.marksOn
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** a rectangle picked: its view comes on screen, and on a clip its frame, paused */
|
|
804
|
+
pickRect(i: number | null): void {
|
|
805
|
+
this.rect = i
|
|
806
|
+
if (i === null) return
|
|
807
|
+
const r = this.draft.rects[i]
|
|
808
|
+
this.view = r?.view ?? 0
|
|
809
|
+
if (r?.t === undefined || !this.clipKey) return
|
|
810
|
+
this.clip.seek(r.t)
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
setRectComment(i: number, comment: string): void {
|
|
814
|
+
this.edit((d) => ({ ...d, rects: d.rects.map((r, j) => (j === i ? { ...r, comment } : r)) }))
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
removeRect(i: number): void {
|
|
818
|
+
this.edit((d) => ({ ...d, rects: d.rects.filter((_, j) => j !== i) }))
|
|
819
|
+
this.rect = null
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
setComment(comment: string): void {
|
|
823
|
+
this.edit((d) => ({ ...d, comment }))
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** back to what was sent */
|
|
827
|
+
dropDraft(): void {
|
|
828
|
+
const x = this.open
|
|
829
|
+
if (!x) return
|
|
830
|
+
const { [x.item.id]: _, ...rest } = this.drafts
|
|
831
|
+
this.drafts = rest
|
|
832
|
+
store.put(`${this.prefix}draft.${x.item.id}`, null)
|
|
833
|
+
this.rect = null
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** V, F, R, A: the draft goes to the card's thread as the owner's verdict, through the server */
|
|
837
|
+
async submit(status: ReviewStatus): Promise<void> {
|
|
838
|
+
const x = this.open
|
|
839
|
+
if (!x || this.busy) return
|
|
840
|
+
if (x.item.origin.by !== 'agent') {
|
|
841
|
+
this.error = 'this item was raised, not shown by an agent: a word on it goes in a reply'
|
|
842
|
+
return
|
|
843
|
+
}
|
|
844
|
+
if (x.item.wip) {
|
|
845
|
+
this.error = 'a draft card: the verdict comes once its agent makes it ready; a word goes in a reply'
|
|
846
|
+
return
|
|
847
|
+
}
|
|
848
|
+
const id = x.item.id
|
|
849
|
+
const d = this.draft
|
|
850
|
+
this.busy = true
|
|
851
|
+
this.error = null
|
|
852
|
+
try {
|
|
853
|
+
const pick = isChoice(x.item) && isApproved(status) && d.pick !== undefined ? { pick: d.pick } : {}
|
|
854
|
+
const m = await postJson<Message>(this.st.url(`/api/review/${id}/feedback`), { status, comment: d.comment, rects: d.rects, reactions: d.reactions ?? [], ...pick })
|
|
855
|
+
runInAction(() => {
|
|
856
|
+
// the verdict shows at once: the list of every card comes back after it
|
|
857
|
+
const r = this.list
|
|
858
|
+
if (r) this.list = { ...r, items: r.items.map((y) => (y.item.id === id ? { ...y, thread: [...y.thread, m] } : y)) }
|
|
859
|
+
const { [id]: _, ...rest } = this.drafts
|
|
860
|
+
this.drafts = rest
|
|
861
|
+
store.put(`${this.prefix}draft.${id}`, null)
|
|
862
|
+
})
|
|
863
|
+
void this.refresh()
|
|
864
|
+
// the tree's count of what waits on him moves at once
|
|
865
|
+
void this.st.cols.poll(true)
|
|
866
|
+
} catch (e) {
|
|
867
|
+
runInAction(() => (this.error = (e as Error).message))
|
|
868
|
+
} finally {
|
|
869
|
+
runInAction(() => (this.busy = false))
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/** the page's keys, as the atelier takes its own: true when it used the key. V approves, F ok with a fix, R rejects,
|
|
874
|
+
* A archives, N the next waiting card, M the rings, Delete the picked rectangle; on a clip Space plays, ← → a frame
|
|
875
|
+
* (a sound: two seconds); on option rows 1 to 9 play that row, ⇧1 to ⇧5 score the row playing, P picks it. A digit is
|
|
876
|
+
* read from its physical key (`code`), so ⇧ and an AZERTY keyboard read the same */
|
|
877
|
+
key(e: Pick<KeyboardEvent, 'key' | 'shiftKey'> & { code?: string }): boolean {
|
|
878
|
+
const k = e.key.toLowerCase()
|
|
879
|
+
const digit = Number(/^Digit([1-9])$/.exec(e.code ?? '')?.[1] ?? (/^[1-9]$/.test(e.key) ? e.key : 0))
|
|
880
|
+
const sound = this.openView?.audio !== undefined
|
|
881
|
+
if (this.rows && digit > 0 && !e.shiftKey) {
|
|
882
|
+
if (digit <= (this.open?.item.views.length ?? 0)) this.listen(digit - 1)
|
|
883
|
+
} else if (this.choice && this.rows && digit > 0 && e.shiftKey) {
|
|
884
|
+
if (digit <= STARS) this.setStars(this.view, digit)
|
|
885
|
+
} else if (this.choice && k === 'p') void this.choose(this.view)
|
|
886
|
+
else if (this.clipKey && e.key === ' ') {
|
|
887
|
+
if (this.clip.paused && this.rows) this.clip.listen()
|
|
888
|
+
else this.clip.toggle()
|
|
889
|
+
} else if (this.clipKey && sound && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) this.clip.jump(this.clip.time + (e.key === 'ArrowRight' ? SOUND_JUMP : -SOUND_JUMP))
|
|
890
|
+
else if (this.clipKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) this.clip.step(e.key === 'ArrowRight' ? 1 : -1)
|
|
891
|
+
else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') this.move(e.key === 'ArrowDown' ? 1 : -1)
|
|
892
|
+
else if (k === 'v') void this.submit('approved')
|
|
893
|
+
else if (k === 'f') void this.submit('approved-with-fix')
|
|
894
|
+
else if (k === 'r') void this.submit('rejected')
|
|
895
|
+
else if (k === 'a') void this.submit('archived')
|
|
896
|
+
else if (k === 'n') this.nextPending()
|
|
897
|
+
else if (k === 'm') this.toggleMarks()
|
|
898
|
+
else if ((e.key === 'Delete' || e.key === 'Backspace') && this.rect !== null) this.removeRect(this.rect)
|
|
899
|
+
else return false
|
|
900
|
+
return true
|
|
901
|
+
}
|
|
902
|
+
}
|