@goodbones/browser 0.1.0-beta.1
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/LICENSE +21 -0
- package/build/dts/index.d.ts +12 -0
- package/build/dts/index.d.ts.map +1 -0
- package/build/dts/main.d.ts +3 -0
- package/build/dts/main.d.ts.map +1 -0
- package/build/dts/model/atlas.d.ts +157 -0
- package/build/dts/model/atlas.d.ts.map +1 -0
- package/build/dts/model/campaigns.d.ts +141 -0
- package/build/dts/model/campaigns.d.ts.map +1 -0
- package/build/dts/server/collect.d.ts +16 -0
- package/build/dts/server/collect.d.ts.map +1 -0
- package/build/dts/server/compose.d.ts +13 -0
- package/build/dts/server/compose.d.ts.map +1 -0
- package/build/dts/server/export.d.ts +3 -0
- package/build/dts/server/export.d.ts.map +1 -0
- package/build/dts/server/handler.d.ts +6 -0
- package/build/dts/server/handler.d.ts.map +1 -0
- package/build/dts/server/serve.d.ts +15 -0
- package/build/dts/server/serve.d.ts.map +1 -0
- package/build/dts/server/site.d.ts +2 -0
- package/build/dts/server/site.d.ts.map +1 -0
- package/build/dts/server/source.d.ts +19 -0
- package/build/dts/server/source.d.ts.map +1 -0
- package/build/dts/server/vite-plugin.d.ts +8 -0
- package/build/dts/server/vite-plugin.d.ts.map +1 -0
- package/build/dts/server/watch.d.ts +10 -0
- package/build/dts/server/watch.d.ts.map +1 -0
- package/build/esm/index.js +14 -0
- package/build/esm/index.js.map +1 -0
- package/build/esm/main.js +134 -0
- package/build/esm/main.js.map +1 -0
- package/build/esm/model/atlas.js +431 -0
- package/build/esm/model/atlas.js.map +1 -0
- package/build/esm/model/campaigns.js +181 -0
- package/build/esm/model/campaigns.js.map +1 -0
- package/build/esm/server/collect.js +83 -0
- package/build/esm/server/collect.js.map +1 -0
- package/build/esm/server/compose.js +50 -0
- package/build/esm/server/compose.js.map +1 -0
- package/build/esm/server/export.js +19 -0
- package/build/esm/server/export.js.map +1 -0
- package/build/esm/server/handler.js +67 -0
- package/build/esm/server/handler.js.map +1 -0
- package/build/esm/server/serve.js +80 -0
- package/build/esm/server/serve.js.map +1 -0
- package/build/esm/server/site.js +6 -0
- package/build/esm/server/site.js.map +1 -0
- package/build/esm/server/source.js +67 -0
- package/build/esm/server/source.js.map +1 -0
- package/build/esm/server/vite-plugin.js +21 -0
- package/build/esm/server/vite-plugin.js.map +1 -0
- package/build/esm/server/watch.js +56 -0
- package/build/esm/server/watch.js.map +1 -0
- package/build/site/_astro/Browser.yxldcnQ3.js +3 -0
- package/build/site/_astro/client.Buyw3Q1S.js +9 -0
- package/build/site/_astro/index.DQrWs1cb.css +1 -0
- package/build/site/_astro/react.DJY1zw8Z.js +1 -0
- package/build/site/favicon.svg +1 -0
- package/build/site/index.html +8 -0
- package/package.json +78 -0
- package/src/app/components/Browser.tsx +153 -0
- package/src/app/components/architecture/ArchitectureBrowser.tsx +240 -0
- package/src/app/components/architecture/Detail.tsx +655 -0
- package/src/app/components/architecture/ManifestPane.tsx +178 -0
- package/src/app/components/architecture/Tree.tsx +205 -0
- package/src/app/components/architecture/rows.ts +181 -0
- package/src/app/components/architecture/selection.ts +6 -0
- package/src/app/components/campaigns/CampaignBrowser.tsx +146 -0
- package/src/app/components/campaigns/Detail.tsx +653 -0
- package/src/app/components/campaigns/Ladder.tsx +326 -0
- package/src/app/components/campaigns/pick.ts +7 -0
- package/src/app/components/lib/data.ts +132 -0
- package/src/app/pages/index.astro +34 -0
- package/src/app/public/favicon.svg +1 -0
- package/src/app/styles/theme.css +993 -0
- package/src/index.ts +37 -0
- package/src/main.ts +155 -0
- package/src/model/atlas.ts +751 -0
- package/src/model/campaigns.ts +376 -0
- package/src/server/collect.ts +132 -0
- package/src/server/compose.ts +79 -0
- package/src/server/export.ts +24 -0
- package/src/server/handler.ts +77 -0
- package/src/server/serve.ts +100 -0
- package/src/server/site.ts +6 -0
- package/src/server/source.ts +89 -0
- package/src/server/vite-plugin.ts +32 -0
- package/src/server/watch.ts +65 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import type { CampaignCard, ObjectiveCard, SectorCard } from "../../../model/campaigns.js";
|
|
2
|
+
import type { Pick } from "./pick.js";
|
|
3
|
+
|
|
4
|
+
// The ladder, drawn: one column per phase with its objectives beneath, an end
|
|
5
|
+
// column, and a lane of sectors each standing in the column of its phase.
|
|
6
|
+
// Objectives no phase names — every objective of a campaign without phases —
|
|
7
|
+
// stand in a first "standing" column, in window everywhere.
|
|
8
|
+
|
|
9
|
+
const COL = 168;
|
|
10
|
+
const GAP = 14;
|
|
11
|
+
const X0 = 16;
|
|
12
|
+
const PHASE_Y = 14;
|
|
13
|
+
const PHASE_H = 52;
|
|
14
|
+
const CHIP_H = 24;
|
|
15
|
+
const CHIP_GAP = 6;
|
|
16
|
+
const SECTOR_H = 28;
|
|
17
|
+
const SECTOR_GAP = 8;
|
|
18
|
+
|
|
19
|
+
type Column =
|
|
20
|
+
| { readonly kind: "standing" }
|
|
21
|
+
| { readonly kind: "phase"; readonly index: number }
|
|
22
|
+
| { readonly kind: "end" };
|
|
23
|
+
|
|
24
|
+
type Props = {
|
|
25
|
+
readonly campaign: CampaignCard;
|
|
26
|
+
readonly nudged: ReadonlySet<string>;
|
|
27
|
+
readonly hover: Pick | null;
|
|
28
|
+
readonly selected: Pick | null;
|
|
29
|
+
readonly onHover: (pick: Pick | null) => void;
|
|
30
|
+
readonly onPick: (pick: Pick | null) => void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const same = (a: Pick | null, b: Pick): boolean =>
|
|
34
|
+
a !== null &&
|
|
35
|
+
a.kind === b.kind &&
|
|
36
|
+
(a.kind === "campaign" ||
|
|
37
|
+
(a.kind === "sector" && b.kind === "sector"
|
|
38
|
+
? a.name === b.name
|
|
39
|
+
: (a as { id?: string }).id === (b as { id?: string }).id));
|
|
40
|
+
|
|
41
|
+
export const columnsOf = (campaign: CampaignCard): ReadonlyArray<Column> => {
|
|
42
|
+
const standing = campaign.objectives.some((one) => one.phase === null);
|
|
43
|
+
return [
|
|
44
|
+
...(standing || campaign.phases.length === 0 ? [{ kind: "standing" } as const] : []),
|
|
45
|
+
...campaign.phases.map((_, index) => ({ kind: "phase", index }) as const),
|
|
46
|
+
{ kind: "end" } as const,
|
|
47
|
+
];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Which column a sector stands in.
|
|
51
|
+
export const columnOfSector = (
|
|
52
|
+
campaign: CampaignCard,
|
|
53
|
+
columns: ReadonlyArray<Column>,
|
|
54
|
+
sector: SectorCard,
|
|
55
|
+
): number => {
|
|
56
|
+
if (sector.done) return columns.length - 1;
|
|
57
|
+
if (campaign.phases.length === 0) return 0;
|
|
58
|
+
const at = columns.findIndex((one) => one.kind === "phase" && one.index === sector.phase);
|
|
59
|
+
return at === -1 ? 0 : at;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const truncate = (text: string, max: number): string =>
|
|
63
|
+
text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
64
|
+
|
|
65
|
+
const scalarLabel = (objective: ObjectiveCard): string => {
|
|
66
|
+
const measure = objective.measure;
|
|
67
|
+
if (measure === null) return String(objective.count);
|
|
68
|
+
const value = measure.value === null ? "?" : String(measure.value);
|
|
69
|
+
return measure.target === null ? value : `${value}→${String(measure.target)}`;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const Ladder = ({
|
|
73
|
+
campaign,
|
|
74
|
+
hover,
|
|
75
|
+
nudged,
|
|
76
|
+
onHover,
|
|
77
|
+
onPick,
|
|
78
|
+
selected,
|
|
79
|
+
}: Props): React.JSX.Element => {
|
|
80
|
+
const columns = columnsOf(campaign);
|
|
81
|
+
const focus = hover ?? selected;
|
|
82
|
+
const x = (column: number): number => X0 + column * (COL + GAP);
|
|
83
|
+
const objectivesIn = (column: Column): ReadonlyArray<ObjectiveCard> =>
|
|
84
|
+
column.kind === "standing"
|
|
85
|
+
? campaign.objectives.filter((one) => one.phase === null)
|
|
86
|
+
: column.kind === "phase"
|
|
87
|
+
? campaign.objectives.filter((one) => one.phase === campaign.phases[column.index]?.id)
|
|
88
|
+
: [];
|
|
89
|
+
const maxChips = Math.max(1, ...columns.map((column) => objectivesIn(column).length));
|
|
90
|
+
const laneY = PHASE_Y + PHASE_H + 14 + maxChips * (CHIP_H + CHIP_GAP) + 18;
|
|
91
|
+
const placed = campaign.sectors.map((sector) => ({
|
|
92
|
+
sector,
|
|
93
|
+
column: columnOfSector(campaign, columns, sector),
|
|
94
|
+
}));
|
|
95
|
+
const rowsIn = new Map<number, number>();
|
|
96
|
+
const withRows = placed.map((one) => {
|
|
97
|
+
const row = rowsIn.get(one.column) ?? 0;
|
|
98
|
+
rowsIn.set(one.column, row + 1);
|
|
99
|
+
return { ...one, row };
|
|
100
|
+
});
|
|
101
|
+
const maxRows = Math.max(1, ...rowsIn.values());
|
|
102
|
+
const laneH = maxRows * (SECTOR_H + SECTOR_GAP) + 12;
|
|
103
|
+
const width = x(columns.length) + 8;
|
|
104
|
+
const height = laneY + laneH + 24;
|
|
105
|
+
|
|
106
|
+
// What the focus relates to.
|
|
107
|
+
const focusObjective =
|
|
108
|
+
focus?.kind === "objective"
|
|
109
|
+
? campaign.objectives.find((one) => one.id === focus.id)
|
|
110
|
+
: undefined;
|
|
111
|
+
const focusPhaseIndex =
|
|
112
|
+
focus?.kind === "phase" ? campaign.phases.findIndex((one) => one.id === focus.id) : -1;
|
|
113
|
+
const focusSector =
|
|
114
|
+
focus?.kind === "sector" ? campaign.sectors.find((one) => one.name === focus.name) : undefined;
|
|
115
|
+
const sectorLit = (sector: SectorCard): boolean =>
|
|
116
|
+
focusObjective !== undefined
|
|
117
|
+
? (sector.residue[focusObjective.id] ?? sector.counts[focusObjective.id] ?? 0) > 0
|
|
118
|
+
: focusPhaseIndex !== -1
|
|
119
|
+
? sector.phase === focusPhaseIndex && !sector.done
|
|
120
|
+
: focusSector !== undefined
|
|
121
|
+
? focusSector.name === sector.name
|
|
122
|
+
: false;
|
|
123
|
+
const objectiveLit = (objective: ObjectiveCard): boolean =>
|
|
124
|
+
focusSector !== undefined
|
|
125
|
+
? (focusSector.residue[objective.id] ?? 0) > 0
|
|
126
|
+
: focusPhaseIndex !== -1
|
|
127
|
+
? objective.phase === campaign.phases[focusPhaseIndex]?.id
|
|
128
|
+
: focusObjective !== undefined
|
|
129
|
+
? focusObjective.id === objective.id
|
|
130
|
+
: false;
|
|
131
|
+
const phaseLit = (index: number): boolean =>
|
|
132
|
+
focusSector !== undefined
|
|
133
|
+
? focusSector.phase === index && !focusSector.done
|
|
134
|
+
: focusObjective !== undefined
|
|
135
|
+
? focusObjective.phase === campaign.phases[index]?.id
|
|
136
|
+
: focusPhaseIndex === index;
|
|
137
|
+
const dimmed = focus !== null && focus.kind !== "campaign";
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<svg
|
|
141
|
+
className={`ladder ${dimmed ? "focused" : ""}`}
|
|
142
|
+
width={width}
|
|
143
|
+
height={height}
|
|
144
|
+
viewBox={`0 0 ${String(width)} ${String(height)}`}
|
|
145
|
+
role="img"
|
|
146
|
+
aria-label={`Campaign ${campaign.id}: ${String(campaign.phases.length)} phases, ${String(campaign.sectors.length)} sectors`}
|
|
147
|
+
onMouseLeave={() => {
|
|
148
|
+
onHover(null);
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<defs>
|
|
152
|
+
<marker
|
|
153
|
+
id="ladder-arrow"
|
|
154
|
+
viewBox="0 0 10 10"
|
|
155
|
+
refX="9"
|
|
156
|
+
refY="5"
|
|
157
|
+
markerWidth="7"
|
|
158
|
+
markerHeight="7"
|
|
159
|
+
orient="auto-start-reverse"
|
|
160
|
+
>
|
|
161
|
+
<path d="M0,0 L10,5 L0,10 z" className="arrowhead" />
|
|
162
|
+
</marker>
|
|
163
|
+
</defs>
|
|
164
|
+
|
|
165
|
+
{columns.map((column, i) => {
|
|
166
|
+
const cx = x(i);
|
|
167
|
+
const objectives = objectivesIn(column);
|
|
168
|
+
if (column.kind === "end") {
|
|
169
|
+
const done = campaign.sectors.filter((one) => one.done).length;
|
|
170
|
+
return (
|
|
171
|
+
<g key="end" className={`column end ${campaign.complete ? "lit" : ""}`}>
|
|
172
|
+
<rect x={cx} y={PHASE_Y} width={COL} height={PHASE_H} rx="8" className="phase end" />
|
|
173
|
+
<text x={cx + 12} y={PHASE_Y + 19} className="eyebrow end-text">
|
|
174
|
+
THE END
|
|
175
|
+
</text>
|
|
176
|
+
<text x={cx + 12} y={PHASE_Y + 38} className="phase-name end-text">
|
|
177
|
+
end state
|
|
178
|
+
</text>
|
|
179
|
+
<text x={cx + 12} y={PHASE_Y + PHASE_H + 20} className="small muted">
|
|
180
|
+
{done} of {campaign.sectors.length} sectors there
|
|
181
|
+
</text>
|
|
182
|
+
</g>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
const phase = column.kind === "phase" ? campaign.phases[column.index] : undefined;
|
|
186
|
+
const lit =
|
|
187
|
+
column.kind === "phase"
|
|
188
|
+
? phaseLit(column.index)
|
|
189
|
+
: focus?.kind === "objective" && focusObjective?.phase === null;
|
|
190
|
+
const pick: Pick | null = phase === undefined ? null : { kind: "phase", id: phase.id };
|
|
191
|
+
return (
|
|
192
|
+
<g
|
|
193
|
+
key={column.kind === "phase" ? `phase:${String(column.index)}` : "standing"}
|
|
194
|
+
className={`column ${lit ? "lit" : ""}`}
|
|
195
|
+
>
|
|
196
|
+
<g
|
|
197
|
+
className={`phase-box ${phase === undefined ? "standing" : phase.defined ? "defined" : "open"} ${pick !== null && same(selected, pick) ? "selected" : ""}`}
|
|
198
|
+
onMouseEnter={() => {
|
|
199
|
+
onHover(pick);
|
|
200
|
+
}}
|
|
201
|
+
onClick={() => {
|
|
202
|
+
onPick(pick);
|
|
203
|
+
}}
|
|
204
|
+
>
|
|
205
|
+
<rect x={cx} y={PHASE_Y} width={COL} height={PHASE_H} rx="8" className="phase" />
|
|
206
|
+
<text x={cx + 12} y={PHASE_Y + 19} className="eyebrow">
|
|
207
|
+
{phase === undefined || column.kind !== "phase"
|
|
208
|
+
? "STANDING"
|
|
209
|
+
: phase.defined
|
|
210
|
+
? `PHASE ${String(column.index + 1)}`
|
|
211
|
+
: "OPEN"}
|
|
212
|
+
{phase?.attested === true ? " · ATTESTED" : ""}
|
|
213
|
+
</text>
|
|
214
|
+
<text x={cx + 12} y={PHASE_Y + 38} className="phase-name">
|
|
215
|
+
{phase === undefined ? "in window everywhere" : truncate(phase.id, 20)}
|
|
216
|
+
</text>
|
|
217
|
+
{phase !== undefined ? (
|
|
218
|
+
<text x={cx + COL - 10} y={PHASE_Y + 38} className="phase-count" textAnchor="end">
|
|
219
|
+
{phase.sectors}
|
|
220
|
+
</text>
|
|
221
|
+
) : null}
|
|
222
|
+
</g>
|
|
223
|
+
{i < columns.length - 1 ? (
|
|
224
|
+
<line
|
|
225
|
+
x1={cx + COL + 1}
|
|
226
|
+
y1={PHASE_Y + PHASE_H / 2}
|
|
227
|
+
x2={cx + COL + GAP - 1}
|
|
228
|
+
y2={PHASE_Y + PHASE_H / 2}
|
|
229
|
+
className="connector"
|
|
230
|
+
markerEnd="url(#ladder-arrow)"
|
|
231
|
+
/>
|
|
232
|
+
) : null}
|
|
233
|
+
{phase !== undefined && !phase.defined && phase.intent !== null ? (
|
|
234
|
+
<text x={cx + 12} y={PHASE_Y + PHASE_H + 20} className="intent">
|
|
235
|
+
“{truncate(phase.intent, 26)}”
|
|
236
|
+
</text>
|
|
237
|
+
) : null}
|
|
238
|
+
{objectives.map((objective, j) => {
|
|
239
|
+
const y =
|
|
240
|
+
PHASE_Y +
|
|
241
|
+
PHASE_H +
|
|
242
|
+
14 +
|
|
243
|
+
j * (CHIP_H + CHIP_GAP) +
|
|
244
|
+
(phase !== undefined && !phase.defined ? 14 : 0);
|
|
245
|
+
const objectivePick: Pick = { kind: "objective", id: objective.id };
|
|
246
|
+
return (
|
|
247
|
+
<g
|
|
248
|
+
key={objective.id}
|
|
249
|
+
className={`chip-box ${objectiveLit(objective) ? "lit" : ""} ${objective.complete ? "complete" : ""} ${same(selected, objectivePick) ? "selected" : ""} ${objective.ledgered ? "" : "unledgered"}`}
|
|
250
|
+
onMouseEnter={() => {
|
|
251
|
+
onHover(objectivePick);
|
|
252
|
+
}}
|
|
253
|
+
onClick={() => {
|
|
254
|
+
onPick(objectivePick);
|
|
255
|
+
}}
|
|
256
|
+
>
|
|
257
|
+
<rect x={cx + 4} y={y} width={COL - 8} height={CHIP_H} rx="12" className="chip" />
|
|
258
|
+
<text x={cx + 14} y={y + 16} className="chip-text mono">
|
|
259
|
+
{truncate(objective.id, 18)}
|
|
260
|
+
</text>
|
|
261
|
+
<text x={cx + COL - 14} y={y + 16} className="chip-count" textAnchor="end">
|
|
262
|
+
{scalarLabel(objective)}
|
|
263
|
+
</text>
|
|
264
|
+
<title>
|
|
265
|
+
{objective.message}
|
|
266
|
+
{objective.measure === null
|
|
267
|
+
? ` — ${String(objective.count)} holdout(s), ${String(Math.round(objective.progress * 100))}% cleared`
|
|
268
|
+
: ` — ${String(objective.measure.value ?? "?")} now, target ${String(objective.measure.target ?? "none")}`}
|
|
269
|
+
</title>
|
|
270
|
+
</g>
|
|
271
|
+
);
|
|
272
|
+
})}
|
|
273
|
+
</g>
|
|
274
|
+
);
|
|
275
|
+
})}
|
|
276
|
+
|
|
277
|
+
<line x1={X0} y1={laneY - 8} x2={width - 8} y2={laneY - 8} className="rule" />
|
|
278
|
+
<text x={X0} y={laneY - 14} className="label">
|
|
279
|
+
Sectors
|
|
280
|
+
</text>
|
|
281
|
+
<text x={X0 + 60} y={laneY - 14} className="small muted">
|
|
282
|
+
holdouts left
|
|
283
|
+
</text>
|
|
284
|
+
{columns.map((_, i) => (
|
|
285
|
+
<rect key={i} x={x(i)} y={laneY} width={COL} height={laneH} rx="8" className="lane" />
|
|
286
|
+
))}
|
|
287
|
+
{withRows.map(({ column, row, sector }) => {
|
|
288
|
+
const cx = x(column) + 4;
|
|
289
|
+
const cy = laneY + 6 + row * (SECTOR_H + SECTOR_GAP);
|
|
290
|
+
const left = Object.entries(sector.residue).reduce((sum, [, n]) => sum + n, 0);
|
|
291
|
+
const pick: Pick = { kind: "sector", name: sector.name };
|
|
292
|
+
const kind = sector.done ? "done" : sector.legacy ? "legacy" : "sector";
|
|
293
|
+
return (
|
|
294
|
+
<g
|
|
295
|
+
key={sector.name}
|
|
296
|
+
className={`sector-box ${kind} ${sectorLit(sector) ? "lit" : ""} ${same(selected, pick) ? "selected" : ""} ${nudged.has(sector.name) ? "nudged" : ""} ${sector.stalled ? "stalled" : ""}`}
|
|
297
|
+
onMouseEnter={() => {
|
|
298
|
+
onHover(pick);
|
|
299
|
+
}}
|
|
300
|
+
onClick={() => {
|
|
301
|
+
onPick(pick);
|
|
302
|
+
}}
|
|
303
|
+
>
|
|
304
|
+
<rect x={cx} y={cy} width={COL - 8} height={SECTOR_H} rx="6" className="sector" />
|
|
305
|
+
<text x={cx + 10} y={cy + 19} className="sector-name mono">
|
|
306
|
+
{truncate(sector.name, 16)}
|
|
307
|
+
</text>
|
|
308
|
+
<text x={cx + COL - 18} y={cy + 19} className="sector-count" textAnchor="end">
|
|
309
|
+
{sector.done ? "done" : sector.stalled ? `${String(left)} ⏸` : String(left)}
|
|
310
|
+
</text>
|
|
311
|
+
<title>
|
|
312
|
+
{sector.name}: {String(sector.files.length)} files
|
|
313
|
+
{sector.phaseId === null ? "" : `, at ${sector.phaseId}`}
|
|
314
|
+
{sector.stalled ? ", stalled" : ""}
|
|
315
|
+
</title>
|
|
316
|
+
</g>
|
|
317
|
+
);
|
|
318
|
+
})}
|
|
319
|
+
{campaign.sectors.length === 0 ? (
|
|
320
|
+
<text x={X0 + 12} y={laneY + 24} className="small muted">
|
|
321
|
+
no sectors
|
|
322
|
+
</text>
|
|
323
|
+
) : null}
|
|
324
|
+
</svg>
|
|
325
|
+
);
|
|
326
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// What the Campaign Browser can hold: the campaign itself, or one of its
|
|
2
|
+
// phases, objectives or sectors.
|
|
3
|
+
export type Pick =
|
|
4
|
+
| { readonly kind: "campaign" }
|
|
5
|
+
| { readonly kind: "phase"; readonly id: string }
|
|
6
|
+
| { readonly kind: "objective"; readonly id: string }
|
|
7
|
+
| { readonly kind: "sector"; readonly name: string };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
// The page's two reads and its one feed, all relative to the page: the bin
|
|
4
|
+
// and the Vite plugin answer `__goodbones/*` beside it, and a static export
|
|
5
|
+
// has them as files. Where the feed is absent the page is a snapshot.
|
|
6
|
+
|
|
7
|
+
export type Loaded<T> =
|
|
8
|
+
| { readonly state: "loading"; readonly value: T | null; readonly error: null }
|
|
9
|
+
| { readonly state: "ready"; readonly value: T; readonly error: null }
|
|
10
|
+
| { readonly state: "failed"; readonly value: T | null; readonly error: string };
|
|
11
|
+
|
|
12
|
+
const urlOf = (route: string): string => new URL(`__goodbones/${route}`, document.baseURI).href;
|
|
13
|
+
|
|
14
|
+
const errorOf = (body: unknown, status: number): string =>
|
|
15
|
+
typeof body === "object" && body !== null && "error" in body
|
|
16
|
+
? String(body.error)
|
|
17
|
+
: `HTTP ${String(status)}`;
|
|
18
|
+
|
|
19
|
+
// `version` bumps when the feed says the repository changed; the value is
|
|
20
|
+
// kept while the next one loads, so the page never blanks.
|
|
21
|
+
export const useModel = <T>(route: string, version: number): Loaded<T> => {
|
|
22
|
+
const [loaded, setLoaded] = useState<Loaded<T>>({ state: "loading", value: null, error: null });
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
let cancelled = false;
|
|
25
|
+
setLoaded((previous) => ({ state: "loading", value: previous.value, error: null }));
|
|
26
|
+
fetch(urlOf(route), { cache: "no-store" })
|
|
27
|
+
.then(async (response) => {
|
|
28
|
+
const body: unknown = await response.json();
|
|
29
|
+
if (cancelled) return;
|
|
30
|
+
if (!response.ok) {
|
|
31
|
+
setLoaded((previous) => ({
|
|
32
|
+
state: "failed",
|
|
33
|
+
value: previous.value,
|
|
34
|
+
error: errorOf(body, response.status),
|
|
35
|
+
}));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
setLoaded({ state: "ready", value: body as T, error: null });
|
|
39
|
+
})
|
|
40
|
+
.catch((cause: unknown) => {
|
|
41
|
+
if (cancelled) return;
|
|
42
|
+
setLoaded((previous) => ({ state: "failed", value: previous.value, error: String(cause) }));
|
|
43
|
+
});
|
|
44
|
+
return () => {
|
|
45
|
+
cancelled = true;
|
|
46
|
+
};
|
|
47
|
+
}, [route, version]);
|
|
48
|
+
return loaded;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type Feed = {
|
|
52
|
+
readonly live: boolean;
|
|
53
|
+
readonly version: number;
|
|
54
|
+
readonly changed: ReadonlyArray<string>;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const useFeed = (): Feed => {
|
|
58
|
+
const [feed, setFeed] = useState<Feed>({ live: false, version: 0, changed: [] });
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (typeof EventSource === "undefined") return undefined;
|
|
61
|
+
const events = new EventSource(urlOf("events"));
|
|
62
|
+
events.addEventListener("hello", () => {
|
|
63
|
+
setFeed((previous) => ({ ...previous, live: true }));
|
|
64
|
+
});
|
|
65
|
+
events.addEventListener("changed", (event) => {
|
|
66
|
+
let files: ReadonlyArray<string> = [];
|
|
67
|
+
try {
|
|
68
|
+
const data: unknown = JSON.parse((event as MessageEvent<string>).data);
|
|
69
|
+
if (
|
|
70
|
+
typeof data === "object" &&
|
|
71
|
+
data !== null &&
|
|
72
|
+
"files" in data &&
|
|
73
|
+
Array.isArray(data.files)
|
|
74
|
+
) {
|
|
75
|
+
files = data.files.map(String);
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
files = [];
|
|
79
|
+
}
|
|
80
|
+
setFeed((previous) => ({ live: true, version: previous.version + 1, changed: files }));
|
|
81
|
+
});
|
|
82
|
+
events.addEventListener("error", () => {
|
|
83
|
+
// A static export has no feed: the first error is the last.
|
|
84
|
+
if (events.readyState === EventSource.CLOSED) {
|
|
85
|
+
setFeed((previous) => ({ ...previous, live: false }));
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
return () => {
|
|
89
|
+
events.close();
|
|
90
|
+
};
|
|
91
|
+
}, []);
|
|
92
|
+
return feed;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// The page's route, in the hash: `#architecture` or `#campaigns`, with a
|
|
96
|
+
// selection after a slash (`#architecture/packages/core/src/index.ts`).
|
|
97
|
+
export type Route = {
|
|
98
|
+
readonly tab: "architecture" | "campaigns";
|
|
99
|
+
readonly selection: string | null;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const routeOf = (hash: string): Route => {
|
|
103
|
+
const trimmed = hash.replace(/^#/, "");
|
|
104
|
+
const at = trimmed.indexOf("/");
|
|
105
|
+
const tab = at === -1 ? trimmed : trimmed.slice(0, at);
|
|
106
|
+
const selection = at === -1 ? null : decodeURIComponent(trimmed.slice(at + 1));
|
|
107
|
+
return { tab: tab === "campaigns" ? "campaigns" : "architecture", selection };
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const hashOf = (route: Route): string =>
|
|
111
|
+
`#${route.tab}${route.selection === null ? "" : `/${encodeURIComponent(route.selection)}`}`;
|
|
112
|
+
|
|
113
|
+
export const useRoute = (): readonly [Route, (route: Route) => void] => {
|
|
114
|
+
const [route, setRoute] = useState<Route>(() => routeOf(window.location.hash));
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const onChange = (): void => {
|
|
117
|
+
setRoute(routeOf(window.location.hash));
|
|
118
|
+
};
|
|
119
|
+
window.addEventListener("hashchange", onChange);
|
|
120
|
+
return () => {
|
|
121
|
+
window.removeEventListener("hashchange", onChange);
|
|
122
|
+
};
|
|
123
|
+
}, []);
|
|
124
|
+
return [
|
|
125
|
+
route,
|
|
126
|
+
(next) => {
|
|
127
|
+
const hash = hashOf(next);
|
|
128
|
+
if (window.location.hash !== hash) window.history.replaceState(null, "", hash);
|
|
129
|
+
setRoute(next);
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
import "../styles/theme.css";
|
|
3
|
+
|
|
4
|
+
import { Browser } from "../components/Browser.js";
|
|
5
|
+
|
|
6
|
+
// The one page: both browsers as a React island rendered in the browser only,
|
|
7
|
+
// since everything they draw comes from `__goodbones/*` beside this file.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
<!doctype html>
|
|
11
|
+
<html lang="en" data-theme="dark">
|
|
12
|
+
<head>
|
|
13
|
+
<meta charset="utf-8" />
|
|
14
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
15
|
+
<title>goodbones browser</title>
|
|
16
|
+
<meta
|
|
17
|
+
name="description"
|
|
18
|
+
content="The Architecture Browser and the Campaign Browser: a repository's tree with its architecture.yaml laid over it, and every campaign's phases, sectors and holdouts."
|
|
19
|
+
/>
|
|
20
|
+
<link rel="icon" href="favicon.svg" type="image/svg+xml" />
|
|
21
|
+
<script is:inline>
|
|
22
|
+
try {
|
|
23
|
+
const held = window.localStorage.getItem("goodbones-theme");
|
|
24
|
+
if (held === "light" || held === "dark") document.documentElement.dataset.theme = held;
|
|
25
|
+
else if (window.matchMedia("(prefers-color-scheme: light)").matches)
|
|
26
|
+
document.documentElement.dataset.theme = "light";
|
|
27
|
+
} catch {}
|
|
28
|
+
</script>
|
|
29
|
+
</head>
|
|
30
|
+
<body>
|
|
31
|
+
<Browser client:only="react" />
|
|
32
|
+
<noscript>The goodbones browser needs JavaScript.</noscript>
|
|
33
|
+
</body>
|
|
34
|
+
</html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#141311"/><path d="M8 8h6v6H8zM18 8h6v6h-6zM8 18h6v6H8zM18 18h6v6h-6z" fill="#e0714a"/><path d="M14 11h4M11 14v4M21 14v4M14 21h4" stroke="#f6f2ea" stroke-width="1.5"/></svg>
|