rvlib-shipkit 2.7.0 → 2.9.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 +12 -0
- package/package.json +11 -3
- package/src/assets/gen/comfyts-gen.ts +56 -25
- package/src/assets/kits/identity-kit.ts +23 -4
- package/src/checks/SK005-rvlib-versions.check.ts +17 -11
- package/src/cli/cmd_Fleet.ts +8 -5
- package/src/core/update-policy.ts +6 -0
- package/src/fleet/commit-bump.ts +123 -0
- package/src/fleet/push-fleet.ts +9 -3
- package/src/fleet/update-fleet.ts +38 -9
- package/src/serve/hub-app/auto-text.tsx +52 -0
- package/src/serve/hub-app/hub.css +2 -0
- package/src/serve/hub-app/media-view.tsx +3 -11
- package/src/serve/hub-app/review/review-answer.tsx +8 -7
- package/src/serve/hub-app/review/review-page.tsx +28 -12
- package/src/serve/hub-app/review/review-st.ts +39 -8
- package/src/serve/hub-app/review/review-views.tsx +104 -35
- package/src/serve/hub-app/review/trailer-page.tsx +5 -4
- package/src/serve/hub-app/review.css +20 -2
- package/src/trailer/sound/comfy.ts +1 -0
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
// `shipkit fleet --update`: hand each behind repo its own update. The update
|
|
2
2
|
// is NOT performed here. Each repo runs its own binary, which is the only copy
|
|
3
3
|
// whose SK005 matches the rules that repo is pinned to, and the only one
|
|
4
|
-
// holding that repo's mutate lease.
|
|
4
|
+
// holding that repo's mutate lease. What it does write is the commit of the
|
|
5
|
+
// bump each repo made, and its push (commit-bump.ts).
|
|
5
6
|
import { existsSync } from 'fs'
|
|
6
7
|
import { join } from 'path'
|
|
7
8
|
import { spawnWithTimeout } from '../core/spawn-timeout.ts'
|
|
8
9
|
import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
|
|
9
|
-
import { ALLOW_MAJOR_ENV, breakingAdvice } from '../core/update-policy.ts'
|
|
10
|
+
import { ALLOW_MAJOR_ENV, breakingAdvice, FLEET_UPDATE_ENV } from '../core/update-policy.ts'
|
|
10
11
|
import { isBehind, judgedVersion, updateRefusal, type FleetRepo } from './scan-fleet.ts'
|
|
12
|
+
import { bumpBaseline, commitBump } from './commit-bump.ts'
|
|
11
13
|
|
|
12
14
|
|
|
13
15
|
export const short = (name: string): string => name.replace(/^rvlib-/, '')
|
|
@@ -23,26 +25,36 @@ export type FleetUpdateOpts = {
|
|
|
23
25
|
/** cross a breaking boundary too (`--major`) */
|
|
24
26
|
major: boolean
|
|
25
27
|
print: (line: string) => void
|
|
28
|
+
/** commit and push each repo's bump (fleet/commit-bump.ts); `--no-commit` leaves it in the tree */
|
|
29
|
+
commit?: boolean
|
|
30
|
+
/** runs the repo's own binary (tests pass a fake) */
|
|
31
|
+
spawn?: typeof spawnWithTimeout
|
|
26
32
|
/** per repo run (default 10 minutes: a fresh health pass probes the world) */
|
|
27
33
|
timeoutMs?: number
|
|
28
34
|
}
|
|
29
35
|
|
|
30
|
-
/** returns how many repos did not update. A repo whose gate refused (dirty
|
|
31
|
-
* tree, frozen, a lease held elsewhere) is a REPORT, not a failure of the
|
|
32
|
-
* command: SK005 defers on purpose */
|
|
33
36
|
/** the env of one repo's guarded run. It stays in its own process
|
|
34
37
|
* (SHIPKIT_NO_HOME): a check the home daemon answers never sees this env, so
|
|
35
38
|
* --major would be dropped on the way */
|
|
36
39
|
export function fleetEnv(base: Record<string, string | undefined>, major: boolean): Record<string, string> {
|
|
37
40
|
const env: Record<string, string> = Object.fromEntries(Object.entries(base).filter((e): e is [string, string] => e[1] != null && e[0] !== 'SHIPKIT_SELF_UPDATED' && e[0] !== ALLOW_MAJOR_ENV))
|
|
38
41
|
env['SHIPKIT_NO_HOME'] = '1'
|
|
42
|
+
env[FLEET_UPDATE_ENV] = '1'
|
|
39
43
|
if (major) env[ALLOW_MAJOR_ENV] = '1'
|
|
40
44
|
return env
|
|
41
45
|
}
|
|
42
46
|
|
|
43
|
-
|
|
47
|
+
/** `notMoved`: repos that did not update. A repo whose gate refused (dirty
|
|
48
|
+
* tree, frozen, a lease held elsewhere) is a REPORT, not a failure of the
|
|
49
|
+
* command: SK005 defers on purpose. `failed`: a commit a hook refused or a
|
|
50
|
+
* push that failed, the lines the operator must act on */
|
|
51
|
+
export type FleetUpdateResult = { notMoved: number; committed: number; failed: number }
|
|
52
|
+
|
|
53
|
+
export async function updateFleet(behind: FleetRepo[], o: FleetUpdateOpts): Promise<FleetUpdateResult> {
|
|
44
54
|
const w = o.print
|
|
45
55
|
let failed = 0
|
|
56
|
+
let committed = 0
|
|
57
|
+
let broke = 0
|
|
46
58
|
for (const r of behind) {
|
|
47
59
|
// the same policy SK005 applies inside the repo: a breaking jump is reported,
|
|
48
60
|
// never taken, and a safe jump beside it still moves
|
|
@@ -71,8 +83,9 @@ export async function updateFleet(behind: FleetRepo[], o: FleetUpdateOpts): Prom
|
|
|
71
83
|
// way around it: SK005 has no autofix, so the binary refuses the id.
|
|
72
84
|
// The re-exec guard is dropped so the repo's own startup self-update
|
|
73
85
|
// can still fire, and --major travels as the one env the policy reads.
|
|
86
|
+
const before = o.commit === true ? await bumpBaseline(r.dir) : null
|
|
74
87
|
const env = fleetEnv(process.env, o.major === true)
|
|
75
|
-
const res = await spawnWithTimeout(['bun', cli, 'check', '--fresh'], { cwd: r.dir, env, timeoutMs: o.timeoutMs ?? 600_000 })
|
|
88
|
+
const res = await (o.spawn ?? spawnWithTimeout)(['bun', cli, 'check', '--fresh'], { cwd: r.dir, env, timeoutMs: o.timeoutMs ?? 600_000 })
|
|
76
89
|
// judge by what node_modules carries now (the pin, for a linked dep),
|
|
77
90
|
// never by the exit code or a printed line: the code covers every rule
|
|
78
91
|
// in that repo, and the line format belongs to whatever version that repo runs
|
|
@@ -82,13 +95,29 @@ export async function updateFleet(behind: FleetRepo[], o: FleetUpdateOpts): Prom
|
|
|
82
95
|
})
|
|
83
96
|
if (stuck.length === 0) {
|
|
84
97
|
w(`ok: ${r.name} — ${wanted.map((d) => `${short(d.name)}${d.linkedInstall ? ' pin' : ''} ${d.at} → ${judgedVersion(r.dir, d.name)}`).join(', ')}`)
|
|
98
|
+
if (o.commit === true) {
|
|
99
|
+
const c = await commitBump({ name: r.name, dir: r.dir }, wanted.map((d) => d.name), before)
|
|
100
|
+
w(c.line)
|
|
101
|
+
if (c.status === 'pushed') committed++
|
|
102
|
+
if (c.status === 'fail') broke++
|
|
103
|
+
}
|
|
85
104
|
continue
|
|
86
105
|
}
|
|
87
106
|
failed++
|
|
107
|
+
// what did move is committed: left in the tree, the next run reads it
|
|
108
|
+
// as someone else's work
|
|
109
|
+
const moved = wanted.filter((d) => !stuck.includes(d))
|
|
110
|
+
if (o.commit === true && moved.length > 0) {
|
|
111
|
+
const c = await commitBump({ name: r.name, dir: r.dir }, moved.map((d) => d.name), before)
|
|
112
|
+
w(c.line)
|
|
113
|
+
if (c.status === 'pushed') committed++
|
|
114
|
+
if (c.status === 'fail') broke++
|
|
115
|
+
}
|
|
88
116
|
const said = res.out.split('\n').find((l) => /\brvlib-/.test(l) && (l.startsWith('WARN') || l.startsWith('FAIL')))
|
|
89
117
|
w(`WARN ${r.name} did not move ${stuck.map((d) => short(d.name)).join(', ')} — ${said ?? `check exited ${res.code}${res.timedOut ? ', timed out' : ''} with no rvlib line`}`)
|
|
90
118
|
w(` fix: cd ${r.dir} && ${manualUpdate(r)}`)
|
|
91
119
|
}
|
|
92
|
-
|
|
93
|
-
|
|
120
|
+
const commits = o.commit === true ? `, ${committed} committed${broke > 0 ? `, ${broke} FAILED to commit or push` : ''}` : ''
|
|
121
|
+
w(failed === 0 ? `ok: ${behind.length} repo(s) updated through their own gate${commits}` : `WARN ${failed}/${behind.length} repo(s) did not update — each line above says why${commits}`)
|
|
122
|
+
return { notMoved: failed, committed, failed: broke }
|
|
94
123
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// the hub's one text box that grows with its text: never an inner scrollbar, unless a sheet caps its height
|
|
2
|
+
// (max-height), past which it scrolls. Every multi line field of the hub is this one
|
|
3
|
+
import { useCallback, useLayoutEffect, useRef, type TextareaHTMLAttributes } from 'react'
|
|
4
|
+
|
|
5
|
+
type Props = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onChange' | 'rows'> & {
|
|
6
|
+
value: string
|
|
7
|
+
onText: (text: string) => void
|
|
8
|
+
/** the height it never shrinks under, in lines */
|
|
9
|
+
rows?: number
|
|
10
|
+
/** the element, for a caller that focuses it */
|
|
11
|
+
el?: (t: HTMLTextAreaElement | null) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** a textarea at the height of its text: its value, a new width, a font that loads all size it again */
|
|
15
|
+
export function AutoText({ value, onText, rows = 1, el, className, ...rest }: Props) {
|
|
16
|
+
const ref = useRef<HTMLTextAreaElement | null>(null)
|
|
17
|
+
const size = useCallback((): void => {
|
|
18
|
+
const t = ref.current
|
|
19
|
+
if (t == null) return
|
|
20
|
+
t.style.height = 'auto'
|
|
21
|
+
const edge = t.offsetHeight - t.clientHeight
|
|
22
|
+
t.style.height = `${t.scrollHeight + edge}px`
|
|
23
|
+
// a sheet's max-height stops it: only then may it scroll
|
|
24
|
+
t.style.overflowY = t.scrollHeight > t.clientHeight + 1 ? 'auto' : 'hidden'
|
|
25
|
+
}, [])
|
|
26
|
+
useLayoutEffect(size, [value, size])
|
|
27
|
+
useLayoutEffect(() => {
|
|
28
|
+
const t = ref.current
|
|
29
|
+
if (t == null || typeof ResizeObserver === 'undefined') return
|
|
30
|
+
let w = t.clientWidth
|
|
31
|
+
const ro = new ResizeObserver(() => {
|
|
32
|
+
if (t.clientWidth === w) return
|
|
33
|
+
w = t.clientWidth
|
|
34
|
+
size()
|
|
35
|
+
})
|
|
36
|
+
ro.observe(t)
|
|
37
|
+
return () => ro.disconnect()
|
|
38
|
+
}, [size])
|
|
39
|
+
return (
|
|
40
|
+
<textarea
|
|
41
|
+
{...rest}
|
|
42
|
+
ref={(t) => {
|
|
43
|
+
ref.current = t
|
|
44
|
+
el?.(t)
|
|
45
|
+
}}
|
|
46
|
+
className={className ? `autotext ${className}` : 'autotext'}
|
|
47
|
+
rows={rows}
|
|
48
|
+
value={value}
|
|
49
|
+
onChange={(e) => onText(e.currentTarget.value)}
|
|
50
|
+
/>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
@@ -225,6 +225,8 @@ main{display:flex;flex-direction:column;min-height:0;overflow:hidden}
|
|
|
225
225
|
.bar{display:flex;flex-direction:column;gap:var(--s2);padding:var(--s2) var(--s4);border-bottom:1px solid var(--line);background:var(--panel)}
|
|
226
226
|
textarea{width:100%;min-height:64px;background:var(--bg);color:var(--ink);border:1px solid var(--line);
|
|
227
227
|
border-radius:3px;padding:var(--s2) var(--s3);font:12px/1.5 ui-monospace,Menlo,monospace;resize:vertical}
|
|
228
|
+
/* auto-text.tsx: the box is as tall as its text, so it never drags nor scrolls (a sheet's max-height may cap it) */
|
|
229
|
+
textarea.autotext{box-sizing:border-box;min-height:0;resize:none;overflow-y:hidden}
|
|
228
230
|
.promptrow{display:flex;gap:var(--s1);align-items:flex-start}
|
|
229
231
|
.promptrow .fold{flex:none;background:var(--panel2);color:var(--dim);border:1px solid var(--line);border-radius:3px;
|
|
230
232
|
padding:var(--s1) var(--s2);font-size:11px;line-height:1.5}
|
|
@@ -6,6 +6,7 @@ import { runInAction } from 'mobx'
|
|
|
6
6
|
import { observer } from 'mobx-react-lite'
|
|
7
7
|
import { useEffect, useRef, useState, type DragEvent, type ReactNode } from 'react'
|
|
8
8
|
import type { RepoSt } from './repo-st.ts'
|
|
9
|
+
import { AutoText } from './auto-text.tsx'
|
|
9
10
|
import { isAud, type MediaSvc } from './media-svc.ts'
|
|
10
11
|
import { GeometryLayer, Rectbar, measureBox, type Box } from './geometry-view.tsx'
|
|
11
12
|
import type { CatRow, CustomPanelRef } from './hub-types.ts'
|
|
@@ -17,17 +18,8 @@ const MIX_GROUND = 44
|
|
|
17
18
|
export const MediaTopbar = observer(({ app }: { app: RepoSt }) => {
|
|
18
19
|
const m = app.media
|
|
19
20
|
const fileRef = useRef<HTMLInputElement>(null)
|
|
20
|
-
const taRef = useRef<HTMLTextAreaElement>(null)
|
|
21
21
|
const manual = m.cur?.manual === true
|
|
22
22
|
const det = m.deterministic
|
|
23
|
-
// auto-grow on EVERY prompt change, programmatic (selection) included —
|
|
24
|
-
// the css max-height caps it, overflow scrolls only past ~14 lines
|
|
25
|
-
useEffect(() => {
|
|
26
|
-
const t = taRef.current
|
|
27
|
-
if (t == null) return
|
|
28
|
-
t.style.height = 'auto'
|
|
29
|
-
t.style.height = `${t.scrollHeight + 2}px`
|
|
30
|
-
}, [m.prompt, m.promptOpen])
|
|
31
23
|
return (
|
|
32
24
|
<div className="bar">
|
|
33
25
|
{/* the prompt AUTOSAVES (debounced), no save button, no fear.
|
|
@@ -36,8 +28,8 @@ export const MediaTopbar = observer(({ app }: { app: RepoSt }) => {
|
|
|
36
28
|
<button className="fold" title={m.promptOpen ? 'fold the prompt' : 'expand the prompt'}
|
|
37
29
|
onClick={() => m.togglePrompt()}>{m.promptOpen ? '▾' : '▸'}</button>
|
|
38
30
|
{m.promptOpen
|
|
39
|
-
|
|
40
|
-
|
|
31
|
+
// it grows on every prompt change, a selection's included; the css max-height caps it near 14 lines
|
|
32
|
+
? <AutoText className="prompt" value={m.prompt} placeholder="select an asset ←" onText={(t) => m.setPrompt(t)} />
|
|
41
33
|
: <div className="prompt folded" onClick={() => m.togglePrompt()}>
|
|
42
34
|
{/* placeholder only when NOTHING is selected, a selected
|
|
43
35
|
asset with an empty prompt shows an empty line */}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// goes to the agent, the verdicts (the phone's in a bar under the thumb), then the thread's conversation and his reply
|
|
3
3
|
// box. When the page reads in one column the header sits over the views instead (review-views.tsx)
|
|
4
4
|
import { observer } from 'mobx-react-lite'
|
|
5
|
+
import { AutoText } from '../auto-text.tsx'
|
|
5
6
|
import { Icon, ICON } from '../icons.tsx'
|
|
6
7
|
import { laneOf, placeText, STATUS_LABEL, stateOf, verdictOf, viewName, withdrawnOf, type ReviewItem, type ReviewStatus } from '../../../review/model.ts'
|
|
7
8
|
import { renderMd } from '../md.ts'
|
|
@@ -98,7 +99,7 @@ export const CardHead = observer(({ rv, x }: { rv: ReviewSt; x: ReviewItem }) =>
|
|
|
98
99
|
<Listeners rv={rv} cards={[x]} />
|
|
99
100
|
</div>
|
|
100
101
|
) : null}
|
|
101
|
-
<
|
|
102
|
+
<AutoText className="rv-comment" rows={2} placeholder="your overall word (a verdict sends it)" value={draft.comment} onKeyDown={blurOnEscape} onText={(t) => rv.setComment(t)} />
|
|
102
103
|
</div>
|
|
103
104
|
)
|
|
104
105
|
})
|
|
@@ -175,7 +176,7 @@ const Outgoing = observer(({ rv }: { rv: ReviewSt }) => {
|
|
|
175
176
|
? 'React to a view (OK, fix, no, a word), or drag on the clip to circle a detail: it pauses, the rectangle keeps its moment.'
|
|
176
177
|
: rv.phone.on
|
|
177
178
|
? 'React to a view (OK, fix, no, a word), or turn « Circle » on above the image, then drag to circle a detail.'
|
|
178
|
-
: 'React to a view (OK, fix, no, a word), or drag on the image to circle a detail.'}
|
|
179
|
+
: 'React to a view (OK, fix, no, a word), or drag on the image to circle a detail, then write beside it.'}
|
|
179
180
|
</div>
|
|
180
181
|
) : null}
|
|
181
182
|
{draft.pick !== undefined ? (
|
|
@@ -192,8 +193,8 @@ const Outgoing = observer(({ rv }: { rv: ReviewSt }) => {
|
|
|
192
193
|
<button className={`rv-outbadge${q.status ? ` re-${q.status} on` : ''}`} title="see this image" onClick={() => { rv.setView(q.view); jumpTo(q.view, q.tile) }}>
|
|
193
194
|
{o?.glyph ?? (q.stars ? '' : '·')}{q.stars ? `★${q.stars}` : ''} {placeText(q, views).slice(1, -1)}
|
|
194
195
|
</button>
|
|
195
|
-
<
|
|
196
|
-
|
|
196
|
+
<AutoText placeholder={o ? `${o.label}, with no word` : 'a word'} value={q.comment} onKeyDown={blurOnEscape}
|
|
197
|
+
onText={(t) => rv.setReactionComment(q.view, q.tile, t)} />
|
|
197
198
|
<button className="ghost rv-x" aria-label="remove this reaction" onClick={() => rv.removeReaction(q.view, q.tile)}>✕</button>
|
|
198
199
|
</div>
|
|
199
200
|
)
|
|
@@ -203,8 +204,8 @@ const Outgoing = observer(({ rv }: { rv: ReviewSt }) => {
|
|
|
203
204
|
<button className={`rv-outbadge rect${rv.rect === i ? ' on' : ''}`} title="see this rectangle" onClick={() => { rv.pickRect(i); jumpTo(q.view ?? 0) }}>
|
|
204
205
|
⬚ {i + 1}{views.length > 1 ? ` · ${viewName(views[q.view ?? 0], q.view ?? 0)}` : ''}{q.side ? ` ${SIDE_LABEL[q.side]}` : ''}{q.t !== undefined ? ` · ${secs(q.t)}` : ''}
|
|
205
206
|
</button>
|
|
206
|
-
<
|
|
207
|
-
|
|
207
|
+
<AutoText placeholder="what is wrong here" value={q.comment} onFocus={() => rv.pickRect(i)} onKeyDown={blurOnEscape}
|
|
208
|
+
onText={(t) => rv.setRectComment(i, t)} />
|
|
208
209
|
<button className="ghost rv-x" aria-label="delete this rectangle" onClick={() => rv.removeRect(i)}>✕</button>
|
|
209
210
|
</div>
|
|
210
211
|
))}
|
|
@@ -235,7 +236,7 @@ const Messages = observer(({ rv }: { rv: ReviewSt }) => {
|
|
|
235
236
|
</div>
|
|
236
237
|
)
|
|
237
238
|
})}
|
|
238
|
-
<
|
|
239
|
+
<AutoText className="rv-reply" rows={2} placeholder="Answer the agent (⌘Enter sends)" value={rv.reply} onText={(t) => rv.setReply(t)}
|
|
239
240
|
onKeyDown={(e) => {
|
|
240
241
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
241
242
|
e.preventDefault()
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// a repo's review page (`#page:review`, wiki build/shipkit/review.md, The review page): the cards on the left, the open
|
|
2
2
|
// one in the middle with every view stacked, the answer on the right; `top` (or `auto` narrower than STACK_PX) reads in
|
|
3
|
-
// one column from the top: the header and the verdicts, the views, the answer. On a phone
|
|
4
|
-
//
|
|
3
|
+
// one column from the top: the header and the verdicts, the views, the answer. On a phone the cards or the card (its
|
|
4
|
+
// views, then its answer), the verdicts in a bar under the thumb. Another card opens at its top. The list is read while
|
|
5
|
+
// the page shows
|
|
5
6
|
import { observer } from 'mobx-react-lite'
|
|
6
|
-
import { useEffect, useRef } from 'react'
|
|
7
|
+
import { useEffect, useRef, type ReactNode } from 'react'
|
|
7
8
|
import { Group, Panel, Separator } from 'react-resizable-panels'
|
|
8
9
|
import type { RepoSt } from '../repo-st.ts'
|
|
9
10
|
import { ReviewForm, VerdictBar } from './review-answer.tsx'
|
|
@@ -11,7 +12,7 @@ import { ReviewList } from './review-list.tsx'
|
|
|
11
12
|
import type { PhonePane, ReviewSt } from './review-st.ts'
|
|
12
13
|
import { ReviewImage } from './review-views.tsx'
|
|
13
14
|
|
|
14
|
-
const PANE_LABEL: Record<PhonePane, string> = { list: 'cards', card: 'card'
|
|
15
|
+
const PANE_LABEL: Record<PhonePane, string> = { list: 'cards', card: 'card' }
|
|
15
16
|
|
|
16
17
|
export const ReviewPage = observer(({ app }: { app: RepoSt }) => {
|
|
17
18
|
const rv = app.review
|
|
@@ -41,15 +42,20 @@ export const ReviewPage = observer(({ app }: { app: RepoSt }) => {
|
|
|
41
42
|
<div className="rv-phone">
|
|
42
43
|
<PhoneTop app={app} />
|
|
43
44
|
<div className="rv-phonetabs seg">
|
|
44
|
-
{(['list', 'card'
|
|
45
|
+
{(['list', 'card'] as const).map((p) => (
|
|
45
46
|
<button key={p} className={rv.phone.pane === p ? 'on' : ''} onClick={() => rv.phone.show(p)}>
|
|
46
47
|
{PANE_LABEL[p]}{p === 'list' && rv.pendingCount > 0 ? ` · ${rv.pendingCount}` : ''}
|
|
47
48
|
</button>
|
|
48
49
|
))}
|
|
49
50
|
</div>
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
{rv.phone.pane === 'list' ? (
|
|
52
|
+
<div className="rv-phonepane"><ReviewList rv={rv} /></div>
|
|
53
|
+
) : (
|
|
54
|
+
<CardScroll rv={rv} className="rv-phonepane">
|
|
55
|
+
<ReviewImage rv={rv} />
|
|
56
|
+
<ReviewForm rv={rv} />
|
|
57
|
+
</CardScroll>
|
|
58
|
+
)}
|
|
53
59
|
{rv.phone.pane === 'list' ? null : <VerdictBar rv={rv} />}
|
|
54
60
|
<Toast rv={rv} />
|
|
55
61
|
</div>
|
|
@@ -65,19 +71,19 @@ export const ReviewPage = observer(({ app }: { app: RepoSt }) => {
|
|
|
65
71
|
{rv.stacked ? (
|
|
66
72
|
// one column read from the top: the header and the verdicts, the views, then the answer
|
|
67
73
|
<Panel id="review-stack" defaultSize="68%" minSize={300} className="rv-pane" style={PANEL}>
|
|
68
|
-
<
|
|
74
|
+
<CardScroll rv={rv} className="rv-scroll">
|
|
69
75
|
<ReviewImage rv={rv} />
|
|
70
76
|
<ReviewForm rv={rv} />
|
|
71
|
-
</
|
|
77
|
+
</CardScroll>
|
|
72
78
|
</Panel>
|
|
73
79
|
) : (
|
|
74
80
|
<>
|
|
75
81
|
<Panel id="review-image" defaultSize="54%" minSize={300} className="rv-pane" style={PANEL}>
|
|
76
|
-
<
|
|
82
|
+
<CardScroll rv={rv} className="rv-scroll"><ReviewImage rv={rv} /></CardScroll>
|
|
77
83
|
</Panel>
|
|
78
84
|
<Separator className="rsep" />
|
|
79
85
|
<Panel id="review-form" defaultSize="26%" minSize={260} className="rv-pane" style={PANEL}>
|
|
80
|
-
<
|
|
86
|
+
<CardScroll rv={rv} className="rv-scroll"><ReviewForm rv={rv} /></CardScroll>
|
|
81
87
|
</Panel>
|
|
82
88
|
</>
|
|
83
89
|
)}
|
|
@@ -96,6 +102,16 @@ export const PhoneTop = observer(({ app }: { app: RepoSt }) => (
|
|
|
96
102
|
</div>
|
|
97
103
|
))
|
|
98
104
|
|
|
105
|
+
/** a column of the open card: another card opens at its top, never where the last one was scrolled */
|
|
106
|
+
const CardScroll = observer(({ rv, className, children }: { rv: ReviewSt; className: string; children: ReactNode }) => {
|
|
107
|
+
const el = useRef<HTMLDivElement | null>(null)
|
|
108
|
+
const id = rv.open?.item.id
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (el.current) el.current.scrollTop = 0
|
|
111
|
+
}, [id])
|
|
112
|
+
return <div ref={el} className={className}>{children}</div>
|
|
113
|
+
})
|
|
114
|
+
|
|
99
115
|
/** what the last copy put on the clipboard, a moment over the page */
|
|
100
116
|
const Toast = observer(({ rv }: { rv: ReviewSt }) => (rv.toast ? <div className="rv-toast" role="status">{rv.toast}</div> : null))
|
|
101
117
|
|
|
@@ -77,7 +77,7 @@ export const STACK_PX = 900
|
|
|
77
77
|
/** where the card sits against its answer: on top of it, left of it, or by the page's width */
|
|
78
78
|
export const CARD_PLACES = ['top', 'left', 'auto'] as const
|
|
79
79
|
export type CardPlace = (typeof CARD_PLACES)[number]
|
|
80
|
-
export type PhonePane = 'list' | 'card'
|
|
80
|
+
export type PhonePane = 'list' | 'card'
|
|
81
81
|
export type PairSide = 'before' | 'after' | 'swipe'
|
|
82
82
|
|
|
83
83
|
const POLL_MS = 2000
|
|
@@ -87,6 +87,8 @@ const SOUND_FPS = 10
|
|
|
87
87
|
const SOUND_JUMP = 2
|
|
88
88
|
|
|
89
89
|
const sameSpot = (r: ReviewReaction, view: number, tile: number | undefined): boolean => r.view === view && r.tile === tile
|
|
90
|
+
/** a draft card's frame on its file urls: a new frame is a new url, past the browser's cache of the old one */
|
|
91
|
+
const frameTag = (x: ReviewItem | null | undefined): string => (x?.item.wip ? `?f=${x.item.wip.frame}` : '')
|
|
90
92
|
|
|
91
93
|
/** what a reload must not lose, under keys of this repo: a blocked storage only loses persistence */
|
|
92
94
|
const store = {
|
|
@@ -232,8 +234,10 @@ export class ReviewSt {
|
|
|
232
234
|
whose: Move | null = null
|
|
233
235
|
status: ShownStatus | null = null
|
|
234
236
|
drafts: Record<string, Draft> = {}
|
|
235
|
-
/** the rectangle of the open card picked
|
|
237
|
+
/** the rectangle of the open card picked, its index: its note box opens beside it */
|
|
236
238
|
rect: number | null = null
|
|
239
|
+
/** the rectangle whose note box takes the focus once it shows: one just drawn, or picked on the image */
|
|
240
|
+
noteFocus: number | null = null
|
|
237
241
|
/** the subject rings over the image (M) */
|
|
238
242
|
marksOn = true
|
|
239
243
|
/** the open card's active view, its index: the one last drawn on or picked, whose clip the keys drive */
|
|
@@ -242,9 +246,11 @@ export class ReviewSt {
|
|
|
242
246
|
viewZoom: Record<number, Zoom> = {}
|
|
243
247
|
/** the gallery tile shown large across the column, `<view>/<tile>` */
|
|
244
248
|
bigTile: string | null = null
|
|
249
|
+
/** a draft card's past frame shown in place of its live one, an index of its history; null is now */
|
|
250
|
+
frame: number | null = null
|
|
245
251
|
/** a card of options shown full, every view stacked, by id: his switch, this visit */
|
|
246
252
|
full: Record<string, boolean> = {}
|
|
247
|
-
/**
|
|
253
|
+
/** an option row he folded or unfolded, by view index; a row he never touched follows `rowOpen` */
|
|
248
254
|
openRows: Record<number, boolean> = {}
|
|
249
255
|
replies: Record<string, string> = {}
|
|
250
256
|
private reading = new Set<string>()
|
|
@@ -300,8 +306,16 @@ export class ReviewSt {
|
|
|
300
306
|
get headInViews(): boolean {
|
|
301
307
|
return this.stacked || this.phone.on || this.choice
|
|
302
308
|
}
|
|
309
|
+
/** an option row shows its full view under it: from the start for an image, a pair or a clip, never for a sound
|
|
310
|
+
* (its strip is its view), else as he folded it */
|
|
311
|
+
rowOpen(i: number): boolean {
|
|
312
|
+
const own = this.openRows[i]
|
|
313
|
+
if (own !== undefined) return own
|
|
314
|
+
const v = this.open?.item.views[i]
|
|
315
|
+
return v !== undefined && (isGallery(v) || v.audio === undefined)
|
|
316
|
+
}
|
|
303
317
|
toggleRow(i: number): void {
|
|
304
|
-
this.openRows = { ...this.openRows, [i]: !this.
|
|
318
|
+
this.openRows = { ...this.openRows, [i]: !this.rowOpen(i) }
|
|
305
319
|
}
|
|
306
320
|
|
|
307
321
|
constructor(private readonly st: RepoSt) {
|
|
@@ -407,12 +421,14 @@ export class ReviewSt {
|
|
|
407
421
|
this.board = on
|
|
408
422
|
}
|
|
409
423
|
|
|
424
|
+
/** a card's file; a draft card's carries its frame, so each new frame is a new url the browser loads again */
|
|
410
425
|
fileUrl(id: string, name: string): string {
|
|
411
|
-
|
|
426
|
+
const x = this.open?.item.id === id ? this.open : this.list?.items.find((y) => y.item.id === id)
|
|
427
|
+
return `${this.st.url(`/api/review/${id}/${name}`)}${frameTag(x)}`
|
|
412
428
|
}
|
|
413
429
|
|
|
414
430
|
thumbUrl(x: ReviewItem, name: string): string {
|
|
415
|
-
return /\.(png|jpe?g|webp)$/.test(name) ? this.st.url(`/api/review/${x.item.id}/thumb/${name}`) : this.fileUrl(x.item.id, name)
|
|
431
|
+
return /\.(png|jpe?g|webp)$/.test(name) ? `${this.st.url(`/api/review/${x.item.id}/thumb/${name}`)}${frameTag(x)}` : this.fileUrl(x.item.id, name)
|
|
416
432
|
}
|
|
417
433
|
|
|
418
434
|
threadImageUrl(id: string, name: string): string {
|
|
@@ -621,6 +637,7 @@ export class ReviewSt {
|
|
|
621
637
|
this.view = 0
|
|
622
638
|
this.viewZoom = {}
|
|
623
639
|
this.bigTile = null
|
|
640
|
+
this.frame = null
|
|
624
641
|
this.openRows = {}
|
|
625
642
|
// a link to a card on the phone opens the card, never the list
|
|
626
643
|
if (id !== null && this.phone.on) this.phone.show('card')
|
|
@@ -635,6 +652,7 @@ export class ReviewSt {
|
|
|
635
652
|
}
|
|
636
653
|
}
|
|
637
654
|
this.rect = null
|
|
655
|
+
this.noteFocus = null
|
|
638
656
|
this.error = null
|
|
639
657
|
}
|
|
640
658
|
|
|
@@ -662,6 +680,7 @@ export class ReviewSt {
|
|
|
662
680
|
const t = this.clipKey ? { t: Math.round(this.clip.time * 1000) / 1000 } : {}
|
|
663
681
|
this.edit((d) => ({ ...d, rects: [...d.rects, { ...r, comment: '', ...view, ...(side ? { side } : {}), ...t }] }))
|
|
664
682
|
this.rect = this.draft.rects.length - 1
|
|
683
|
+
this.noteFocus = this.rect
|
|
665
684
|
}
|
|
666
685
|
|
|
667
686
|
setView(i: number): void {
|
|
@@ -694,6 +713,10 @@ export class ReviewSt {
|
|
|
694
713
|
this.viewZoom = { ...this.viewZoom, [i]: z }
|
|
695
714
|
}
|
|
696
715
|
|
|
716
|
+
setFrame(f: number | null): void {
|
|
717
|
+
this.frame = f
|
|
718
|
+
}
|
|
719
|
+
|
|
697
720
|
toggleTile(view: number, tile: number): void {
|
|
698
721
|
const k = `${view}/${tile}`
|
|
699
722
|
this.bigTile = this.bigTile === k ? null : k
|
|
@@ -847,9 +870,11 @@ export class ReviewSt {
|
|
|
847
870
|
this.marksOn = !this.marksOn
|
|
848
871
|
}
|
|
849
872
|
|
|
850
|
-
/** a rectangle picked: its view comes on screen, and on a clip its frame, paused
|
|
851
|
-
|
|
873
|
+
/** a rectangle picked: its view comes on screen, and on a clip its frame, paused; `focus` (a click on it on the
|
|
874
|
+
* image) puts the cursor in its note box */
|
|
875
|
+
pickRect(i: number | null, focus = false): void {
|
|
852
876
|
this.rect = i
|
|
877
|
+
this.noteFocus = focus ? i : null
|
|
853
878
|
if (i === null) return
|
|
854
879
|
const r = this.draft.rects[i]
|
|
855
880
|
this.view = r?.view ?? 0
|
|
@@ -857,6 +882,11 @@ export class ReviewSt {
|
|
|
857
882
|
this.clip.seek(r.t)
|
|
858
883
|
}
|
|
859
884
|
|
|
885
|
+
/** the note box took the focus it was asked */
|
|
886
|
+
noteFocused(): void {
|
|
887
|
+
this.noteFocus = null
|
|
888
|
+
}
|
|
889
|
+
|
|
860
890
|
setRectComment(i: number, comment: string): void {
|
|
861
891
|
this.edit((d) => ({ ...d, rects: d.rects.map((r, j) => (j === i ? { ...r, comment } : r)) }))
|
|
862
892
|
}
|
|
@@ -864,6 +894,7 @@ export class ReviewSt {
|
|
|
864
894
|
removeRect(i: number): void {
|
|
865
895
|
this.edit((d) => ({ ...d, rects: d.rects.filter((_, j) => j !== i) }))
|
|
866
896
|
this.rect = null
|
|
897
|
+
this.noteFocus = null
|
|
867
898
|
}
|
|
868
899
|
|
|
869
900
|
setComment(comment: string): void {
|