rvlib-shipkit 0.10.0 → 0.13.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 +204 -0
- package/README.md +17 -3
- package/package.json +5 -2
- package/src/asks/asks-queue.ts +42 -0
- package/src/assets/kit-config.ts +2 -3
- package/src/assets/roll-workspace.ts +2 -1
- package/src/assets/run-assets.ts +0 -11
- package/src/assets/server/studio-server.ts +7 -24
- package/src/cli/cmd_Assets.ts +3 -14
- package/src/cli/cmd_Check.ts +22 -4
- package/src/cli/cmd_Needs.ts +47 -0
- package/src/cli/cmd_Reflect.ts +116 -0
- package/src/cli/cmd_Rules.ts +32 -0
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli.ts +7 -2
- package/src/collections/actions.ts +85 -0
- package/src/collections/collection-types.ts +65 -0
- package/src/collections/graphs.ts +34 -0
- package/src/collections/pings.ts +72 -0
- package/src/collections/posts.ts +125 -0
- package/src/collections/providers.ts +275 -0
- package/src/contracts/apply-contracts.ts +47 -0
- package/src/contracts/run-contracts.ts +8 -3
- package/src/core/canonical-scripts.ts +46 -0
- package/src/core/check-suite.ts +6 -3
- package/src/core/locked-write.ts +39 -0
- package/src/core/motherhome.ts +87 -0
- package/src/core/run-check.ts +28 -15
- package/src/core/run-ci.ts +4 -0
- package/src/core/write-status.ts +7 -26
- package/src/init-repo.ts +24 -11
- package/src/reflect/prompt-corpus.ts +305 -0
- package/src/repo-config.ts +26 -38
- package/src/rules/SK001-package-json-shape.ts +156 -0
- package/src/rules/SK002-contracts-fulfillness.ts +19 -0
- package/src/rules/SK003-claude-md-shape.ts +64 -0
- package/src/rules/SK004-gitignore-policy.ts +105 -0
- package/src/rules/SK005-rvlib-versions.ts +99 -0
- package/src/rules/SK006-npm-publish-safety.ts +42 -0
- package/src/rules/SK007-github-visibility.ts +63 -0
- package/src/rules/rule-registry.ts +32 -0
- package/src/rules/rule.ts +62 -0
- package/src/rules/run-fix.ts +57 -0
- package/src/serve/hub-app/col-card.tsx +192 -0
- package/src/serve/hub-app/cols-svc.ts +136 -0
- package/src/serve/hub-app/geometry-view.tsx +155 -0
- package/src/serve/hub-app/health-svc.ts +49 -0
- package/src/serve/hub-app/hub-app.tsx +240 -127
- package/src/serve/hub-app/hub-st.ts +169 -0
- package/src/serve/hub-app/hub-types.ts +72 -0
- package/src/serve/hub-app/hub.css +286 -35
- package/src/serve/hub-app/index.html +1 -1
- package/src/serve/hub-app/md.ts +24 -0
- package/src/serve/hub-app/media-svc.ts +429 -0
- package/src/serve/hub-app/media-view.tsx +299 -0
- package/src/serve/run-serve.ts +46 -91
- package/src/serve/serve-hub.ts +99 -49
- package/src/shipkit.ts +13 -6
- package/src/assets/server/studio-page.ts +0 -687
- package/src/core/package-json-check.ts +0 -88
- package/src/serve/hub-pages.ts +0 -45
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
// Media (atelier) service — the studio state, per build/app-state-tree:
|
|
2
|
+
// observable state + actions + computeds live HERE; components read and
|
|
3
|
+
// call. Talks to the media API the hub mounts at /media/*. Kind/asset
|
|
4
|
+
// structure is read-only (kit.ts is code-reviewed); this edits WORKSPACE
|
|
5
|
+
// state: picks, alts, prompt overlays, meta sidecars, geometry rects.
|
|
6
|
+
import { makeAutoObservable, runInAction } from 'mobx'
|
|
7
|
+
import type { HubSt } from './hub-st.ts'
|
|
8
|
+
import type { AssetDetail, CatRow, Rect, SideAsset } from './hub-types.ts'
|
|
9
|
+
|
|
10
|
+
const MEDIA = '/media'
|
|
11
|
+
// thumbs change in place when a pick changes → ~8s cache buckets; roll
|
|
12
|
+
// files are immutable (one name = one file forever), so their URLs carry
|
|
13
|
+
// no bust — an <audio> src that never changes never restarts playback
|
|
14
|
+
const V = (): number => Date.now() >> 13
|
|
15
|
+
export const rollUrl = (id: string, name: string): string => `${MEDIA}/img?id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`
|
|
16
|
+
export const thumbUrl = (id: string): string => `${MEDIA}/api/thumb?id=${encodeURIComponent(id)}&t=${V()}`
|
|
17
|
+
export const isAud = (n: string): boolean => ['mp3', 'wav', 'ogg', 'm4a'].includes(String(n ?? '').split('.').pop() ?? '')
|
|
18
|
+
|
|
19
|
+
const j = async (u: string): Promise<any> => {
|
|
20
|
+
const r = await fetch(MEDIA + u)
|
|
21
|
+
if (!r.ok) throw new Error(`${u}: ${r.status}`)
|
|
22
|
+
return r.json()
|
|
23
|
+
}
|
|
24
|
+
const post = async (u: string, b?: unknown): Promise<any> => {
|
|
25
|
+
const r = await fetch(MEDIA + u, { method: 'POST', body: JSON.stringify(b ?? {}) })
|
|
26
|
+
return r.json()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class MediaSvc {
|
|
30
|
+
assets: SideAsset[] = []
|
|
31
|
+
pending: string[] = []
|
|
32
|
+
generating = false
|
|
33
|
+
genLine = ''
|
|
34
|
+
queued = 0
|
|
35
|
+
errors: string[] = []
|
|
36
|
+
progress: { value: number; max: number } | null = null
|
|
37
|
+
/** the media API vanished (mount failed) — shown loud, polling stops */
|
|
38
|
+
dead: string | null = null
|
|
39
|
+
|
|
40
|
+
detail: AssetDetail | null = null
|
|
41
|
+
active: string | null = null
|
|
42
|
+
/** strip hover preview (image rolls only — audio must not restart) */
|
|
43
|
+
hover: string | null = null
|
|
44
|
+
prompt = ''
|
|
45
|
+
promptMsg = ''
|
|
46
|
+
statusLine = ''
|
|
47
|
+
n = '3'
|
|
48
|
+
|
|
49
|
+
rect: Rect | null = null
|
|
50
|
+
rectRatio = 0.6
|
|
51
|
+
|
|
52
|
+
catRows: CatRow[] = []
|
|
53
|
+
allSize = ''
|
|
54
|
+
allMsg = ''
|
|
55
|
+
|
|
56
|
+
openCats = new Set<string>()
|
|
57
|
+
tab: string = 'asset'
|
|
58
|
+
drawerOpen = false
|
|
59
|
+
jsonText = ''
|
|
60
|
+
jsonMsg = ''
|
|
61
|
+
|
|
62
|
+
private hbTimer: ReturnType<typeof setInterval> | null = null
|
|
63
|
+
private rectTimer: ReturnType<typeof setTimeout> | null = null
|
|
64
|
+
private promptTimer: ReturnType<typeof setTimeout> | null = null
|
|
65
|
+
/** last text known saved for the CURRENT asset — an equal edit is not a save */
|
|
66
|
+
private savedPrompt = ''
|
|
67
|
+
|
|
68
|
+
constructor(private st: HubSt) {
|
|
69
|
+
makeAutoObservable<this, 'st' | 'hbTimer' | 'rectTimer' | 'promptTimer' | 'savedPrompt'>(
|
|
70
|
+
this, { st: false, hbTimer: false, rectTimer: false, promptTimer: false, savedPrompt: false })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
start(): void {
|
|
74
|
+
void this.heartbeat()
|
|
75
|
+
this.hbTimer = setInterval(() => void this.heartbeat(), 1500)
|
|
76
|
+
}
|
|
77
|
+
dispose(): void {
|
|
78
|
+
if (this.hbTimer != null) clearInterval(this.hbTimer)
|
|
79
|
+
if (this.rectTimer != null) clearTimeout(this.rectTimer)
|
|
80
|
+
if (this.promptTimer != null) clearTimeout(this.promptTimer)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
get curId(): string | null {
|
|
84
|
+
return this.st.sel?.t === 'asset' ? this.st.sel.id : null
|
|
85
|
+
}
|
|
86
|
+
get catMode(): string | null {
|
|
87
|
+
return this.st.sel?.t === 'cat' ? this.st.sel.cat : null
|
|
88
|
+
}
|
|
89
|
+
get cats(): string[] {
|
|
90
|
+
return [...new Set(this.visibleAssets.map((a) => a.cat))]
|
|
91
|
+
}
|
|
92
|
+
/** attention predicates — mirror the collection severities for media
|
|
93
|
+
* (providers.ts: manual+empty = red; unpicked rolls / stale = yellow) */
|
|
94
|
+
needsAsset = (a: SideAsset): boolean => a.stale || (a.manual && a.rolls === 0) || (a.rolls > 0 && !a.picked)
|
|
95
|
+
redAsset = (a: SideAsset): boolean => a.manual && a.rolls === 0
|
|
96
|
+
get visibleAssets(): SideAsset[] {
|
|
97
|
+
const f = this.st.filter
|
|
98
|
+
const base = f === 'all' ? this.assets : this.assets.filter(f === 'red' ? this.redAsset : this.needsAsset)
|
|
99
|
+
const q = this.st.query.trim().toLowerCase()
|
|
100
|
+
if (q === '') return base
|
|
101
|
+
return base.filter((a) => a.label.toLowerCase().includes(q) || a.id.toLowerCase().includes(q))
|
|
102
|
+
}
|
|
103
|
+
get cur(): SideAsset | null {
|
|
104
|
+
return this.assets.find((a) => a.id === this.curId) ?? null
|
|
105
|
+
}
|
|
106
|
+
get deterministic(): boolean {
|
|
107
|
+
return this.detail?.info.deterministic === true
|
|
108
|
+
}
|
|
109
|
+
get hasGeom(): boolean {
|
|
110
|
+
return this.detail?.asset.geometry === true && this.cur?.icon !== true
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async heartbeat(): Promise<void> {
|
|
114
|
+
if (this.dead != null) return
|
|
115
|
+
let s: any
|
|
116
|
+
try {
|
|
117
|
+
const r = await fetch(MEDIA + '/api/state')
|
|
118
|
+
if (r.status === 404) {
|
|
119
|
+
// the mount failed at boot — it will not come back without a
|
|
120
|
+
// restart; say so once, loudly, and stop wasting the wire
|
|
121
|
+
runInAction(() => { this.dead = 'not mounted — fix the kit, restart the serve task' })
|
|
122
|
+
if (this.hbTimer != null) clearInterval(this.hbTimer)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
if (!r.ok) throw new Error(String(r.status))
|
|
126
|
+
s = await r.json()
|
|
127
|
+
} catch {
|
|
128
|
+
// transient (serve task restarting, network blip) — KEEP polling;
|
|
129
|
+
// only a boot-time 404 is permanent (collections self-heal, so must we)
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
runInAction(() => {
|
|
133
|
+
this.assets = s.assets
|
|
134
|
+
this.pending = s.pending ?? []
|
|
135
|
+
this.generating = s.generating
|
|
136
|
+
this.queued = s.queued ?? 0
|
|
137
|
+
this.progress = s.progress ?? null
|
|
138
|
+
this.errors = s.errors ?? []
|
|
139
|
+
const p = s.progress
|
|
140
|
+
this.genLine = s.generating
|
|
141
|
+
? `generating ${s.current ?? '…'} ${s.done}/${s.total}${s.queued ? ` · ${s.queued} queued` : ''}${p?.max ? ` · step ${p.value}/${p.max}` : ''}`
|
|
142
|
+
: ''
|
|
143
|
+
})
|
|
144
|
+
if (this.curId != null) await this.refreshRolls()
|
|
145
|
+
if (this.catMode != null) await this.fillCat()
|
|
146
|
+
this.st.afterFirstBeat()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async select(id: string): Promise<void> {
|
|
150
|
+
this.active = null
|
|
151
|
+
this.rect = null
|
|
152
|
+
this.detail = null
|
|
153
|
+
this.statusLine = ''
|
|
154
|
+
const a = this.assets.find((x) => x.id === id)
|
|
155
|
+
if (a != null) this.openCats.add(a.cat)
|
|
156
|
+
const r = (await j(`/api/asset?id=${encodeURIComponent(id)}`)) as AssetDetail
|
|
157
|
+
if (this.curId !== id) return // selection changed mid-fetch — never clobber
|
|
158
|
+
runInAction(() => {
|
|
159
|
+
this.detail = r
|
|
160
|
+
this.prompt = r.asset.prompt
|
|
161
|
+
this.savedPrompt = r.asset.prompt
|
|
162
|
+
this.promptMsg = ''
|
|
163
|
+
if (this.deterministic) this.n = '1'
|
|
164
|
+
if (this.tab.startsWith('c:') && !r.asset.custom.some((c) => `c:${c.id}` === this.tab)) this.tab = 'asset'
|
|
165
|
+
this.pickActive(r)
|
|
166
|
+
})
|
|
167
|
+
if (this.hasGeom) await this.loadRect()
|
|
168
|
+
void this.loadTab()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private pickActive(r: AssetDetail): void {
|
|
172
|
+
if (this.active == null || !r.rolls.some((x) => x.name === this.active))
|
|
173
|
+
this.active = (r.rolls.find((x) => x.picked) ?? r.rolls[0])?.name ?? null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async refreshRolls(): Promise<void> {
|
|
177
|
+
const id = this.curId
|
|
178
|
+
if (id == null) return
|
|
179
|
+
const r = (await j(`/api/asset?id=${encodeURIComponent(id)}`)) as AssetDetail
|
|
180
|
+
if (this.curId !== id) return
|
|
181
|
+
runInAction(() => {
|
|
182
|
+
if (this.detail == null) this.detail = r
|
|
183
|
+
else this.detail.rolls = r.rolls
|
|
184
|
+
this.pickActive(r)
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
setActive(name: string): void {
|
|
189
|
+
this.active = name
|
|
190
|
+
if (this.hasGeom) void this.loadRect()
|
|
191
|
+
if (this.tab !== 'asset') void this.loadTab()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async generate(): Promise<void> {
|
|
195
|
+
if (this.curId == null) return
|
|
196
|
+
await post('/api/generate', { id: this.curId, prompt: this.prompt, count: +this.n })
|
|
197
|
+
void this.heartbeat()
|
|
198
|
+
}
|
|
199
|
+
async genCat(cat: string, count: number): Promise<void> {
|
|
200
|
+
const r = await post('/api/generate-cat', { cat, count })
|
|
201
|
+
if (r?.error != null) runInAction(() => { this.statusLine = r.error })
|
|
202
|
+
void this.heartbeat()
|
|
203
|
+
}
|
|
204
|
+
clearQueue(): void {
|
|
205
|
+
void post('/api/queue/clear')
|
|
206
|
+
}
|
|
207
|
+
stopCurrent(): void {
|
|
208
|
+
void post('/api/interrupt')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async pick(name: string): Promise<void> {
|
|
212
|
+
const res = await post('/api/pick', { id: this.curId, name })
|
|
213
|
+
runInAction(() => {
|
|
214
|
+
this.statusLine = res.error != null ? `ship FAILED: ${res.error}` : res.shipped != null ? `shipped → ${res.shipped}` : 'picked (pick.json)'
|
|
215
|
+
this.active = name
|
|
216
|
+
})
|
|
217
|
+
await this.refreshRolls()
|
|
218
|
+
}
|
|
219
|
+
async alt(name: string): Promise<void> {
|
|
220
|
+
await post('/api/alt', { id: this.curId, name })
|
|
221
|
+
await this.refreshRolls()
|
|
222
|
+
}
|
|
223
|
+
async discard(name: string): Promise<void> {
|
|
224
|
+
await post('/api/discard', { id: this.curId, name })
|
|
225
|
+
runInAction(() => {
|
|
226
|
+
if (this.active === name) this.active = null
|
|
227
|
+
// the ✕ sits on the hovered tile and its unmount fires no
|
|
228
|
+
// mouseleave — a latched hover would 404 the stage
|
|
229
|
+
if (this.hover === name) this.hover = null
|
|
230
|
+
})
|
|
231
|
+
await this.refreshRolls()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async upload(files: FileList | File[]): Promise<void> {
|
|
235
|
+
if (this.curId == null) return
|
|
236
|
+
for (const f of Array.from(files)) {
|
|
237
|
+
const fd = new FormData()
|
|
238
|
+
fd.append('id', this.curId)
|
|
239
|
+
fd.append('file', f)
|
|
240
|
+
const r = await fetch(`${MEDIA}/api/upload`, { method: 'POST', body: fd }).then((x) => x.json()).catch((e: Error) => ({ error: String(e) }))
|
|
241
|
+
if (r.error != null) runInAction(() => { this.statusLine = `upload FAIL: ${r.error}` })
|
|
242
|
+
}
|
|
243
|
+
await this.refreshRolls()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** prompt AUTOSAVES — edit, pause, it lands in the overlay (the server
|
|
247
|
+
* clears the overlay when the text equals the kit prompt). Guards: no
|
|
248
|
+
* save before the detail is in, no save of an unchanged text, and the
|
|
249
|
+
* asset id is captured at SCHEDULE time — a selection switch inside
|
|
250
|
+
* the debounce window must never write onto the wrong asset. */
|
|
251
|
+
setPrompt(v: string): void {
|
|
252
|
+
this.prompt = v
|
|
253
|
+
if (this.detail == null) return // nothing selected/loaded — nowhere to save
|
|
254
|
+
const id = this.curId
|
|
255
|
+
if (id == null) return
|
|
256
|
+
if (this.promptTimer != null) clearTimeout(this.promptTimer)
|
|
257
|
+
this.promptTimer = setTimeout(() => {
|
|
258
|
+
this.promptTimer = null
|
|
259
|
+
if (this.curId !== id) return // selection moved on — drop, never cross-write
|
|
260
|
+
const text = this.prompt
|
|
261
|
+
if (text === this.savedPrompt) return // unchanged — not a save
|
|
262
|
+
void (async () => {
|
|
263
|
+
const r = await post('/api/prompt', { id, prompt: text })
|
|
264
|
+
runInAction(() => {
|
|
265
|
+
if (this.curId !== id) return // selection moved while the POST flew — a late response must not corrupt the new asset's baseline
|
|
266
|
+
if (r.error != null) {
|
|
267
|
+
this.promptMsg = `save FAIL: ${r.error}`
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
this.savedPrompt = text
|
|
271
|
+
this.promptMsg = 'prompt saved'
|
|
272
|
+
})
|
|
273
|
+
setTimeout(() => runInAction(() => { if (this.promptMsg === 'prompt saved') this.promptMsg = '' }), 1500)
|
|
274
|
+
})()
|
|
275
|
+
}, 800)
|
|
276
|
+
}
|
|
277
|
+
copyPrompt(): void {
|
|
278
|
+
void navigator.clipboard.writeText(this.prompt)
|
|
279
|
+
this.promptMsg = 'copied'
|
|
280
|
+
setTimeout(() => runInAction(() => { this.promptMsg = '' }), 900)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
toggleFold(cat: string): void {
|
|
284
|
+
if (this.st.query.trim() !== '') return // folds yield to a live search
|
|
285
|
+
this.openCats.has(cat) ? this.openCats.delete(cat) : this.openCats.add(cat)
|
|
286
|
+
}
|
|
287
|
+
/** rail 🖼 click — every category unfolds at once */
|
|
288
|
+
openAllCats(): void {
|
|
289
|
+
for (const a of this.assets) this.openCats.add(a.cat)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── geometry ──────────────────────────────────────────────────────────
|
|
293
|
+
async loadRect(): Promise<void> {
|
|
294
|
+
if (!this.hasGeom || this.curId == null || this.active == null) {
|
|
295
|
+
this.rect = null
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
const id = this.curId
|
|
299
|
+
const name = this.active
|
|
300
|
+
const r = await j(`/api/rect?id=${encodeURIComponent(id)}&name=${encodeURIComponent(name)}`).catch(() => null)
|
|
301
|
+
if (this.curId !== id || this.active !== name) return // roll switched mid-fetch
|
|
302
|
+
runInAction(() => {
|
|
303
|
+
this.rect = r?.rect ?? null
|
|
304
|
+
if (this.rect != null && this.rect.w > 0) this.rectRatio = this.rect.h / this.rect.w
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
/** 250ms trailing debounce → POST; server re-cleans + measures art.
|
|
308
|
+
* (id, name, rect) are captured at SCHEDULE time — an arrow-key roll
|
|
309
|
+
* switch inside the debounce window must never write the old roll's
|
|
310
|
+
* rect onto the new one */
|
|
311
|
+
saveRect(): void {
|
|
312
|
+
const id = this.curId
|
|
313
|
+
const name = this.active
|
|
314
|
+
const rect = this.rect
|
|
315
|
+
if (id == null || name == null || rect == null) return
|
|
316
|
+
if (this.rectTimer != null) clearTimeout(this.rectTimer)
|
|
317
|
+
this.rectTimer = setTimeout(() => {
|
|
318
|
+
void (async () => {
|
|
319
|
+
const r = await post('/api/rect', { id, name, rect })
|
|
320
|
+
runInAction(() => {
|
|
321
|
+
if (this.curId !== id || this.active !== name) return // selection moved on — never adopt a stale rect
|
|
322
|
+
if (r.rect != null) this.rect = r.rect
|
|
323
|
+
else if (r.error != null) this.statusLine = `rect FAIL: ${r.error}`
|
|
324
|
+
})
|
|
325
|
+
})()
|
|
326
|
+
}, 250)
|
|
327
|
+
}
|
|
328
|
+
async legsDefault(): Promise<void> {
|
|
329
|
+
if (this.curId == null || this.active == null) return
|
|
330
|
+
const r = await post('/api/legs-default', { id: this.curId, name: this.active })
|
|
331
|
+
if (r.rect != null) runInAction(() => { this.rect = r.rect })
|
|
332
|
+
}
|
|
333
|
+
removeLegs(): void {
|
|
334
|
+
if (this.rect?.legs == null) return
|
|
335
|
+
delete this.rect.legs
|
|
336
|
+
delete this.rect.legColor
|
|
337
|
+
this.saveRect()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── category grid ─────────────────────────────────────────────────────
|
|
341
|
+
async fillCat(): Promise<void> {
|
|
342
|
+
const mode = this.catMode
|
|
343
|
+
if (mode == null) return
|
|
344
|
+
const r = await j(`/api/catrects?cat=${encodeURIComponent(mode)}`)
|
|
345
|
+
if (this.catMode !== mode) return // navigated away mid-fetch
|
|
346
|
+
runInAction(() => { this.catRows = r.rows ?? [] })
|
|
347
|
+
}
|
|
348
|
+
async setSize(id: string, name: string, v: number): Promise<void> {
|
|
349
|
+
const row = this.catRows.find((r) => r.id === id)
|
|
350
|
+
if (row?.rect == null) return
|
|
351
|
+
const nv = Math.max(0.1, v || row.rect.size)
|
|
352
|
+
if (nv === row.rect.size) return // blur without a change is not a save
|
|
353
|
+
row.rect.size = nv
|
|
354
|
+
await post('/api/rect', { id, name, rect: row.rect })
|
|
355
|
+
}
|
|
356
|
+
async toggleFlip(id: string, name: string): Promise<void> {
|
|
357
|
+
await post('/api/flip', { id, name })
|
|
358
|
+
await this.fillCat()
|
|
359
|
+
}
|
|
360
|
+
async applyAllSize(): Promise<void> {
|
|
361
|
+
const v = Math.max(0.1, +this.allSize || 0)
|
|
362
|
+
if (!v) return
|
|
363
|
+
const rows = this.catRows.filter((r) => !r.icon && r.rect != null)
|
|
364
|
+
// one action for all mutations (post-await writes would land outside
|
|
365
|
+
// it — mobx strict mode), then the POSTs
|
|
366
|
+
runInAction(() => { for (const row of rows) row.rect!.size = v })
|
|
367
|
+
for (const row of rows) await post('/api/rect', { id: row.id, name: row.name, rect: row.rect })
|
|
368
|
+
runInAction(() => { this.allMsg = 'applied' })
|
|
369
|
+
setTimeout(() => runInAction(() => { this.allMsg = '' }), 1200)
|
|
370
|
+
await this.fillCat()
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ── drawer: asset json (read-only) · roll meta sidecar · custom panels ─
|
|
374
|
+
setTab(tab: string): void {
|
|
375
|
+
this.tab = tab
|
|
376
|
+
this.drawerOpen = true
|
|
377
|
+
void this.loadTab()
|
|
378
|
+
}
|
|
379
|
+
async loadTab(): Promise<void> {
|
|
380
|
+
this.jsonMsg = ''
|
|
381
|
+
if (this.tab.startsWith('c:')) return // panel host owns that surface
|
|
382
|
+
if (this.tab === 'asset') {
|
|
383
|
+
const text = this.detail != null ? JSON.stringify(this.detail.asset, null, 2) : ''
|
|
384
|
+
runInAction(() => { this.jsonText = text })
|
|
385
|
+
} else {
|
|
386
|
+
const r = this.curId != null && this.active != null ? await post('/api/meta', { id: this.curId, name: this.active }) : { text: '' }
|
|
387
|
+
runInAction(() => { this.jsonText = r.text ?? '' })
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
async saveMeta(): Promise<void> {
|
|
391
|
+
if (this.tab !== 'meta' || this.curId == null || this.active == null) return
|
|
392
|
+
const r = await post('/api/meta', { id: this.curId, name: this.active, text: this.jsonText })
|
|
393
|
+
runInAction(() => { this.jsonMsg = r.error != null ? `FAIL: ${r.error}` : 'saved' })
|
|
394
|
+
}
|
|
395
|
+
/** frozen per mount: a roll/asset switch remounts with a fresh api */
|
|
396
|
+
panelApi(c: { kind: string }): Record<string, unknown> {
|
|
397
|
+
const id = this.curId!
|
|
398
|
+
const name = this.active!
|
|
399
|
+
return {
|
|
400
|
+
assetId: id,
|
|
401
|
+
kindId: c.kind,
|
|
402
|
+
roll: name,
|
|
403
|
+
imgUrl: rollUrl(id, name),
|
|
404
|
+
getMeta: async () => {
|
|
405
|
+
const r = await post('/api/meta', { id, name })
|
|
406
|
+
if (r.error != null) throw new Error(r.error)
|
|
407
|
+
return r.text ? JSON.parse(r.text) : null
|
|
408
|
+
},
|
|
409
|
+
saveMeta: async (data: unknown) => {
|
|
410
|
+
const r = await post('/api/meta', { id, name, text: JSON.stringify(data) })
|
|
411
|
+
if (r.error != null) throw new Error(r.error)
|
|
412
|
+
},
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// keyboard verbs (←/→ roll · Enter pick · A alt · X discard)
|
|
417
|
+
key(k: string): boolean {
|
|
418
|
+
const rolls = this.detail?.rolls ?? []
|
|
419
|
+
if (this.curId == null || rolls.length === 0) return false
|
|
420
|
+
const i = rolls.findIndex((x) => x.name === this.active)
|
|
421
|
+
if (k === 'ArrowRight' && i < rolls.length - 1) this.setActive(rolls[i + 1]!.name)
|
|
422
|
+
else if (k === 'ArrowLeft' && i > 0) this.setActive(rolls[i - 1]!.name)
|
|
423
|
+
else if (k === 'Enter' && this.active != null) void this.pick(this.active)
|
|
424
|
+
else if ((k === 'a' || k === 'A') && this.active != null) void this.alt(this.active)
|
|
425
|
+
else if ((k === 'x' || k === 'X') && this.active != null) void this.discard(this.active)
|
|
426
|
+
else return false
|
|
427
|
+
return true
|
|
428
|
+
}
|
|
429
|
+
}
|