rvlib-shipkit 2.3.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 +11 -2
- package/package.json +1 -1
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +4 -0
- package/src/cli/cmd_Review.ts +36 -10
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Trailer.ts +7 -2
- package/src/cli/kit/verb.ts +11 -3
- package/src/cli.ts +2 -0
- package/src/core/app-icon.ts +10 -6
- package/src/repo-config.ts +3 -0
- package/src/review/format.ts +11 -3
- package/src/review/model.ts +47 -5
- package/src/review/project.ts +20 -4
- package/src/review/routes.ts +12 -1
- package/src/review/store.ts +15 -6
- package/src/review/unit.ts +19 -1
- 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/hash-route.ts +12 -4
- package/src/serve/hub-app/home-st.ts +7 -1
- package/src/serve/hub-app/hub.css +38 -1
- package/src/serve/hub-app/icons.tsx +6 -0
- package/src/serve/hub-app/layout-st.ts +2 -2
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/project-list.css +9 -7
- package/src/serve/hub-app/project-list.tsx +5 -2
- package/src/serve/hub-app/projects-svc.ts +3 -2
- package/src/serve/hub-app/repo-st.ts +22 -7
- package/src/serve/hub-app/review/clip-st.ts +57 -7
- package/src/serve/hub-app/review/kind-view.tsx +78 -9
- package/src/serve/hub-app/review/review-answer.tsx +83 -53
- package/src/serve/hub-app/review/review-page.tsx +9 -14
- package/src/serve/hub-app/review/review-st.ts +141 -18
- package/src/serve/hub-app/review/review-views.tsx +154 -9
- package/src/serve/hub-app/review.css +70 -10
- 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 +4 -4
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/serve-hub.ts +7 -0
- 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 +33 -4
- package/src/trailer/build.ts +170 -29
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +4 -4
- package/src/trailer/grammar.ts +429 -67
- package/src/trailer/graph.ts +130 -56
- package/src/trailer/look.ts +3 -0
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/plan.ts +2 -2
- package/src/trailer/prune.ts +13 -2
- package/src/trailer/score/compose.ts +18 -2
- 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/pattern.ts +13 -6
- package/src/trailer/score/score.ts +28 -14
- package/src/trailer/score/sound.ts +12 -1
- package/src/trailer/score/synth.ts +24 -2
- 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/titles.ts +451 -235
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +12 -1
- package/src/trailer/words.ts +29 -8
- package/src/wiki/graph-mount.ts +3 -0
- package/src/wiki/graph-page.ts +45 -9
- package/src/wiki/graph-pages.ts +128 -0
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the shown repo's row marked in its identity colour. Its width is the
|
|
4
4
|
// panel the page gives it (hub-app.tsx, react-resizable-panels). A row says
|
|
5
5
|
// three things and no more (faceOf): the open asks as its one badge, a dim
|
|
6
|
-
// icon while VS Code does not have the folder open, a thin edge breathing
|
|
6
|
+
// name (never the icon) while VS Code does not have the folder open, a thin edge breathing
|
|
7
7
|
// while Claude sessions run in it; on the home, a dot while its hub starts
|
|
8
8
|
// or crashed and a mark while a global process it uses is off. Everything
|
|
9
9
|
// else is the hover card, placed by floating-ui beside the row. A click
|
|
@@ -72,6 +72,7 @@ export const Row = observer(({ app, p, i }: { app: ListHost; p: Project; i: numb
|
|
|
72
72
|
const warn = s.warnOf(p.name)
|
|
73
73
|
// the shown repo's row is marked in its own colour
|
|
74
74
|
const mark = cur ? (idVars(s.identityOf(p.name)) as CSSProperties) : undefined
|
|
75
|
+
// folded, the rail wears the repo's app icon: its mark on its identity plate
|
|
75
76
|
return (
|
|
76
77
|
<>
|
|
77
78
|
<button ref={f.refs.setReference} className={cls} aria-label={p.name} style={mark} draggable
|
|
@@ -95,7 +96,9 @@ export const Row = observer(({ app, p, i }: { app: ListHost; p: Project; i: numb
|
|
|
95
96
|
},
|
|
96
97
|
onDragEnd: () => s.dragEnd(),
|
|
97
98
|
})}>
|
|
98
|
-
|
|
99
|
+
{app.layout?.listRail === true && p.appIcon != null
|
|
100
|
+
? <img className="pjapp" src={p.appIcon} alt="" width={ROW_ICON} height={ROW_ICON} draggable={false} />
|
|
101
|
+
: <Face p={p} px={ROW_ICON} />}
|
|
99
102
|
<span className="pjname">{p.name}</span>
|
|
100
103
|
{face.badge != null ? <b className="pga">{face.badge}</b> : null}
|
|
101
104
|
{face.busy ? <i className="pgs" /> : null}
|
|
@@ -134,8 +134,9 @@ export function sortNames(list: Project[], mode: SortMode): string[] {
|
|
|
134
134
|
export type HubDot = 'starting' | 'crashed' | null
|
|
135
135
|
|
|
136
136
|
/** what the icon face shows, and nothing more: at most ONE badge (the open
|
|
137
|
-
* asks, the only fact that needs a human's hands), dim while VS Code
|
|
138
|
-
* not have the folder open, and a pulsing edge
|
|
137
|
+
* asks, the only fact that needs a human's hands), a dim name while VS Code
|
|
138
|
+
* does not have the folder open (the icon stays whole), and a pulsing edge
|
|
139
|
+
* while Claude sessions run */
|
|
139
140
|
export type Face = { badge: number | null; dim: boolean; busy: boolean }
|
|
140
141
|
export const faceOf = (p: Project): Face => ({
|
|
141
142
|
badge: p.asks > 0 ? p.asks : null,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// (hash-route.ts), and setSel is the one place both move together. A repo
|
|
6
6
|
// that is not shown keeps all of it and polls nothing (resume, pause), and
|
|
7
7
|
// only the shown one writes the url.
|
|
8
|
-
import { makeAutoObservable } from 'mobx'
|
|
8
|
+
import { makeAutoObservable, reaction } from 'mobx'
|
|
9
9
|
import { ClockSvc } from './clock-svc.ts'
|
|
10
10
|
import { ColsSvc } from './cols-svc.ts'
|
|
11
11
|
import { canonicalSel, currentHash, hashFor, parseHash, selOf, type CheckOwner, type HashSel } from './hash-route.ts'
|
|
@@ -23,6 +23,7 @@ import { TestsSvc } from './tests-svc.ts'
|
|
|
23
23
|
import { AdminSvc } from './admin-svc.ts'
|
|
24
24
|
import { ReviewSt } from './review/review-st.ts'
|
|
25
25
|
import { TrailerSt } from './review/trailer-st.ts'
|
|
26
|
+
import { WikiSt } from './wiki-st.ts'
|
|
26
27
|
import { repoSections } from './repo-sections.ts'
|
|
27
28
|
import { isTreeWalk, sectionKeyLabel } from './keys.ts'
|
|
28
29
|
import type { Resource } from './hub-types.ts'
|
|
@@ -72,6 +73,8 @@ export class RepoSt {
|
|
|
72
73
|
review: ReviewSt
|
|
73
74
|
/** the trailer page: a cut played with its timeline and his frame notes (review/trailer-st.ts) */
|
|
74
75
|
trailer: TrailerSt
|
|
76
|
+
/** the wikis page's search and preview */
|
|
77
|
+
wiki: WikiSt
|
|
75
78
|
sel: Sel = null
|
|
76
79
|
filter: Filter = 'all'
|
|
77
80
|
/** sidebar search — live substring filter over asset + collection rows */
|
|
@@ -111,7 +114,16 @@ export class RepoSt {
|
|
|
111
114
|
this.admin = new AdminSvc(this)
|
|
112
115
|
this.review = new ReviewSt(this)
|
|
113
116
|
this.trailer = new TrailerSt(this)
|
|
114
|
-
|
|
117
|
+
this.wiki = new WikiSt(this)
|
|
118
|
+
makeAutoObservable<this, 'beat' | 'onceRead'>(this, { onceRead: false, home: false, key: false, base: false, clock: false, media: false, cols: false, tree: false, health: false, readme: false, checks: false, comfy: false, timeline: false, live: false, git: false, tests: false, admin: false, review: false, trailer: false, wiki: false, searchEl: false, beat: false })
|
|
119
|
+
// a view or a filter changed on the review page: its address follows, the card unchanged
|
|
120
|
+
reaction(
|
|
121
|
+
() => (this.sel?.t === 'review' ? this.review.address : null),
|
|
122
|
+
(q) => {
|
|
123
|
+
const s = this.sel
|
|
124
|
+
if (q !== null && s?.t === 'review' && (s.q ?? '') !== q) this.setSel({ t: 'review', id: s.id, ...(q ? { q } : {}) })
|
|
125
|
+
},
|
|
126
|
+
)
|
|
115
127
|
}
|
|
116
128
|
|
|
117
129
|
/** an absolute hub path ('/api/…', '/media/…', '/hub.json') as this
|
|
@@ -298,8 +310,9 @@ export class RepoSt {
|
|
|
298
310
|
// the tool set rides after the asset id (media-svc syncHash), so an
|
|
299
311
|
// address is the same page whatever tools it carries
|
|
300
312
|
if (cur.kind === next.kind && cur.col === next.col && cur.id === next.id) {
|
|
301
|
-
// the trailer's note and filter ride on
|
|
302
|
-
|
|
313
|
+
// the trailer's note and filter, the review's view and filters ride on their page, as the tool set does on an
|
|
314
|
+
// asset: no history entry
|
|
315
|
+
if ((next.kind === 'trailer' || next.kind === 'review') && currentHash() !== h) history.replaceState(null, '', `#${h}`)
|
|
303
316
|
return
|
|
304
317
|
}
|
|
305
318
|
if (this.walking && this.walked) history.replaceState(null, '', `#${h}`)
|
|
@@ -361,9 +374,11 @@ export class RepoSt {
|
|
|
361
374
|
this.setSel({ t: 'processes', id })
|
|
362
375
|
}
|
|
363
376
|
/** the review page, `id` the card it opens, null for the first the list shows */
|
|
364
|
-
showReview(id: string | null): void {
|
|
377
|
+
showReview(id: string | null, q?: string): void {
|
|
365
378
|
this.review.select(id)
|
|
366
|
-
|
|
379
|
+
if (q !== undefined) this.review.applyAddress(q)
|
|
380
|
+
const query = this.review.address
|
|
381
|
+
this.setSel({ t: 'review', id, ...(query ? { q: query } : {}) })
|
|
367
382
|
if (this.polling) this.review.start()
|
|
368
383
|
}
|
|
369
384
|
/** a plain key the page shown claims for itself: the review page (V F R A N M, a clip's Space and arrows) and
|
|
@@ -400,7 +415,7 @@ export class RepoSt {
|
|
|
400
415
|
case 'git': return this.showGit()
|
|
401
416
|
case 'when': return this.showWhen()
|
|
402
417
|
case 'checks': return this.showChecks(sel.from)
|
|
403
|
-
case 'review': return this.showReview(sel.id)
|
|
418
|
+
case 'review': return this.showReview(sel.id, sel.q ?? '')
|
|
404
419
|
case 'trailer': return this.showTrailer(sel.trailer, sel.cut, sel.note ?? null, sel.filter ?? null)
|
|
405
420
|
}
|
|
406
421
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// the one clock of a clip on screen (a card's clip view, a trailer cut): it plays, pauses, seeks and steps
|
|
2
|
-
// of the view at once, so a pair of clips stays in sync. A paused time is always the middle of a frame, so
|
|
3
|
-
// of a pair and every rectangle drawn on it agree on the frame. The sound choice and the volume are the
|
|
4
|
-
// in the browser
|
|
1
|
+
// the one clock of a clip on screen (a card's clip view, a sound, a trailer cut): it plays, pauses, seeks and steps
|
|
2
|
+
// every video of the view at once, so a pair of clips stays in sync. A paused time is always the middle of a frame, so
|
|
3
|
+
// both videos of a pair and every rectangle drawn on it agree on the frame. The sound choice and the volume are the
|
|
4
|
+
// page's, kept in the browser. One thing plays at a time on the whole page (`solo`): any clock or player that starts
|
|
5
|
+
// pauses the one playing
|
|
5
6
|
import { makeAutoObservable, runInAction } from 'mobx'
|
|
6
7
|
|
|
7
8
|
/** the part of a video element the clock drives */
|
|
@@ -52,6 +53,45 @@ class Heard {
|
|
|
52
53
|
|
|
53
54
|
export const heard = new Heard()
|
|
54
55
|
|
|
56
|
+
/** what plays now, page wide: anything that starts pauses it first */
|
|
57
|
+
interface Playing {
|
|
58
|
+
pause(): void
|
|
59
|
+
}
|
|
60
|
+
let playing: Playing | null = null
|
|
61
|
+
/** the clock each media element of the page plays under; an element no clock drives is its own */
|
|
62
|
+
const owners = new WeakMap<object, Playing>()
|
|
63
|
+
|
|
64
|
+
export const solo = {
|
|
65
|
+
/** `p` starts: the one playing before it pauses */
|
|
66
|
+
claim(p: Playing): void {
|
|
67
|
+
const before = playing
|
|
68
|
+
playing = p
|
|
69
|
+
if (before && before !== p) before.pause()
|
|
70
|
+
},
|
|
71
|
+
/** `p` stopped or left the page */
|
|
72
|
+
release(p: Playing): void {
|
|
73
|
+
if (playing === p) playing = null
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// every audio or video of the page that starts (a sound's own player, the atelier's rolls) claims under its clock, so
|
|
78
|
+
// the rule holds for players no clock drives
|
|
79
|
+
if (typeof document !== 'undefined')
|
|
80
|
+
document.addEventListener(
|
|
81
|
+
'play',
|
|
82
|
+
(e) => {
|
|
83
|
+
const el = e.target
|
|
84
|
+
if (!(el instanceof HTMLMediaElement)) return
|
|
85
|
+
let me = owners.get(el)
|
|
86
|
+
if (!me) {
|
|
87
|
+
me = { pause: () => el.pause() }
|
|
88
|
+
owners.set(el, me)
|
|
89
|
+
}
|
|
90
|
+
solo.claim(me)
|
|
91
|
+
},
|
|
92
|
+
true,
|
|
93
|
+
)
|
|
94
|
+
|
|
55
95
|
export class ClipSt {
|
|
56
96
|
/** which clip is on screen, empty when none */
|
|
57
97
|
key = ''
|
|
@@ -79,14 +119,14 @@ export class ClipSt {
|
|
|
79
119
|
return heard.level
|
|
80
120
|
}
|
|
81
121
|
|
|
82
|
-
/** another clip on screen: from the start, playing
|
|
83
|
-
load(key: string, duration: number, fps: number): void {
|
|
122
|
+
/** another clip on screen: from the start, playing unless `still`; the same one again changes nothing */
|
|
123
|
+
load(key: string, duration: number, fps: number, still = false): void {
|
|
84
124
|
if (key === this.key) return
|
|
85
125
|
this.key = key
|
|
86
126
|
this.duration = duration
|
|
87
127
|
this.fps = fps > 0 ? fps : 20
|
|
88
128
|
this.time = 0
|
|
89
|
-
this.paused =
|
|
129
|
+
this.paused = still
|
|
90
130
|
this.error = null
|
|
91
131
|
}
|
|
92
132
|
|
|
@@ -104,6 +144,7 @@ export class ClipSt {
|
|
|
104
144
|
/** a video of the view on screen; it returns its release */
|
|
105
145
|
attach(el: ClipEl): () => void {
|
|
106
146
|
this.els.push(el)
|
|
147
|
+
owners.set(el, this)
|
|
107
148
|
el.muted = !this.sound || this.els[0] !== el
|
|
108
149
|
el.volume = heard.level
|
|
109
150
|
el.playbackRate = this.rate
|
|
@@ -113,9 +154,16 @@ export class ClipSt {
|
|
|
113
154
|
if (this.raf === 0 && typeof requestAnimationFrame === 'function') this.raf = requestAnimationFrame(this.follow)
|
|
114
155
|
return () => {
|
|
115
156
|
this.els = this.els.filter((e) => e !== el)
|
|
157
|
+
if (this.els.length === 0) solo.release(this)
|
|
116
158
|
}
|
|
117
159
|
}
|
|
118
160
|
|
|
161
|
+
/** played to be heard: its sound on, from where it stands */
|
|
162
|
+
listen(): void {
|
|
163
|
+
this.hear(true)
|
|
164
|
+
this.play()
|
|
165
|
+
}
|
|
166
|
+
|
|
119
167
|
toggleSound(): void {
|
|
120
168
|
this.hear(!this.sound)
|
|
121
169
|
heard.setOn(this.sound)
|
|
@@ -142,6 +190,7 @@ export class ClipSt {
|
|
|
142
190
|
play(): void {
|
|
143
191
|
this.paused = false
|
|
144
192
|
this.error = null
|
|
193
|
+
solo.claim(this)
|
|
145
194
|
for (const el of this.els) this.start(el)
|
|
146
195
|
}
|
|
147
196
|
|
|
@@ -181,6 +230,7 @@ export class ClipSt {
|
|
|
181
230
|
}
|
|
182
231
|
|
|
183
232
|
private start(el: ClipEl): void {
|
|
233
|
+
solo.claim(this)
|
|
184
234
|
const p = el.play()
|
|
185
235
|
if (p)
|
|
186
236
|
p.catch((e: Error) => {
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// what the repo's review unit says of the open item, drawn from DATA the hub computed (review/project.ts viewOfItem): its
|
|
2
|
-
// kind's facts, links, a text or its change as a diff,
|
|
3
|
-
// and a pair's animation to download. The page never
|
|
2
|
+
// kind's facts, links, a text or its change as a diff, its parts (a card each: logo, media beside the text, its change),
|
|
3
|
+
// and « live », the kind's url in a frame, off until he opens it; and a pair's animation to download. The page never
|
|
4
|
+
// runs the repo's code
|
|
4
5
|
import { makeAutoObservable, runInAction } from 'mobx'
|
|
5
6
|
import { observer } from 'mobx-react-lite'
|
|
6
7
|
import { useEffect } from 'react'
|
|
7
8
|
import { isGallery, isPair, viewName, type ReviewItem } from '../../../review/model.ts'
|
|
8
|
-
import type {
|
|
9
|
+
import type { PartData } from '../../../review/project.ts'
|
|
10
|
+
import type { Fact, Link, PartTone, ViewText } from '../../../review/unit.ts'
|
|
9
11
|
|
|
10
12
|
/** where an item's files and routes answer under the repo's base: the review page's own url maker */
|
|
11
13
|
interface Urls {
|
|
@@ -17,6 +19,7 @@ export interface KindData {
|
|
|
17
19
|
links: Link[]
|
|
18
20
|
live: string | null
|
|
19
21
|
text: ViewText | null
|
|
22
|
+
parts: PartData[]
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
/** each item's data view, read once an item opens and again when its thread moves; « live » stays as he left it */
|
|
@@ -40,7 +43,7 @@ export class KindViewSt {
|
|
|
40
43
|
read(x: ReviewItem): void {
|
|
41
44
|
const key = `${x.item.id}@${x.thread.at(-1)?.id ?? ''}`
|
|
42
45
|
if (this.views.has(key)) return
|
|
43
|
-
this.views.set(key, { facts: [], links: [], live: null, text: null })
|
|
46
|
+
this.views.set(key, { facts: [], links: [], live: null, text: null, parts: [] })
|
|
44
47
|
void fetch(this.st.fileUrl(x.item.id, 'view'))
|
|
45
48
|
.then(async (r) => (r.ok ? ((await r.json()) as KindData) : { error: ((await r.json().catch(() => ({}))) as { error?: string }).error ?? `HTTP ${r.status}` }))
|
|
46
49
|
.catch((e: Error) => ({ error: e.message }))
|
|
@@ -82,10 +85,8 @@ const kindViewOf = (st: Urls): KindViewSt => {
|
|
|
82
85
|
return k
|
|
83
86
|
}
|
|
84
87
|
|
|
85
|
-
/** a
|
|
86
|
-
function
|
|
87
|
-
const a = before.split('\n')
|
|
88
|
-
const b = after.split('\n')
|
|
88
|
+
/** a diff of two token lists: each token kept, gone or new */
|
|
89
|
+
function diffSeq(a: readonly string[], b: readonly string[]): { t: ' ' | '-' | '+'; line: string }[] {
|
|
89
90
|
const n = a.length
|
|
90
91
|
const m = b.length
|
|
91
92
|
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
|
|
@@ -103,6 +104,12 @@ function diffLines(before: string, after: string): { t: ' ' | '-' | '+'; line: s
|
|
|
103
104
|
return out
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
/** a line diff of two texts: each line kept, gone or new */
|
|
108
|
+
const diffLines = (before: string, after: string) => diffSeq(before.split('\n'), after.split('\n'))
|
|
109
|
+
|
|
110
|
+
/** a word diff of two texts, the spaces kept as tokens so each side reads as written */
|
|
111
|
+
const diffWords = (before: string, after: string) => diffSeq(before.split(/(\s+)/), after.split(/(\s+)/))
|
|
112
|
+
|
|
106
113
|
const TONE = { ' ': 'var(--dim)', '-': 'var(--red, #e5484d)', '+': 'var(--green, #46a758)' } as const
|
|
107
114
|
|
|
108
115
|
/** the kind's data view of the open item, under its facts */
|
|
@@ -125,7 +132,7 @@ export const KindView = observer(({ app, x }: { app: Urls; x: ReviewItem }) => {
|
|
|
125
132
|
) : null}
|
|
126
133
|
{v.links.length > 0 ? (
|
|
127
134
|
<div className="rv-fact">
|
|
128
|
-
{v.links.map((l) => <
|
|
135
|
+
{v.links.map((l) => <LinkTo key={l.url} l={l} />)}
|
|
129
136
|
</div>
|
|
130
137
|
) : null}
|
|
131
138
|
{t !== null && 'text' in t ? <pre className="rv-pre rv-kindtext">{t.text}</pre> : null}
|
|
@@ -136,6 +143,7 @@ export const KindView = observer(({ app, x }: { app: Urls; x: ReviewItem }) => {
|
|
|
136
143
|
))}
|
|
137
144
|
</pre>
|
|
138
145
|
) : null}
|
|
146
|
+
{v.parts.map((p, i) => <PartCard key={i} app={app} x={x} n={i} p={p} />)}
|
|
139
147
|
{v.live !== null ? (
|
|
140
148
|
<div>
|
|
141
149
|
<button className="ghost" onClick={() => k.toggleLive()}>{k.live ? 'hide live' : 'live'}</button>
|
|
@@ -146,6 +154,67 @@ export const KindView = observer(({ app, x }: { app: Urls; x: ReviewItem }) => {
|
|
|
146
154
|
)
|
|
147
155
|
})
|
|
148
156
|
|
|
157
|
+
/** a link: a page of the tool that frames the hub is posted to it, any other opens in a new tab */
|
|
158
|
+
const LinkTo = ({ l }: { l: Link }) => {
|
|
159
|
+
const framed = l.host === true && window.parent !== window
|
|
160
|
+
return (
|
|
161
|
+
<a
|
|
162
|
+
href={l.url}
|
|
163
|
+
target="_blank"
|
|
164
|
+
rel="noreferrer"
|
|
165
|
+
onClick={(e) => {
|
|
166
|
+
if (!framed) return
|
|
167
|
+
e.preventDefault()
|
|
168
|
+
window.parent.postMessage({ shipkit: 'open', url: l.url }, location.origin)
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
{l.label}
|
|
172
|
+
</a>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const BADGE: Record<PartTone, string> = { new: 'var(--green, #46a758)', changed: 'var(--orange, #e5932d)', gone: 'var(--red, #e5484d)', same: 'var(--dim)' }
|
|
177
|
+
const GONE = { background: 'color-mix(in srgb, var(--red, #e5484d) 18%, transparent)', textDecoration: 'line-through' }
|
|
178
|
+
const ADDED = { background: 'color-mix(in srgb, var(--green, #46a758) 20%, transparent)' }
|
|
179
|
+
|
|
180
|
+
/** one side of a word diff: what stayed and what that side alone has, marked */
|
|
181
|
+
const Side = ({ before, after, side }: { before: string; after: string; side: '-' | '+' }) => (
|
|
182
|
+
<div className="rv-pre rv-parttext">
|
|
183
|
+
{diffWords(before, after)
|
|
184
|
+
.filter((d) => d.t === ' ' || d.t === side)
|
|
185
|
+
.map((d, i) => <span key={i} style={d.t === ' ' ? undefined : side === '-' ? GONE : ADDED}>{d.line}</span>)}
|
|
186
|
+
</div>
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
/** a part of the subject as a card: its logo and name, its change, its media beside its text or the text's change */
|
|
190
|
+
const PartCard = ({ app, x, n, p }: { app: Urls; x: ReviewItem; n: number; p: PartData }) => {
|
|
191
|
+
const t = p.text
|
|
192
|
+
const src = app.fileUrl(x.item.id, `part-${n}`)
|
|
193
|
+
return (
|
|
194
|
+
<div className="rv-part">
|
|
195
|
+
<div className="rv-parthead">
|
|
196
|
+
{p.icon ? <img className="rv-particon" src={p.icon} alt="" /> : null}
|
|
197
|
+
<b>{p.label}</b>
|
|
198
|
+
{p.detail ? <span className="rv-dim"> · {p.detail}</span> : null}
|
|
199
|
+
{p.badge ? <span className="rv-partbadge" style={{ color: BADGE[p.badge.tone], borderColor: BADGE[p.badge.tone] }}>{p.badge.text}</span> : null}
|
|
200
|
+
</div>
|
|
201
|
+
<div className="rv-partbody">
|
|
202
|
+
{p.media === 'video' ? <video src={src} muted loop autoPlay playsInline controls /> : p.media === 'image' ? <img src={src} alt="" /> : null}
|
|
203
|
+
{'text' in t ? (
|
|
204
|
+
<div className="rv-pre rv-parttext">{t.text}</div>
|
|
205
|
+
) : t.before === t.after ? (
|
|
206
|
+
<div className="rv-pre rv-parttext">{t.after}</div>
|
|
207
|
+
) : (
|
|
208
|
+
<>
|
|
209
|
+
<div><div className="rv-factl">before</div><Side before={t.before} after={t.after} side="-" /></div>
|
|
210
|
+
<div><div className="rv-factl">after</div><Side before={t.before} after={t.after} side="+" /></div>
|
|
211
|
+
</>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
149
218
|
/** a pair of images as a looping animation to download: the mode, the format, the corner labels */
|
|
150
219
|
export const AnimPanel = observer(({ app, x }: { app: Urls; x: ReviewItem }) => {
|
|
151
220
|
const pairs = x.item.views.flatMap((v, i) => (!isGallery(v) && isPair(v.files) && !v.clip && !v.audio ? [{ i, name: viewName(v, i) }] : []))
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
// the review page's right column: the
|
|
2
|
-
// verdicts (the phone's in a bar under the thumb), then the thread's conversation and his reply
|
|
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)
|
|
3
4
|
import { observer } from 'mobx-react-lite'
|
|
4
|
-
import {
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
import { laneOf, placeText, STATUS_LABEL, stateOf, verdictOf, viewName, withdrawnOf, type ReviewItem, type ReviewStatus } from '../../../review/model.ts'
|
|
5
7
|
import { renderMd } from '../md.ts'
|
|
6
|
-
import { AutoTag, blurOnEscape,
|
|
8
|
+
import { AutoTag, blurOnEscape, Empty, LaneTag, Listeners, REACTIONS, Rework, secs, short, SIDE_LABEL, ThumbOf, toneOf, VERDICTS } from './kit.tsx'
|
|
7
9
|
import { AnimPanel, KindView } from './kind-view.tsx'
|
|
8
10
|
import type { ReviewSt } from './review-st.ts'
|
|
9
11
|
|
|
@@ -11,21 +13,14 @@ export const ReviewForm = observer(({ rv }: { rv: ReviewSt }) => {
|
|
|
11
13
|
const x = rv.open
|
|
12
14
|
if (!x) return <Empty text="The open card, its comment and its answer." />
|
|
13
15
|
const r = x.item
|
|
14
|
-
const status = rv.statusOf(x)
|
|
15
16
|
const draft = rv.draft
|
|
16
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
|
|
17
21
|
return (
|
|
18
22
|
<div className="rv-form">
|
|
19
|
-
<
|
|
20
|
-
<h3 className="rv-title">{r.title}</h3>
|
|
21
|
-
{r.views.length > 0 && r.what ? <div className="md rv-what" dangerouslySetInnerHTML={{ __html: renderMd(r.what) }} /> : null}
|
|
22
|
-
{r.keeps ? <div className="rv-keeps"><b>keeps:</b> {r.keeps}</div> : null}
|
|
23
|
-
{r.why ? <div className="rv-dim rv-pre">another subject: {r.why}</div> : null}
|
|
24
|
-
<div className="rv-facts">
|
|
25
|
-
<Fact label="subject">{aboutText(r, rv.spec ?? undefined)}</Fact>
|
|
26
|
-
{r.commit ? <Fact label="commit"><span className="rv-mono">{r.commit}</span></Fact> : null}
|
|
27
|
-
<Fact label="id"><span className="rv-mono rv-dim">{r.id}</span></Fact>
|
|
28
|
-
</div>
|
|
23
|
+
{headAbove ? null : <CardHead rv={rv} x={x} verdicts={false} />}
|
|
29
24
|
<KindView app={rv} x={x} />
|
|
30
25
|
<AnimPanel app={rv} x={x} />
|
|
31
26
|
{rv.newer.map((y) => <Linked key={y.item.id} rv={rv} label="replaced by" id={y.item.id} item={y} />)}
|
|
@@ -37,8 +32,8 @@ export const ReviewForm = observer(({ rv }: { rv: ReviewSt }) => {
|
|
|
37
32
|
<Outgoing rv={rv} />
|
|
38
33
|
<label className="rv-label">Comment</label>
|
|
39
34
|
<textarea className="rv-comment" rows={3} placeholder="the overall word" value={draft.comment} onKeyDown={blurOnEscape} onChange={(e) => rv.setComment(e.currentTarget.value)} />
|
|
40
|
-
<div className="rv-listenrow"><span className="rv-dim">who reads your answer</span><Listeners rv={rv} cards={[x]} /></div>
|
|
41
|
-
{!rv.phone.on && !r.wip && r.origin.by === 'agent' ? (
|
|
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' ? (
|
|
42
37
|
<div className="rv-verdicts">
|
|
43
38
|
{VERDICTS.map((v) => <VerdictButton key={v.status} v={v} sent={sent?.status} busy={rv.busy} send={() => void rv.submit(v.status)} />)}
|
|
44
39
|
</div>
|
|
@@ -55,60 +50,84 @@ export const ReviewForm = observer(({ rv }: { rv: ReviewSt }) => {
|
|
|
55
50
|
)
|
|
56
51
|
})
|
|
57
52
|
|
|
58
|
-
const Fact = ({ label, children }: { label: string; children: React.ReactNode }) => (
|
|
59
|
-
<div className="rv-fact"><span className="rv-factl">{label}</span><span className="rv-factv">{children}</span></div>
|
|
60
|
-
)
|
|
61
|
-
|
|
62
53
|
const RAISED_NOW: Record<string, string> = {
|
|
63
54
|
new: 'waiting for a dispatcher',
|
|
64
55
|
question: 'a question for you',
|
|
65
56
|
blocked: 'blocked: it waits on your decision',
|
|
66
57
|
}
|
|
67
58
|
|
|
68
|
-
/** the
|
|
69
|
-
|
|
70
|
-
const fb = verdictOf(x)
|
|
71
|
-
const withdrawn = withdrawnOf(x)
|
|
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 {
|
|
72
61
|
const o = x.item.origin
|
|
73
62
|
if (o.by !== 'agent') {
|
|
74
63
|
const st = stateOf(x, false)
|
|
75
|
-
const by = o.by === 'player' ? `by ${o.player}` : o.by === 'auto' ? `by the
|
|
64
|
+
const by = o.by === 'player' ? `by ${o.player}` : o.by === 'auto' ? `by the rule ${o.rule}` : 'by you'
|
|
76
65
|
const now =
|
|
77
|
-
st.state === 'open' ? `sent to ${st.topic ?? 'a topic'}
|
|
66
|
+
st.state === 'open' ? `sent to ${st.topic ?? 'a topic'}`
|
|
78
67
|
: st.state === 'claimed' ? `taken by ${st.worker ?? 'an agent'}`
|
|
79
68
|
: st.state === 'closed' ? (st.why === 'wontfix' ? 'closed, not done' : st.why === 'superseded' ? 'replaced by a card' : st.why === 'rated' ? 'rated' : 'done')
|
|
80
69
|
: RAISED_NOW[st.state] ?? st.state
|
|
81
|
-
return (
|
|
82
|
-
<div className={`rv-status ${toneOf(status)}`}>
|
|
83
|
-
<div className="rv-statushead">{cap(`raised ${by} · ${now}`)} {o.by === 'auto' ? <><LaneTag lane={o.lane} /><AutoTag rule={o.rule} /></> : null}</div>
|
|
84
|
-
<div className="rv-dim">on {when(x.item.created)}</div>
|
|
85
|
-
<Rework rv={rv} x={x} />
|
|
86
|
-
</div>
|
|
87
|
-
)
|
|
70
|
+
return `raised ${by} ${short(x.item.created)} · ${now}`
|
|
88
71
|
}
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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)
|
|
99
87
|
return (
|
|
100
|
-
<div className=
|
|
101
|
-
<
|
|
102
|
-
<div className="rv-
|
|
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>
|
|
103
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}
|
|
104
108
|
</div>
|
|
105
109
|
)
|
|
106
110
|
})
|
|
107
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
|
+
|
|
108
127
|
/** one verdict: plain until one is sent, then the chosen one filled and the others dimmed */
|
|
109
|
-
const VerdictButton = ({ v, sent, busy, send }: { v: (typeof VERDICTS)[number]; sent: ReviewStatus | undefined; busy: boolean; send: () => void }) => (
|
|
110
|
-
<button className={`rv-verdict ${toneOf(v.status)}${sent === v.status ? ' chosen' : ''}${sent && sent !== v.status ? ' off' : ''}`} disabled={busy} onClick={send}>
|
|
111
|
-
<span className="rv-vglyph">{sent === v.status ? '✓' : v.glyph}</span> {v.label} ({v.key}){sent === v.status ? ' · chosen' : ''}
|
|
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' : ''}
|
|
112
131
|
</button>
|
|
113
132
|
)
|
|
114
133
|
|
|
@@ -163,25 +182,36 @@ const Outgoing = observer(({ rv }: { rv: ReviewSt }) => {
|
|
|
163
182
|
const views = x.item.views
|
|
164
183
|
const draft = rv.draft
|
|
165
184
|
const reactions = rv.outgoingReactions
|
|
166
|
-
const empty = reactions.length === 0 && draft.rects.length === 0
|
|
185
|
+
const empty = reactions.length === 0 && draft.rects.length === 0 && draft.pick === undefined
|
|
167
186
|
return (
|
|
168
187
|
<div className="rv-outgoing">
|
|
169
188
|
<div className="rv-divider">what goes to the agent</div>
|
|
170
189
|
{empty ? (
|
|
171
190
|
<div className="rv-dim rv-small">
|
|
172
|
-
{rv.
|
|
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
|
|
173
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.'
|
|
174
197
|
: rv.phone.on
|
|
175
198
|
? 'React to a view (OK, fix, no, a word), or turn « Circle » on above the image, then drag to circle a detail.'
|
|
176
199
|
: 'React to a view (OK, fix, no, a word), or drag on the image to circle a detail.'}
|
|
177
200
|
</div>
|
|
178
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}
|
|
179
209
|
{reactions.map((q) => {
|
|
180
210
|
const o = REACTIONS.find((r) => r.status === q.status)
|
|
181
211
|
return (
|
|
182
212
|
<div key={`${q.view}/${q.tile ?? ''}`} className="rv-outrow">
|
|
183
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) }}>
|
|
184
|
-
{o?.glyph ?? '·'} {placeText(q, views).slice(1, -1)}
|
|
214
|
+
{o?.glyph ?? (q.stars ? '' : '·')}{q.stars ? `★${q.stars}` : ''} {placeText(q, views).slice(1, -1)}
|
|
185
215
|
</button>
|
|
186
216
|
<textarea rows={1} placeholder={o ? `${o.label}, with no word` : 'a word'} value={q.comment} onKeyDown={blurOnEscape}
|
|
187
217
|
onChange={(e) => rv.setReactionComment(q.view, q.tile, e.currentTarget.value)} />
|
|
@@ -1,6 +1,6 @@
|
|
|
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
|
-
// one in the middle with every view stacked, the answer on the right; narrower than STACK_PX
|
|
3
|
-
//
|
|
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 one pane at a time, the verdicts in a bar under the thumb. The list is read while the page
|
|
4
4
|
// shows
|
|
5
5
|
import { observer } from 'mobx-react-lite'
|
|
6
6
|
import { useEffect, useRef } from 'react'
|
|
@@ -60,17 +60,12 @@ export const ReviewPage = observer(({ app }: { app: RepoSt }) => {
|
|
|
60
60
|
</Panel>
|
|
61
61
|
<Separator className="rsep" />
|
|
62
62
|
{rv.stacked ? (
|
|
63
|
-
// one column: the
|
|
63
|
+
// one column read from the top: the header and the verdicts, the views, then the answer
|
|
64
64
|
<Panel id="review-stack" defaultSize="68%" minSize={300} className="rv-pane" style={PANEL}>
|
|
65
|
-
<
|
|
66
|
-
<
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
<Separator className="rsep hsep" />
|
|
70
|
-
<Panel id="review-form" defaultSize="38%" minSize={140} className="rv-pane" style={PANEL}>
|
|
71
|
-
<div className="rv-scroll"><ReviewForm rv={rv} /></div>
|
|
72
|
-
</Panel>
|
|
73
|
-
</Group>
|
|
65
|
+
<div className="rv-scroll">
|
|
66
|
+
<ReviewImage rv={rv} />
|
|
67
|
+
<ReviewForm rv={rv} />
|
|
68
|
+
</div>
|
|
74
69
|
</Panel>
|
|
75
70
|
) : (
|
|
76
71
|
<>
|
|
@@ -87,11 +82,11 @@ export const ReviewPage = observer(({ app }: { app: RepoSt }) => {
|
|
|
87
82
|
)
|
|
88
83
|
})
|
|
89
84
|
|
|
90
|
-
/** the phone's top line: the way back to the hub, and the repo the page is of. On a phone the page covers the hub's
|
|
85
|
+
/** the phone's top line: the way back to the hub (none in another tool's frame), and the repo the page is of. On a phone the page covers the hub's
|
|
91
86
|
* panes, which leave it no room */
|
|
92
87
|
export const PhoneTop = observer(({ app }: { app: RepoSt }) => (
|
|
93
88
|
<div className="rv-phonetop">
|
|
94
|
-
<button className="ghost" onClick={() => app.showReadme()}>◂ hub</button>
|
|
89
|
+
{app.home.embedded ? null : <button className="ghost" onClick={() => app.showReadme()}>◂ hub</button>}
|
|
95
90
|
<b>{app.name}</b>
|
|
96
91
|
<span className="rv-dim">{app.sel?.t === 'trailer' ? 'trailer' : 'review'}</span>
|
|
97
92
|
</div>
|