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,113 @@
|
|
|
1
|
+
// the review page's shared kit: a status's tone class, the verdicts and reactions as rows, the lane tag, the rework
|
|
2
|
+
// spinner, who listens, the thumbnail. Colours come from review.css, which reads hub.css tokens
|
|
3
|
+
import { observer } from 'mobx-react-lite'
|
|
4
|
+
import { useEffect, useState } from 'react'
|
|
5
|
+
import { firstView, thumbFileOf, type ReviewItem, type ReviewStatus, type ShownStatus, type Side } from '../../../review/model.ts'
|
|
6
|
+
import type { ReviewSt } from './review-st.ts'
|
|
7
|
+
|
|
8
|
+
/** a status's tone, as a class review.css colours: its tint, its line, its badge */
|
|
9
|
+
export const toneOf = (s: ShownStatus): string => `st-${s}`
|
|
10
|
+
|
|
11
|
+
/** the verdicts the owner gives, in the order of their buttons */
|
|
12
|
+
export const VERDICTS: { status: ReviewStatus; label: string; short: string; key: string; glyph: string }[] = [
|
|
13
|
+
{ status: 'approved', label: 'Approve', short: 'Approve', key: 'V', glyph: '✓' },
|
|
14
|
+
{ status: 'approved-with-fix', label: 'OK with a fix', short: 'Fix', key: 'F', glyph: '⚒' },
|
|
15
|
+
{ status: 'rejected', label: 'Reject', short: 'Reject', key: 'R', glyph: '✕' },
|
|
16
|
+
{ status: 'archived', label: 'Archive, I will answer later, another way', short: 'Later', key: 'A', glyph: '▤' },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
/** the owner's reaction to a view or a tile, in the order of the toggles */
|
|
20
|
+
export const REACTIONS: { status: ReviewStatus; label: string; tip: string; glyph: string }[] = [
|
|
21
|
+
{ status: 'approved', label: 'OK', tip: 'this image is right', glyph: '✓' },
|
|
22
|
+
{ status: 'approved-with-fix', label: 'Fix', tip: 'almost: a fix', glyph: '⚒' },
|
|
23
|
+
{ status: 'rejected', label: 'No', tip: 'this image is wrong', glyph: '✕' },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
export const PAIR_NAMES = { base: 'before', mod: 'after' }
|
|
27
|
+
export const SIDE_LABEL: Record<Side, string> = { before: 'before', after: 'after' }
|
|
28
|
+
|
|
29
|
+
export const when = (iso: string): string => new Date(iso).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' })
|
|
30
|
+
/** `02/10 22:39` */
|
|
31
|
+
export const short = (iso: string): string => new Date(iso).toLocaleString(undefined, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
|
32
|
+
export const cap = (t: string): string => t.charAt(0).toUpperCase() + t.slice(1)
|
|
33
|
+
/** `1.25 s` */
|
|
34
|
+
export const secs = (t: number): string => `${t.toFixed(2)} s`
|
|
35
|
+
|
|
36
|
+
/** the room above and below a fitted image: a tall one stops there, so it shows whole */
|
|
37
|
+
export const FIT_ROOM = 190
|
|
38
|
+
|
|
39
|
+
/** keeps the focus where it was: a click on a bar never takes the keys V, R, N or Space */
|
|
40
|
+
export const keepFocus = (e: React.MouseEvent): void => e.preventDefault()
|
|
41
|
+
|
|
42
|
+
/** Escape leaves a text field, so V, R and N work again */
|
|
43
|
+
export const blurOnEscape = (e: React.KeyboardEvent<HTMLElement>): void => {
|
|
44
|
+
if (e.key === 'Escape') e.currentTarget.blur()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** a lane's colour, hashed from its name: one lane always one colour, neighbours far apart on the wheel */
|
|
48
|
+
export function laneColor(lane: string): string {
|
|
49
|
+
let h = 2166136261
|
|
50
|
+
for (let i = 0; i < lane.length; i++) h = Math.imul(h ^ lane.charCodeAt(i), 16777619)
|
|
51
|
+
return `hsl(${Math.round(((h >>> 0) * 137.508) % 360)} 55% 58%)`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const LaneTag = ({ lane }: { lane: string }) => (
|
|
55
|
+
<span className="rv-lane" title={`lane ${lane}`} style={{ ['--lane' as string]: laneColor(lane) }}>
|
|
56
|
+
{lane}
|
|
57
|
+
</span>
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
export const AutoTag = ({ rule }: { rule: string }) => (
|
|
61
|
+
<span className="rv-auto" title={`raised by the auto rule ${rule}`}>
|
|
62
|
+
auto
|
|
63
|
+
</span>
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
/** a lane reworking the card: a slow spinner and « in work · L147 » in the lane's colour; nothing when nobody does */
|
|
67
|
+
export const Rework = observer(({ rv, x }: { rv: ReviewSt; x: ReviewItem }) => {
|
|
68
|
+
const r = rv.reworkOf(x)
|
|
69
|
+
const [, redraw] = useState(0)
|
|
70
|
+
const until = r?.until
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (until === undefined) return
|
|
73
|
+
const t = setTimeout(() => redraw((n) => n + 1), Math.max(0, until - Date.now()) + 1000)
|
|
74
|
+
return () => clearTimeout(t)
|
|
75
|
+
}, [until])
|
|
76
|
+
if (!r) return null
|
|
77
|
+
return (
|
|
78
|
+
<div className="rv-reworkrow">
|
|
79
|
+
<span className="rv-rework" style={{ ['--lane' as string]: laneColor(r.lane) }} title={`${r.lane} reworks this card since ${when(r.since)}`}>
|
|
80
|
+
<span className="rv-spin" />
|
|
81
|
+
in work · {r.lane}
|
|
82
|
+
</span>
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
/** who listens: the pid of each live `sk review watch` that reads his answer on these cards, or « nobody listens » */
|
|
88
|
+
export const Listeners = observer(({ rv, cards }: { rv: ReviewSt; cards: readonly ReviewItem[] }) => {
|
|
89
|
+
const ws = rv.listenersOf(cards)
|
|
90
|
+
const on = ws.length > 0
|
|
91
|
+
return (
|
|
92
|
+
<span
|
|
93
|
+
className={`rv-listen${on ? ' on' : ''}`}
|
|
94
|
+
title={
|
|
95
|
+
on
|
|
96
|
+
? ws.map((w) => `sk review watch, pid ${w.pid}, ${w.lane ? `lane ${w.lane}` : w.session ? `session ${w.session.slice(0, 8)}` : 'every card'}, since ${when(w.started)}`).join('\n')
|
|
97
|
+
: 'no sk review watch reads these cards: your answer will wait'
|
|
98
|
+
}
|
|
99
|
+
>
|
|
100
|
+
◉ {on ? ws.map((w) => w.pid).join(' · ') : 'nobody listens'}
|
|
101
|
+
</span>
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
/** an item's thumbnail: its first view's image, else a speech mark (a note with no image) */
|
|
106
|
+
export const ThumbOf = ({ rv, x }: { rv: ReviewSt; x: ReviewItem }) => {
|
|
107
|
+
const first = firstView(x.item)
|
|
108
|
+
if (!first) return <div className="rv-thumbnone">✎</div>
|
|
109
|
+
return <img className="rv-thumbimg" src={rv.thumbUrl(x, thumbFileOf(first))} alt="" loading="lazy" />
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** the page's empty pane: one line of dim words */
|
|
113
|
+
export const Empty = ({ text }: { text: string }) => <div className="rv-empty">{text}</div>
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// the review page's right column: the card's header (title, state pill, what), a kind's facts, the linked cards, what
|
|
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
|
+
// box. When the page reads in one column the header sits over the views instead (review-views.tsx)
|
|
4
|
+
import { observer } from 'mobx-react-lite'
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
import { laneOf, placeText, STATUS_LABEL, stateOf, verdictOf, viewName, withdrawnOf, type ReviewItem, type ReviewStatus } from '../../../review/model.ts'
|
|
7
|
+
import { renderMd } from '../md.ts'
|
|
8
|
+
import { AutoTag, blurOnEscape, Empty, LaneTag, Listeners, REACTIONS, Rework, secs, short, SIDE_LABEL, ThumbOf, toneOf, VERDICTS } from './kit.tsx'
|
|
9
|
+
import { AnimPanel, KindView } from './kind-view.tsx'
|
|
10
|
+
import type { ReviewSt } from './review-st.ts'
|
|
11
|
+
|
|
12
|
+
export const ReviewForm = observer(({ rv }: { rv: ReviewSt }) => {
|
|
13
|
+
const x = rv.open
|
|
14
|
+
if (!x) return <Empty text="The open card, its comment and its answer." />
|
|
15
|
+
const r = x.item
|
|
16
|
+
const draft = rv.draft
|
|
17
|
+
const sent = verdictOf(x)
|
|
18
|
+
// in one column the header and the verdicts sit over the views
|
|
19
|
+
const headAbove = rv.headInViews
|
|
20
|
+
const verdictsAbove = rv.stacked && !rv.phone.on
|
|
21
|
+
return (
|
|
22
|
+
<div className="rv-form">
|
|
23
|
+
{headAbove ? null : <CardHead rv={rv} x={x} verdicts={false} />}
|
|
24
|
+
<KindView app={rv} x={x} />
|
|
25
|
+
<AnimPanel app={rv} x={x} />
|
|
26
|
+
{rv.newer.map((y) => <Linked key={y.item.id} rv={rv} label="replaced by" id={y.item.id} item={y} />)}
|
|
27
|
+
{r.supersedes && rv.history.length === 0 ? <Linked rv={rv} label="replaces" id={r.supersedes} item={null} /> : null}
|
|
28
|
+
{rv.answered.map((y) => <Linked key={y.item.id} rv={rv} label="answers" id={y.item.id} item={y} />)}
|
|
29
|
+
{rv.answeredBy ? <Linked rv={rv} label="answered by" id={rv.answeredBy.item.id} item={rv.answeredBy} /> : null}
|
|
30
|
+
{rv.history.length > 0 ? <div className="rv-divider">earlier versions</div> : null}
|
|
31
|
+
{rv.history.map((y, i) => <Linked key={y.item.id} rv={rv} label={`v${rv.history.length - i}`} id={y.item.id} item={y} />)}
|
|
32
|
+
<Outgoing rv={rv} />
|
|
33
|
+
<label className="rv-label">Comment</label>
|
|
34
|
+
<textarea className="rv-comment" rows={3} placeholder="the overall word" value={draft.comment} onKeyDown={blurOnEscape} onChange={(e) => rv.setComment(e.currentTarget.value)} />
|
|
35
|
+
{verdictsAbove ? null : <div className="rv-listenrow"><span className="rv-dim">who reads your answer</span><Listeners rv={rv} cards={[x]} /></div>}
|
|
36
|
+
{!rv.phone.on && !verdictsAbove && !r.wip && r.origin.by === 'agent' ? (
|
|
37
|
+
<div className="rv-verdicts">
|
|
38
|
+
{VERDICTS.map((v) => <VerdictButton key={v.status} v={v} sent={sent?.status} busy={rv.busy} send={() => void rv.submit(v.status)} />)}
|
|
39
|
+
</div>
|
|
40
|
+
) : null}
|
|
41
|
+
<Messages rv={rv} />
|
|
42
|
+
<div className="rv-formfoot">
|
|
43
|
+
<button className="ghost" onClick={() => rv.nextPending()}>next waiting (N)</button>
|
|
44
|
+
{rv.dirty && sent ? <button className="ghost" onClick={() => rv.dropDraft()}>back to the answer sent</button> : null}
|
|
45
|
+
</div>
|
|
46
|
+
{sent && rv.dirty ? <div className="rv-dim">The changes are not sent yet: a verdict sends them.</div> : null}
|
|
47
|
+
{!sent && rv.dirty ? <div className="rv-dim">Draft kept in this browser until it is sent.</div> : null}
|
|
48
|
+
{rv.error ? <div className="rv-err rv-pre">{rv.error}</div> : null}
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const RAISED_NOW: Record<string, string> = {
|
|
54
|
+
new: 'waiting for a dispatcher',
|
|
55
|
+
question: 'a question for you',
|
|
56
|
+
blocked: 'blocked: it waits on your decision',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** where the item stands, in a few words beside its pill: when it was sent or answered, who holds a raised item */
|
|
60
|
+
function stateWords(x: ReviewItem): string {
|
|
61
|
+
const o = x.item.origin
|
|
62
|
+
if (o.by !== 'agent') {
|
|
63
|
+
const st = stateOf(x, false)
|
|
64
|
+
const by = o.by === 'player' ? `by ${o.player}` : o.by === 'auto' ? `by the rule ${o.rule}` : 'by you'
|
|
65
|
+
const now =
|
|
66
|
+
st.state === 'open' ? `sent to ${st.topic ?? 'a topic'}`
|
|
67
|
+
: st.state === 'claimed' ? `taken by ${st.worker ?? 'an agent'}`
|
|
68
|
+
: st.state === 'closed' ? (st.why === 'wontfix' ? 'closed, not done' : st.why === 'superseded' ? 'replaced by a card' : st.why === 'rated' ? 'rated' : 'done')
|
|
69
|
+
: RAISED_NOW[st.state] ?? st.state
|
|
70
|
+
return `raised ${by} ${short(x.item.created)} · ${now}`
|
|
71
|
+
}
|
|
72
|
+
const fb = verdictOf(x)
|
|
73
|
+
if (fb) return `answered ${short(fb.at)} · ${fb.seen ? 'read by the agent' : 'not read yet'}`
|
|
74
|
+
const withdrawn = withdrawnOf(x)
|
|
75
|
+
if (withdrawn !== undefined) return `withdrawn: ${withdrawn}`
|
|
76
|
+
return x.item.wip ? 'the agent works on it' : `sent ${short(x.item.created)}`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** the card's header, for every card: the title, one tight line (the state as the list's pill, the lane, when, the
|
|
80
|
+
* commit, the id to copy), then what the agent says. No subject row: the list's row names it, a kind's facts say the
|
|
81
|
+
* rest; no id row: the url carries it. With `verdicts`, the four verdicts under it (the page in one column) */
|
|
82
|
+
export const CardHead = observer(({ rv, x, verdicts }: { rv: ReviewSt; x: ReviewItem; verdicts: boolean }) => {
|
|
83
|
+
const r = x.item
|
|
84
|
+
const status = rv.statusOf(x)
|
|
85
|
+
const sent = verdictOf(x)
|
|
86
|
+
const lane = laneOf(x)
|
|
87
|
+
return (
|
|
88
|
+
<div className="rv-head">
|
|
89
|
+
<h3 className="rv-title">{r.title}</h3>
|
|
90
|
+
<div className="rv-meta">
|
|
91
|
+
<span className={`rv-badge ${toneOf(status)}`}>{STATUS_LABEL[status]}</span>
|
|
92
|
+
{lane ? <LaneTag lane={lane} /> : null}
|
|
93
|
+
{r.origin.by === 'auto' ? <AutoTag rule={r.origin.rule} /> : null}
|
|
94
|
+
<span>{stateWords(x)}</span>
|
|
95
|
+
{r.commit ? <span className="rv-mono" title={`commit ${r.commit}`}>@{r.commit.slice(0, 9)}</span> : null}
|
|
96
|
+
<CopyId id={r.id} />
|
|
97
|
+
</div>
|
|
98
|
+
<Rework rv={rv} x={x} />
|
|
99
|
+
{r.views.length > 0 && r.what ? <div className="md rv-what" dangerouslySetInnerHTML={{ __html: renderMd(r.what) }} /> : null}
|
|
100
|
+
{r.keeps ? <div className="rv-keeps"><b>keeps:</b> {r.keeps}</div> : null}
|
|
101
|
+
{r.why ? <div className="rv-pre rv-why">another subject: {r.why}</div> : null}
|
|
102
|
+
{verdicts && !r.wip && r.origin.by === 'agent' ? (
|
|
103
|
+
<div className="rv-headverdicts">
|
|
104
|
+
{VERDICTS.map((v) => <VerdictButton key={v.status} v={v} sent={sent?.status} busy={rv.busy} send={() => void rv.submit(v.status)} short />)}
|
|
105
|
+
<Listeners rv={rv} cards={[x]} />
|
|
106
|
+
</div>
|
|
107
|
+
) : null}
|
|
108
|
+
</div>
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
/** the id, copied on a click: an agent names a card by it */
|
|
113
|
+
const CopyId = ({ id }: { id: string }) => {
|
|
114
|
+
const [done, setDone] = useState(false)
|
|
115
|
+
return (
|
|
116
|
+
<button className="rv-copyid ghost" title={`${id}: click to copy`} onClick={() => {
|
|
117
|
+
void navigator.clipboard?.writeText(id).then(() => {
|
|
118
|
+
setDone(true)
|
|
119
|
+
setTimeout(() => setDone(false), 1200)
|
|
120
|
+
})
|
|
121
|
+
}}>
|
|
122
|
+
{done ? '✓ copied' : '⧉ id'}
|
|
123
|
+
</button>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** one verdict: plain until one is sent, then the chosen one filled and the others dimmed */
|
|
128
|
+
const VerdictButton = ({ v, sent, busy, send, short: brief }: { v: (typeof VERDICTS)[number]; sent: ReviewStatus | undefined; busy: boolean; send: () => void; short?: boolean }) => (
|
|
129
|
+
<button className={`rv-verdict ${toneOf(v.status)}${sent === v.status ? ' chosen' : ''}${sent && sent !== v.status ? ' off' : ''}`} disabled={busy} onClick={send} title={brief ? `${v.label} (${v.key})` : undefined}>
|
|
130
|
+
<span className="rv-vglyph">{sent === v.status ? '✓' : v.glyph}</span> {brief ? v.short : v.label} ({v.key}){sent === v.status && !brief ? ' · chosen' : ''}
|
|
131
|
+
</button>
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
/** the phone's verdicts: one row stuck to the bottom of the open card, under the thumb */
|
|
135
|
+
export const VerdictBar = observer(({ rv }: { rv: ReviewSt }) => {
|
|
136
|
+
const x = rv.open
|
|
137
|
+
if (!x || x.item.wip || x.item.origin.by !== 'agent') return null
|
|
138
|
+
const sent = verdictOf(x)?.status
|
|
139
|
+
return (
|
|
140
|
+
<div className="rv-verdictbar">
|
|
141
|
+
{VERDICTS.map((v) => (
|
|
142
|
+
<button key={v.status} className={`rv-verdict ${toneOf(v.status)}${sent === v.status ? ' chosen' : ''}${sent && sent !== v.status ? ' off' : ''}`} disabled={rv.busy}
|
|
143
|
+
onClick={() => void rv.submit(v.status)}>
|
|
144
|
+
{sent === v.status ? '✓' : v.glyph} {v.short}
|
|
145
|
+
</button>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
/** the card this one replaces, or the one replacing it: a click opens it, its answer shown here */
|
|
152
|
+
const Linked = observer(({ rv, label, id, item }: { rv: ReviewSt; label: string; id: string; item: ReviewItem | null }) => {
|
|
153
|
+
if (!item) return <div className="rv-err">{label} {id}: not found</div>
|
|
154
|
+
const status = rv.statusOf(item)
|
|
155
|
+
const fb = verdictOf(item)
|
|
156
|
+
return (
|
|
157
|
+
<div className={`rv-linked ${toneOf(status)}`}>
|
|
158
|
+
<div className="rv-linkedrow">
|
|
159
|
+
<div className="rv-thumb xs" onClick={() => rv.pick(item.item.id)}><ThumbOf rv={rv} x={item} /></div>
|
|
160
|
+
<span className="rv-dim">{label}</span>
|
|
161
|
+
<a className="rv-linkedtitle" onClick={() => rv.pick(item.item.id)}>{item.item.title}</a>
|
|
162
|
+
<span className="rv-badge">{STATUS_LABEL[status]}</span>
|
|
163
|
+
</div>
|
|
164
|
+
{fb?.comment ? <div className="rv-pre rv-small">{fb.comment}</div> : null}
|
|
165
|
+
{fb?.rects.map((q, i) => (q.comment ? (
|
|
166
|
+
<div key={i} className="rv-dim rv-small">{i + 1}{q.side ? ` ${SIDE_LABEL[q.side]}` : ''}{q.t !== undefined ? ` · ${secs(q.t)}` : ''}: {q.comment}</div>
|
|
167
|
+
) : null))}
|
|
168
|
+
</div>
|
|
169
|
+
)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
/** scrolls the middle column to a view, or to one tile of a gallery */
|
|
173
|
+
function jumpTo(view: number, tile?: number): void {
|
|
174
|
+
document.getElementById(tile === undefined ? `review-view-${view}` : `review-tile-${view}-${tile}`)?.scrollIntoView({ behavior: 'smooth', block: tile === undefined ? 'start' : 'center' })
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** « what goes to the agent »: every reaction to a view or a tile, then every rectangle, each by its view's label; a
|
|
178
|
+
* click on its badge scrolls to it, its comment edits in place, the cross removes it */
|
|
179
|
+
const Outgoing = observer(({ rv }: { rv: ReviewSt }) => {
|
|
180
|
+
const x = rv.open
|
|
181
|
+
if (!x) return null
|
|
182
|
+
const views = x.item.views
|
|
183
|
+
const draft = rv.draft
|
|
184
|
+
const reactions = rv.outgoingReactions
|
|
185
|
+
const empty = reactions.length === 0 && draft.rects.length === 0 && draft.pick === undefined
|
|
186
|
+
return (
|
|
187
|
+
<div className="rv-outgoing">
|
|
188
|
+
<div className="rv-divider">what goes to the agent</div>
|
|
189
|
+
{empty ? (
|
|
190
|
+
<div className="rv-dim rv-small">
|
|
191
|
+
{rv.choice && rv.rows
|
|
192
|
+
? 'Score the options (stars, or ⇧1 to ⇧5 on the one playing), a word on one with ✎, then pick one (P): the pick is your answer.'
|
|
193
|
+
: rv.rows
|
|
194
|
+
? 'Play each one (1 to 9), then OK, fix or no on a row, or a word with ✎.'
|
|
195
|
+
: rv.clipKey
|
|
196
|
+
? '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.'
|
|
197
|
+
: rv.phone.on
|
|
198
|
+
? 'React to a view (OK, fix, no, a word), or turn « Circle » on above the image, then drag to circle a detail.'
|
|
199
|
+
: 'React to a view (OK, fix, no, a word), or drag on the image to circle a detail.'}
|
|
200
|
+
</div>
|
|
201
|
+
) : null}
|
|
202
|
+
{draft.pick !== undefined ? (
|
|
203
|
+
<div className="rv-outrow">
|
|
204
|
+
<button className="rv-outbadge re-approved on" title="see the option you picked" onClick={() => { rv.setView(draft.pick ?? 0); jumpTo(draft.pick ?? 0) }}>
|
|
205
|
+
✓ picked {viewName(views[draft.pick], draft.pick)}
|
|
206
|
+
</button>
|
|
207
|
+
</div>
|
|
208
|
+
) : null}
|
|
209
|
+
{reactions.map((q) => {
|
|
210
|
+
const o = REACTIONS.find((r) => r.status === q.status)
|
|
211
|
+
return (
|
|
212
|
+
<div key={`${q.view}/${q.tile ?? ''}`} className="rv-outrow">
|
|
213
|
+
<button className={`rv-outbadge${q.status ? ` re-${q.status} on` : ''}`} title="see this image" onClick={() => { rv.setView(q.view); jumpTo(q.view, q.tile) }}>
|
|
214
|
+
{o?.glyph ?? (q.stars ? '' : '·')}{q.stars ? `★${q.stars}` : ''} {placeText(q, views).slice(1, -1)}
|
|
215
|
+
</button>
|
|
216
|
+
<textarea rows={1} placeholder={o ? `${o.label}, with no word` : 'a word'} value={q.comment} onKeyDown={blurOnEscape}
|
|
217
|
+
onChange={(e) => rv.setReactionComment(q.view, q.tile, e.currentTarget.value)} />
|
|
218
|
+
<button className="ghost rv-x" aria-label="remove this reaction" onClick={() => rv.removeReaction(q.view, q.tile)}>✕</button>
|
|
219
|
+
</div>
|
|
220
|
+
)
|
|
221
|
+
})}
|
|
222
|
+
{draft.rects.map((q, i) => (
|
|
223
|
+
<div key={`rect${i}`} className="rv-outrow">
|
|
224
|
+
<button className={`rv-outbadge rect${rv.rect === i ? ' on' : ''}`} title="see this rectangle" onClick={() => { rv.pickRect(i); jumpTo(q.view ?? 0) }}>
|
|
225
|
+
⬚ {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)}` : ''}
|
|
226
|
+
</button>
|
|
227
|
+
<textarea rows={1} placeholder="what is wrong here" value={q.comment} onFocus={() => rv.pickRect(i)} onKeyDown={blurOnEscape}
|
|
228
|
+
onChange={(e) => rv.setRectComment(i, e.currentTarget.value)} />
|
|
229
|
+
<button className="ghost rv-x" aria-label="delete this rectangle" onClick={() => rv.removeRect(i)}>✕</button>
|
|
230
|
+
</div>
|
|
231
|
+
))}
|
|
232
|
+
</div>
|
|
233
|
+
)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
/** the thread's conversation, every version's and every answered item's messages in time order, then his reply box
|
|
237
|
+
* (⌘Enter sends) */
|
|
238
|
+
const Messages = observer(({ rv }: { rv: ReviewSt }) => {
|
|
239
|
+
const conv = rv.conversation
|
|
240
|
+
const versions = rv.openThread?.versions ?? []
|
|
241
|
+
const vOf = (id: string): number => versions.length - versions.findIndex((x) => x.item.id === id)
|
|
242
|
+
const multi = new Set(conv.map((m) => m.on)).size > 1
|
|
243
|
+
return (
|
|
244
|
+
<div className="rv-messages">
|
|
245
|
+
<div className="rv-divider">talk</div>
|
|
246
|
+
{conv.length === 0 ? <div className="rv-dim rv-small">No message. An agent answers with sk review reply.</div> : null}
|
|
247
|
+
{conv.map((m) => {
|
|
248
|
+
const mine = m.who === 'owner'
|
|
249
|
+
return (
|
|
250
|
+
<div key={`${m.on}/${m.id}`} className={`rv-msg${mine ? ' mine' : ''}`}>
|
|
251
|
+
<div className="rv-dim rv-small">{mine ? 'you' : m.who} · {short(m.at)}{multi ? ` · v${vOf(m.on)}` : ''}</div>
|
|
252
|
+
<div className="rv-pre">{m.text}</div>
|
|
253
|
+
{m.image ? (
|
|
254
|
+
<a href={rv.threadImageUrl(m.on, m.image)} target="_blank" rel="noreferrer"><img className="rv-msgimg" src={rv.threadImageUrl(m.on, m.image)} alt="" /></a>
|
|
255
|
+
) : null}
|
|
256
|
+
</div>
|
|
257
|
+
)
|
|
258
|
+
})}
|
|
259
|
+
<textarea className="rv-reply" rows={2} placeholder="Answer the agent (⌘Enter sends)" value={rv.reply} onChange={(e) => rv.setReply(e.currentTarget.value)}
|
|
260
|
+
onKeyDown={(e) => {
|
|
261
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
262
|
+
e.preventDefault()
|
|
263
|
+
void rv.sendReply()
|
|
264
|
+
} else blurOnEscape(e)
|
|
265
|
+
}} />
|
|
266
|
+
<div className="rv-replyrow"><button className="primary" disabled={!rv.reply.trim() || rv.busy} onClick={() => void rv.sendReply()}>Reply</button></div>
|
|
267
|
+
</div>
|
|
268
|
+
)
|
|
269
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// the dispatcher's board (wiki build/shipkit/review.md, Raised items): what the owner, a player or a rule raised, by state,
|
|
2
|
+
// each with its topic and its worker, a stale claim marked; who holds the dispatcher role. A row opens its item
|
|
3
|
+
import { observer } from 'mobx-react-lite'
|
|
4
|
+
import { isMarkItem, isStale, stateOf, topicLabel, type State } from '../../../review/model.ts'
|
|
5
|
+
import type { ReviewSt } from './review-st.ts'
|
|
6
|
+
|
|
7
|
+
const STATES: { state: State; label: string }[] = [
|
|
8
|
+
{ state: 'new', label: 'new: waits on the dispatcher' },
|
|
9
|
+
{ state: 'question', label: 'a question for you' },
|
|
10
|
+
{ state: 'blocked', label: 'blocked on you' },
|
|
11
|
+
{ state: 'open', label: 'open: waits on a worker' },
|
|
12
|
+
{ state: 'claimed', label: 'claimed' },
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
export const ReviewBoard = observer(({ rv }: { rv: ReviewSt }) => {
|
|
16
|
+
const spec = rv.spec
|
|
17
|
+
const now = new Date()
|
|
18
|
+
const rows = rv.items.filter((x) => x.item.origin.by !== 'agent' && !isMarkItem(x)).map((x) => ({ x, st: stateOf(x, rv.followed.has(x.item.id)) }))
|
|
19
|
+
const role = spec?.role
|
|
20
|
+
return (
|
|
21
|
+
<div className="rv-listscroll rv-board">
|
|
22
|
+
<div className="rv-count">{role ? `dispatcher: ${role.name}, since ${role.since.slice(0, 16).replace('T', ' ')}` : 'dispatcher: nobody (sk review dispatcher --as <name>)'}</div>
|
|
23
|
+
{STATES.map(({ state, label }) => {
|
|
24
|
+
const of = rows.filter((r) => r.st.state === state)
|
|
25
|
+
if (of.length === 0) return null
|
|
26
|
+
return (
|
|
27
|
+
<div key={state}>
|
|
28
|
+
<div className="rv-divider">{label} · {of.length}</div>
|
|
29
|
+
{of.map(({ x, st }) => (
|
|
30
|
+
<div key={x.item.id} className={`rv-boardrow${rv.open?.item.id === x.item.id ? ' on' : ''}`} onClick={() => rv.pick(x.item.id)}>
|
|
31
|
+
<div className="rv-linkedtitle">{x.item.title}</div>
|
|
32
|
+
<div className="rv-dim">
|
|
33
|
+
{st.topic ? topicLabel(st.topic, spec ?? undefined) : 'no topic'}
|
|
34
|
+
{st.worker ? ` · ${st.worker}` : ''}
|
|
35
|
+
{isStale(x, now) ? ' · stale claim' : ''}
|
|
36
|
+
{x.item.origin.by === 'player' ? ` · ${x.item.origin.server}#${x.item.origin.serverId}` : x.item.origin.by === 'auto' ? ` · auto ${x.item.origin.rule}` : ''}
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
})}
|
|
43
|
+
{rows.every((r) => r.st.state === 'closed') ? <div className="rv-empty">Nothing raised is open.</div> : null}
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// the review page's left column: the search and the filters, one card per thread, its older versions nested
|
|
2
|
+
import { observer } from 'mobx-react-lite'
|
|
3
|
+
import { useEffect, useRef } from 'react'
|
|
4
|
+
import { aboutText, firstView, isGallery, isPair, laneOf, MOVE_LABEL, STATUS_LABEL, subjectLabel, verdictOf, type Move, type ReviewItem, type ShownStatus } from '../../../review/model.ts'
|
|
5
|
+
import type { Thread } from '../../../review/threads.ts'
|
|
6
|
+
import { AutoTag, LaneTag, laneColor, Listeners, Rework, ThumbOf, toneOf } from './kit.tsx'
|
|
7
|
+
import { ReviewBoard } from './review-board.tsx'
|
|
8
|
+
import type { ReviewSt } from './review-st.ts'
|
|
9
|
+
import { Caret } from '../tree-row.tsx'
|
|
10
|
+
|
|
11
|
+
const ALL = ''
|
|
12
|
+
|
|
13
|
+
export const ReviewList = observer(({ rv }: { rv: ReviewSt }) => {
|
|
14
|
+
const shown = rv.visible
|
|
15
|
+
const open = rv.open?.item.id
|
|
16
|
+
return (
|
|
17
|
+
<div className="rv-listcol">
|
|
18
|
+
<div className="rv-filters">
|
|
19
|
+
<input className="rv-search" placeholder="search" aria-label="search the cards" value={rv.query} onChange={(e) => rv.setQuery(e.currentTarget.value)}
|
|
20
|
+
onKeyDown={(e) => { if (e.key === 'Escape') { rv.setQuery(''); e.currentTarget.blur() } }} />
|
|
21
|
+
<div className="rv-selects">
|
|
22
|
+
<select aria-label="subject" value={rv.subject ?? ALL} onChange={(e) => rv.setFilter({ subject: e.currentTarget.value === ALL ? null : e.currentTarget.value })}>
|
|
23
|
+
<option value={ALL}>every subject</option>
|
|
24
|
+
{rv.subjects.map((k) => <option key={k} value={k}>{subjectLabel(k, rv.spec ?? undefined)}</option>)}
|
|
25
|
+
</select>
|
|
26
|
+
<select aria-label="state" value={rv.status ?? ALL} onChange={(e) => rv.setFilter({ status: e.currentTarget.value === ALL ? null : (e.currentTarget.value as ShownStatus) })}>
|
|
27
|
+
<option value={ALL}>every state</option>
|
|
28
|
+
{(Object.keys(STATUS_LABEL) as ShownStatus[]).map((s) => <option key={s} value={s}>{STATUS_LABEL[s]}</option>)}
|
|
29
|
+
</select>
|
|
30
|
+
</div>
|
|
31
|
+
<span className="seg rv-whose" aria-label="whose move">
|
|
32
|
+
<button className={rv.whose === null ? 'on' : ''} onClick={() => rv.setFilter({ whose: null })}>all</button>
|
|
33
|
+
{(['owner', 'agents', 'none'] as const).map((m: Move) => (
|
|
34
|
+
<button key={m} className={rv.whose === m ? 'on' : ''} onClick={() => rv.setFilter({ whose: m })}>{MOVE_LABEL[m]}</button>
|
|
35
|
+
))}
|
|
36
|
+
</span>
|
|
37
|
+
<div className="rv-switches">
|
|
38
|
+
<label className="rv-switch"><input type="checkbox" checked={rv.hideHandled} onChange={(e) => rv.setHideHandled(e.currentTarget.checked)} />hide handled</label>
|
|
39
|
+
<label className="rv-switch"><input type="checkbox" checked={rv.byLane} onChange={(e) => rv.setByLane(e.currentTarget.checked)} />by lane</label>
|
|
40
|
+
{rv.spec?.dispatcher ? <label className="rv-switch"><input type="checkbox" checked={rv.board} onChange={(e) => rv.setBoard(e.currentTarget.checked)} />board</label> : null}
|
|
41
|
+
</div>
|
|
42
|
+
<div className="rv-count">
|
|
43
|
+
{rv.activeCount} active · {rv.pendingCount} waiting{rv.hideHandled ? '' : ` · ${shown.length} / ${rv.threads.length} threads`}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
{rv.board && rv.spec?.dispatcher ? <ReviewBoard rv={rv} /> : <div className="rv-listscroll">
|
|
47
|
+
{rv.byLane
|
|
48
|
+
? rv.laneGroups.map((g) => (
|
|
49
|
+
<div key={g.lane ?? ''}>
|
|
50
|
+
<LaneHeader rv={rv} lane={g.lane} threads={g.threads} />
|
|
51
|
+
{g.threads.map((t) => <ThreadRow key={t.head.item.id} rv={rv} t={t} openId={open} />)}
|
|
52
|
+
</div>
|
|
53
|
+
))
|
|
54
|
+
: shown.map((t) => <ThreadRow key={t.head.item.id} rv={rv} t={t} openId={open} />)}
|
|
55
|
+
{shown.length === 0 && rv.list != null ? (
|
|
56
|
+
<div className="rv-empty">{rv.items.length === 0 ? 'No card yet. An agent sends one with sk review add.' : rv.hideHandled ? 'Nothing active: « hide handled » hides the handled threads.' : 'No card matches.'}</div>
|
|
57
|
+
) : null}
|
|
58
|
+
{rv.list == null && rv.listError == null ? <div className="rv-empty"><span className="spin" /> reading the cards…</div> : null}
|
|
59
|
+
{rv.listError != null ? <div className="rv-err">{rv.listError}</div> : null}
|
|
60
|
+
{rv.broken.map((b) => <div key={b.id} className="rv-err">unreadable: {b.id} ({b.error})</div>)}
|
|
61
|
+
</div>}
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const LaneHeader = observer(({ rv, lane, threads }: { rv: ReviewSt; lane: string | null; threads: readonly Thread[] }) => (
|
|
67
|
+
<div className="rv-lanehead" style={lane ? { ['--lane' as string]: laneColor(lane) } : undefined}>
|
|
68
|
+
<b>{lane ?? 'no lane'}</b>
|
|
69
|
+
<span className="rv-dim">{threads.length}</span>
|
|
70
|
+
<span className="sp" />
|
|
71
|
+
<Listeners rv={rv} cards={threads.map((t) => t.head)} />
|
|
72
|
+
</div>
|
|
73
|
+
))
|
|
74
|
+
|
|
75
|
+
const ThreadRow = observer(({ rv, t, openId }: { rv: ReviewSt; t: Thread; openId: string | undefined }) => {
|
|
76
|
+
const older = t.versions.slice(1)
|
|
77
|
+
const unfolded = older.length > 0 && rv.showsVersions(t)
|
|
78
|
+
const n = t.versions.length
|
|
79
|
+
return (
|
|
80
|
+
<>
|
|
81
|
+
<ReviewRow rv={rv} x={t.head} on={t.head.item.id === openId} unread={rv.unread(t)} version={n}
|
|
82
|
+
fold={older.length > 0 ? { open: unfolded, toggle: () => rv.fold(t.head.item.id) } : undefined} />
|
|
83
|
+
{unfolded ? older.map((x, i) => <VersionRow key={x.item.id} rv={rv} x={x} n={n - 1 - i} on={x.item.id === openId} />) : null}
|
|
84
|
+
</>
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const laneStyle = (x: ReviewItem): React.CSSProperties | undefined => {
|
|
89
|
+
const lane = laneOf(x)
|
|
90
|
+
return lane ? { ['--lane' as string]: laneColor(lane) } : undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** an older version under its thread's head: small, its own status, a click opens it */
|
|
94
|
+
const VersionRow = observer(({ rv, x, n, on }: { rv: ReviewSt; x: ReviewItem; n: number; on: boolean }) => {
|
|
95
|
+
const status = rv.statusOf(x)
|
|
96
|
+
const lane = laneOf(x)
|
|
97
|
+
return (
|
|
98
|
+
<div className={`rv-vrow ${toneOf(status)}${on ? ' on' : ''}${lane ? ' laned' : ''}`} style={laneStyle(x)} onClick={() => rv.pick(x.item.id)}>
|
|
99
|
+
<div className="rv-thumb sm"><ThumbOf rv={rv} x={x} /></div>
|
|
100
|
+
<b className="rv-vn">v{n}</b>
|
|
101
|
+
<span className="rv-badge">{STATUS_LABEL[status]}</span>
|
|
102
|
+
{lane ? <LaneTag lane={lane} /> : null}
|
|
103
|
+
{x.item.origin.by === 'auto' ? <AutoTag rule={x.item.origin.rule} /> : null}
|
|
104
|
+
<span className="rv-vtitle">{x.item.title}</span>
|
|
105
|
+
</div>
|
|
106
|
+
)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const ReviewRow = observer(({ rv, x, on, version, unread, fold }: {
|
|
110
|
+
rv: ReviewSt
|
|
111
|
+
x: ReviewItem
|
|
112
|
+
on: boolean
|
|
113
|
+
version: number
|
|
114
|
+
unread: boolean
|
|
115
|
+
fold: { open: boolean; toggle: () => void } | undefined
|
|
116
|
+
}) => {
|
|
117
|
+
const r = x.item
|
|
118
|
+
const status = rv.statusOf(x)
|
|
119
|
+
const rework = rv.reworkOf(x)
|
|
120
|
+
// handled by the agent: its answer marked seen, or a newer version sent; never while a lane reworks it
|
|
121
|
+
const muted = !on && !rework && (verdictOf(x)?.seen === true || status === 'superseded' || status === 'withdrawn')
|
|
122
|
+
const lane = laneOf(x)
|
|
123
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (on) ref.current?.scrollIntoView({ block: 'nearest' })
|
|
126
|
+
}, [on])
|
|
127
|
+
const first = firstView(r)
|
|
128
|
+
return (
|
|
129
|
+
<div ref={ref} className={`rv-row ${toneOf(status)}${on ? ' on' : ''}${muted ? ' muted' : ''}${lane ? ' laned' : ''}`} style={laneStyle(x)} onClick={() => rv.pick(r.id)}>
|
|
130
|
+
<div className="rv-thumb"><ThumbOf rv={rv} x={x} /></div>
|
|
131
|
+
<div className="rv-rowbody">
|
|
132
|
+
<div className={`rv-rowtitle${on || status === 'pending' ? ' strong' : ''}`}>
|
|
133
|
+
{unread ? <span className="rv-unread" title="a message of the agent to read" /> : null}
|
|
134
|
+
{r.title}
|
|
135
|
+
</div>
|
|
136
|
+
<div className="rv-rowmeta">
|
|
137
|
+
<span className="rv-badge">{STATUS_LABEL[status]}</span>
|
|
138
|
+
{version > 1 ? <span className="rv-badge outline">v{version}</span> : null}
|
|
139
|
+
{lane ? <LaneTag lane={lane} /> : null}
|
|
140
|
+
{r.origin.by === 'auto' ? <AutoTag rule={r.origin.rule} /> : null}
|
|
141
|
+
<span className="rv-about">
|
|
142
|
+
{aboutText(r, rv.spec ?? undefined)}
|
|
143
|
+
{r.views.some((v) => !isGallery(v) && v.clip) ? ' · clip' : ''}
|
|
144
|
+
{first && isPair(first.files) ? ' · before/after' : ''}
|
|
145
|
+
</span>
|
|
146
|
+
</div>
|
|
147
|
+
<Rework rv={rv} x={x} />
|
|
148
|
+
</div>
|
|
149
|
+
{fold ? (
|
|
150
|
+
<button className="rv-fold" aria-label={fold.open ? 'fold the versions' : 'unfold the versions'} onClick={(e) => { e.stopPropagation(); fold.toggle() }}>
|
|
151
|
+
<Caret open={fold.open} />
|
|
152
|
+
</button>
|
|
153
|
+
) : null}
|
|
154
|
+
</div>
|
|
155
|
+
)
|
|
156
|
+
})
|