@miadi/episode-ui 0.1.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/LICENSE +21 -0
- package/README.md +69 -0
- package/dist/ceremony.d.ts +48 -0
- package/dist/ceremony.d.ts.map +1 -0
- package/dist/ceremony.js +52 -0
- package/dist/ceremony.js.map +1 -0
- package/dist/file-browser.d.ts +29 -0
- package/dist/file-browser.d.ts.map +1 -0
- package/dist/file-browser.js +38 -0
- package/dist/file-browser.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/shelf-filter.d.ts +22 -0
- package/dist/shelf-filter.d.ts.map +1 -0
- package/dist/shelf-filter.js +34 -0
- package/dist/shelf-filter.js.map +1 -0
- package/dist/text-editor.d.ts +62 -0
- package/dist/text-editor.d.ts.map +1 -0
- package/dist/text-editor.js +88 -0
- package/dist/text-editor.js.map +1 -0
- package/dist/types.d.ts +78 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +63 -0
- package/dist/types.js.map +1 -0
- package/package.json +70 -0
- package/src/ceremony.tsx +185 -0
- package/src/episode-ui.css +400 -0
- package/src/file-browser.tsx +154 -0
- package/src/index.ts +49 -0
- package/src/shelf-filter.tsx +98 -0
- package/src/text-editor.tsx +230 -0
- package/src/types.ts +125 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One stylesheet, plain class names, every colour a custom property the host
|
|
3
|
+
* sets through `themeVars(theme)`. A surface that wants a different look
|
|
4
|
+
* overrides the properties or the classes; nothing here is !important and
|
|
5
|
+
* nothing assumes a framework.
|
|
6
|
+
*
|
|
7
|
+
* Touch targets are 40px because the first surface this runs on is a phone.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
.episode-file-browser,
|
|
11
|
+
.episode-text-editor,
|
|
12
|
+
.episode-text-viewer,
|
|
13
|
+
.ceremony-note-editor,
|
|
14
|
+
.ceremony-attachments,
|
|
15
|
+
.episode-shelf {
|
|
16
|
+
color: var(--episode-text);
|
|
17
|
+
font-size: 13px;
|
|
18
|
+
line-height: 1.5;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* ---------- file browser ---------- */
|
|
22
|
+
|
|
23
|
+
.episode-file-browser-head {
|
|
24
|
+
display: flex;
|
|
25
|
+
align-items: baseline;
|
|
26
|
+
justify-content: space-between;
|
|
27
|
+
gap: 10px;
|
|
28
|
+
margin-bottom: 8px;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.episode-file-browser-title {
|
|
32
|
+
margin: 0;
|
|
33
|
+
font-size: 13px;
|
|
34
|
+
font-weight: 800;
|
|
35
|
+
letter-spacing: 0.02em;
|
|
36
|
+
text-transform: uppercase;
|
|
37
|
+
color: var(--episode-text-dim);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.episode-file-browser-count,
|
|
41
|
+
.episode-shelf-count {
|
|
42
|
+
font-family: var(--episode-mono);
|
|
43
|
+
font-size: 11px;
|
|
44
|
+
color: var(--episode-muted);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.episode-file-browser-filter,
|
|
48
|
+
.episode-shelf-filter {
|
|
49
|
+
width: 100%;
|
|
50
|
+
min-height: 40px;
|
|
51
|
+
padding: 8px 10px;
|
|
52
|
+
margin-bottom: 8px;
|
|
53
|
+
background: var(--episode-bg);
|
|
54
|
+
color: var(--episode-text);
|
|
55
|
+
border: 1px solid var(--episode-border);
|
|
56
|
+
border-radius: 8px;
|
|
57
|
+
font: inherit;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.episode-file-browser-list,
|
|
61
|
+
.episode-shelf-list,
|
|
62
|
+
.ceremony-attachments-list {
|
|
63
|
+
list-style: none;
|
|
64
|
+
margin: 0;
|
|
65
|
+
padding: 0;
|
|
66
|
+
display: flex;
|
|
67
|
+
flex-direction: column;
|
|
68
|
+
gap: 2px;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.episode-file-row {
|
|
72
|
+
display: flex;
|
|
73
|
+
align-items: center;
|
|
74
|
+
gap: 6px;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.episode-file-open {
|
|
78
|
+
flex: 1;
|
|
79
|
+
display: flex;
|
|
80
|
+
align-items: center;
|
|
81
|
+
gap: 9px;
|
|
82
|
+
min-height: 40px;
|
|
83
|
+
padding: 6px 9px;
|
|
84
|
+
background: transparent;
|
|
85
|
+
color: inherit;
|
|
86
|
+
border: 1px solid transparent;
|
|
87
|
+
border-radius: 7px;
|
|
88
|
+
font: inherit;
|
|
89
|
+
text-align: left;
|
|
90
|
+
cursor: pointer;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.episode-file-open:hover:not(:disabled),
|
|
94
|
+
.episode-shelf-entry:hover {
|
|
95
|
+
background: var(--episode-card);
|
|
96
|
+
border-color: var(--episode-border);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.episode-file-open:disabled {
|
|
100
|
+
cursor: default;
|
|
101
|
+
opacity: 0.55;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.episode-file-row.is-selected .episode-file-open {
|
|
105
|
+
background: var(--episode-card);
|
|
106
|
+
border-color: var(--episode-accent);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.episode-file-row.is-guidance .episode-file-name {
|
|
110
|
+
color: var(--episode-muted);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.episode-file-icon {
|
|
114
|
+
flex: none;
|
|
115
|
+
font-size: 14px;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.episode-file-name {
|
|
119
|
+
flex: 1;
|
|
120
|
+
overflow-wrap: anywhere;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.episode-file-meta,
|
|
124
|
+
.ceremony-attachment-meta {
|
|
125
|
+
flex: none;
|
|
126
|
+
font-family: var(--episode-mono);
|
|
127
|
+
font-size: 10.5px;
|
|
128
|
+
color: var(--episode-muted);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.episode-file-attach,
|
|
132
|
+
.ceremony-attachment-detach,
|
|
133
|
+
.episode-text-viewer-toggle {
|
|
134
|
+
min-height: 40px;
|
|
135
|
+
padding: 4px 10px;
|
|
136
|
+
background: transparent;
|
|
137
|
+
color: var(--episode-text-dim);
|
|
138
|
+
border: 1px solid var(--episode-border);
|
|
139
|
+
border-radius: 999px;
|
|
140
|
+
font-family: var(--episode-mono);
|
|
141
|
+
font-size: 10.5px;
|
|
142
|
+
cursor: pointer;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.episode-file-attach[aria-pressed="true"] {
|
|
146
|
+
color: var(--episode-accent);
|
|
147
|
+
border-color: var(--episode-accent);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.episode-file-browser-empty,
|
|
151
|
+
.ceremony-attachments-empty,
|
|
152
|
+
.episode-file-browser-notice {
|
|
153
|
+
margin: 6px 0 0;
|
|
154
|
+
color: var(--episode-muted);
|
|
155
|
+
font-size: 12px;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* ---------- text editor and viewer ---------- */
|
|
159
|
+
|
|
160
|
+
.episode-text-editor,
|
|
161
|
+
.episode-text-viewer {
|
|
162
|
+
border: 1px solid var(--episode-border);
|
|
163
|
+
border-radius: 10px;
|
|
164
|
+
background: var(--episode-card);
|
|
165
|
+
padding: 10px 12px;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.episode-text-editor-head,
|
|
169
|
+
.episode-text-viewer-head,
|
|
170
|
+
.ceremony-note-head {
|
|
171
|
+
display: flex;
|
|
172
|
+
align-items: center;
|
|
173
|
+
justify-content: space-between;
|
|
174
|
+
gap: 10px;
|
|
175
|
+
margin-bottom: 8px;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.episode-text-editor-file,
|
|
179
|
+
.episode-text-viewer-file {
|
|
180
|
+
font-family: var(--episode-mono);
|
|
181
|
+
font-size: 11.5px;
|
|
182
|
+
color: var(--episode-text-dim);
|
|
183
|
+
overflow-wrap: anywhere;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.episode-text-editor-state {
|
|
187
|
+
flex: none;
|
|
188
|
+
font-family: var(--episode-mono);
|
|
189
|
+
font-size: 10.5px;
|
|
190
|
+
color: var(--episode-muted);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.episode-text-editor-state.is-saved {
|
|
194
|
+
color: var(--episode-success);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.episode-text-editor-state.is-conflict,
|
|
198
|
+
.episode-text-editor-state.is-error {
|
|
199
|
+
color: var(--episode-warning);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.episode-text-editor-message {
|
|
203
|
+
margin: 0 0 8px;
|
|
204
|
+
padding: 8px 10px;
|
|
205
|
+
border-radius: 8px;
|
|
206
|
+
font-size: 12px;
|
|
207
|
+
background: var(--episode-bg);
|
|
208
|
+
border: 1px solid var(--episode-border);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.episode-text-editor-message.is-conflict,
|
|
212
|
+
.episode-text-editor-message.is-error {
|
|
213
|
+
border-color: var(--episode-warning);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.episode-text-editor-their-revision {
|
|
217
|
+
color: var(--episode-muted);
|
|
218
|
+
font-family: var(--episode-mono);
|
|
219
|
+
font-size: 11px;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.episode-text-editor-field,
|
|
223
|
+
.ceremony-note-field {
|
|
224
|
+
width: 100%;
|
|
225
|
+
min-height: 220px;
|
|
226
|
+
padding: 10px;
|
|
227
|
+
background: var(--episode-bg);
|
|
228
|
+
color: var(--episode-text);
|
|
229
|
+
border: 1px solid var(--episode-border);
|
|
230
|
+
border-radius: 8px;
|
|
231
|
+
font-family: var(--episode-mono);
|
|
232
|
+
font-size: 12.5px;
|
|
233
|
+
line-height: 1.6;
|
|
234
|
+
resize: vertical;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.episode-text-editor-actions,
|
|
238
|
+
.ceremony-note-actions {
|
|
239
|
+
display: flex;
|
|
240
|
+
align-items: center;
|
|
241
|
+
gap: 10px;
|
|
242
|
+
margin-top: 8px;
|
|
243
|
+
flex-wrap: wrap;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.episode-text-editor-actions button,
|
|
247
|
+
.ceremony-note-actions button {
|
|
248
|
+
min-height: 40px;
|
|
249
|
+
padding: 6px 14px;
|
|
250
|
+
background: var(--episode-accent);
|
|
251
|
+
color: #1a1a1a;
|
|
252
|
+
border: none;
|
|
253
|
+
border-radius: 8px;
|
|
254
|
+
font: inherit;
|
|
255
|
+
font-weight: 700;
|
|
256
|
+
cursor: pointer;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.episode-text-editor-actions button:disabled,
|
|
260
|
+
.ceremony-note-actions button:disabled {
|
|
261
|
+
opacity: 0.45;
|
|
262
|
+
cursor: default;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.episode-text-editor-reload {
|
|
266
|
+
background: transparent !important;
|
|
267
|
+
color: var(--episode-warning) !important;
|
|
268
|
+
border: 1px solid var(--episode-warning) !important;
|
|
269
|
+
font-weight: 400 !important;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.episode-text-viewer-source {
|
|
273
|
+
margin: 0;
|
|
274
|
+
max-height: 60vh;
|
|
275
|
+
overflow: auto;
|
|
276
|
+
white-space: pre-wrap;
|
|
277
|
+
overflow-wrap: anywhere;
|
|
278
|
+
font-family: var(--episode-mono);
|
|
279
|
+
font-size: 12.5px;
|
|
280
|
+
color: var(--episode-text-dim);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.episode-text-viewer-rich {
|
|
284
|
+
font-size: 14px;
|
|
285
|
+
line-height: 1.65;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/* ---------- ceremony ---------- */
|
|
289
|
+
|
|
290
|
+
.ceremony-note-label {
|
|
291
|
+
font-size: 12px;
|
|
292
|
+
font-weight: 700;
|
|
293
|
+
color: var(--episode-text-dim);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.ceremony-note-state {
|
|
297
|
+
font-family: var(--episode-mono);
|
|
298
|
+
font-size: 10.5px;
|
|
299
|
+
color: var(--episode-muted);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.ceremony-note-state.is-saved {
|
|
303
|
+
color: var(--episode-success);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.ceremony-note-message.is-error {
|
|
307
|
+
color: var(--episode-warning);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.ceremony-attachments {
|
|
311
|
+
margin-top: 10px;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.ceremony-attachments-title {
|
|
315
|
+
display: block;
|
|
316
|
+
font-size: 11px;
|
|
317
|
+
font-weight: 800;
|
|
318
|
+
letter-spacing: 0.03em;
|
|
319
|
+
text-transform: uppercase;
|
|
320
|
+
color: var(--episode-muted);
|
|
321
|
+
margin-bottom: 5px;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.ceremony-attachment {
|
|
325
|
+
display: flex;
|
|
326
|
+
align-items: center;
|
|
327
|
+
gap: 8px;
|
|
328
|
+
flex-wrap: wrap;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
.ceremony-attachment-open {
|
|
332
|
+
min-height: 40px;
|
|
333
|
+
padding: 2px 0;
|
|
334
|
+
background: none;
|
|
335
|
+
border: none;
|
|
336
|
+
color: var(--episode-accent);
|
|
337
|
+
font-family: var(--episode-mono);
|
|
338
|
+
font-size: 12px;
|
|
339
|
+
text-align: left;
|
|
340
|
+
cursor: pointer;
|
|
341
|
+
overflow-wrap: anywhere;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.ceremony-attachment-open:disabled {
|
|
345
|
+
color: var(--episode-text-dim);
|
|
346
|
+
cursor: default;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
.ceremony-attachment-note {
|
|
350
|
+
width: 100%;
|
|
351
|
+
color: var(--episode-text-dim);
|
|
352
|
+
font-size: 12px;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/* ---------- shelf ---------- */
|
|
356
|
+
|
|
357
|
+
.episode-shelf-entry {
|
|
358
|
+
display: flex;
|
|
359
|
+
align-items: flex-start;
|
|
360
|
+
gap: 10px;
|
|
361
|
+
width: 100%;
|
|
362
|
+
min-height: 40px;
|
|
363
|
+
padding: 8px 10px;
|
|
364
|
+
background: transparent;
|
|
365
|
+
color: inherit;
|
|
366
|
+
border: 1px solid transparent;
|
|
367
|
+
border-radius: 8px;
|
|
368
|
+
font: inherit;
|
|
369
|
+
text-align: left;
|
|
370
|
+
cursor: pointer;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.episode-shelf-number {
|
|
374
|
+
flex: none;
|
|
375
|
+
font-family: var(--episode-mono);
|
|
376
|
+
font-weight: 700;
|
|
377
|
+
color: var(--episode-accent);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
.episode-shelf-body {
|
|
381
|
+
display: flex;
|
|
382
|
+
flex-direction: column;
|
|
383
|
+
gap: 2px;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
.episode-shelf-body small {
|
|
387
|
+
font-family: var(--episode-mono);
|
|
388
|
+
font-size: 10.5px;
|
|
389
|
+
color: var(--episode-muted);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
.episode-file-open:focus-visible,
|
|
393
|
+
.episode-file-attach:focus-visible,
|
|
394
|
+
.episode-shelf-entry:focus-visible,
|
|
395
|
+
.ceremony-attachment-open:focus-visible,
|
|
396
|
+
.episode-text-editor-actions button:focus-visible,
|
|
397
|
+
.ceremony-note-actions button:focus-visible {
|
|
398
|
+
outline: 2px solid var(--episode-accent);
|
|
399
|
+
outline-offset: 2px;
|
|
400
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from "react"
|
|
4
|
+
import type { CSSProperties } from "react"
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_THEME,
|
|
7
|
+
formatBytes,
|
|
8
|
+
iconFor,
|
|
9
|
+
themeVars,
|
|
10
|
+
type EpisodeCapabilitiesView,
|
|
11
|
+
type EpisodeFileView,
|
|
12
|
+
type EpisodeUITheme,
|
|
13
|
+
} from "./types.js"
|
|
14
|
+
|
|
15
|
+
export interface EpisodeFileBrowserProps {
|
|
16
|
+
files: EpisodeFileView[]
|
|
17
|
+
capabilities: EpisodeCapabilitiesView
|
|
18
|
+
/** The file currently open, by `relativePath`. */
|
|
19
|
+
selected?: string | null
|
|
20
|
+
onSelect?: (file: EpisodeFileView) => void
|
|
21
|
+
/** Offered per file when the caller can act on a ceremony. */
|
|
22
|
+
onAttach?: (file: EpisodeFileView) => void
|
|
23
|
+
/** Already attached to the ceremony in context, by `relativePath`. */
|
|
24
|
+
attached?: string[]
|
|
25
|
+
theme?: EpisodeUITheme
|
|
26
|
+
title?: string
|
|
27
|
+
/** Shown when the vessel holds no listable file. */
|
|
28
|
+
emptyLabel?: string
|
|
29
|
+
className?: string
|
|
30
|
+
style?: CSSProperties
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The files in one vessel, as a list a person can act on.
|
|
35
|
+
*
|
|
36
|
+
* It fetches nothing. The host lists the files and says what this person may
|
|
37
|
+
* do; the browser decides only what to show and raises intent. A capability
|
|
38
|
+
* that is absent removes its control rather than disabling it — a button a
|
|
39
|
+
* person can never press is noise, not information.
|
|
40
|
+
*/
|
|
41
|
+
export function EpisodeFileBrowser({
|
|
42
|
+
files,
|
|
43
|
+
capabilities,
|
|
44
|
+
selected = null,
|
|
45
|
+
onSelect,
|
|
46
|
+
onAttach,
|
|
47
|
+
attached = [],
|
|
48
|
+
theme = DEFAULT_THEME,
|
|
49
|
+
title = "Files in this episode",
|
|
50
|
+
emptyLabel = "This episode folder holds no readable file yet.",
|
|
51
|
+
className,
|
|
52
|
+
style,
|
|
53
|
+
}: EpisodeFileBrowserProps) {
|
|
54
|
+
const [query, setQuery] = useState("")
|
|
55
|
+
|
|
56
|
+
const shown = useMemo(() => {
|
|
57
|
+
const needle = query.trim().toLowerCase()
|
|
58
|
+
if (!needle) return files
|
|
59
|
+
return files.filter((file) => file.relativePath.toLowerCase().includes(needle))
|
|
60
|
+
}, [files, query])
|
|
61
|
+
|
|
62
|
+
if (!capabilities.browse) return null
|
|
63
|
+
|
|
64
|
+
const attachedSet = new Set(attached)
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<section
|
|
68
|
+
className={["episode-file-browser", className].filter(Boolean).join(" ")}
|
|
69
|
+
style={{ ...themeVars(theme), ...style } as CSSProperties}
|
|
70
|
+
aria-label={title}
|
|
71
|
+
>
|
|
72
|
+
<header className="episode-file-browser-head">
|
|
73
|
+
<h3 className="episode-file-browser-title">{title}</h3>
|
|
74
|
+
<span className="episode-file-browser-count">
|
|
75
|
+
{files.length} {files.length === 1 ? "file" : "files"}
|
|
76
|
+
</span>
|
|
77
|
+
</header>
|
|
78
|
+
|
|
79
|
+
{files.length > 6 && (
|
|
80
|
+
<input
|
|
81
|
+
className="episode-file-browser-filter"
|
|
82
|
+
type="search"
|
|
83
|
+
value={query}
|
|
84
|
+
placeholder="Filter by name"
|
|
85
|
+
aria-label="Filter files"
|
|
86
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
87
|
+
/>
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
{files.length === 0 ? (
|
|
91
|
+
<p className="episode-file-browser-empty">{emptyLabel}</p>
|
|
92
|
+
) : (
|
|
93
|
+
<ul className="episode-file-browser-list">
|
|
94
|
+
{shown.map((file) => {
|
|
95
|
+
const isSelected = file.relativePath === selected
|
|
96
|
+
const canOpen = capabilities.read && (file.previewable || file.kind !== "text")
|
|
97
|
+
return (
|
|
98
|
+
<li
|
|
99
|
+
key={file.relativePath}
|
|
100
|
+
className={[
|
|
101
|
+
"episode-file-row",
|
|
102
|
+
isSelected ? "is-selected" : "",
|
|
103
|
+
file.guidance ? "is-guidance" : "",
|
|
104
|
+
]
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.join(" ")}
|
|
107
|
+
>
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
className="episode-file-open"
|
|
111
|
+
disabled={!canOpen}
|
|
112
|
+
aria-current={isSelected ? "true" : undefined}
|
|
113
|
+
onClick={() => onSelect?.(file)}
|
|
114
|
+
>
|
|
115
|
+
<span className="episode-file-icon" aria-hidden="true">
|
|
116
|
+
{iconFor(file)}
|
|
117
|
+
</span>
|
|
118
|
+
<span className="episode-file-name">{file.relativePath}</span>
|
|
119
|
+
<span className="episode-file-meta">
|
|
120
|
+
{formatBytes(file.size)}
|
|
121
|
+
{file.editable && capabilities.edit ? " · editable" : ""}
|
|
122
|
+
{file.kind === "document" ? " · download" : ""}
|
|
123
|
+
{file.guidance ? " · guidance" : ""}
|
|
124
|
+
</span>
|
|
125
|
+
</button>
|
|
126
|
+
{onAttach && capabilities.attachToCeremony && (
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
className="episode-file-attach"
|
|
130
|
+
aria-pressed={attachedSet.has(file.relativePath)}
|
|
131
|
+
title={
|
|
132
|
+
attachedSet.has(file.relativePath)
|
|
133
|
+
? "On this ceremony's table"
|
|
134
|
+
: "Name this file as being on the ceremony's table"
|
|
135
|
+
}
|
|
136
|
+
onClick={() => onAttach(file)}
|
|
137
|
+
>
|
|
138
|
+
{attachedSet.has(file.relativePath) ? "on the table" : "attach"}
|
|
139
|
+
</button>
|
|
140
|
+
)}
|
|
141
|
+
</li>
|
|
142
|
+
)
|
|
143
|
+
})}
|
|
144
|
+
</ul>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{!capabilities.read && (
|
|
148
|
+
<p className="episode-file-browser-notice">
|
|
149
|
+
Sign in to read these files. What is here is listed for everyone; what it says is not.
|
|
150
|
+
</p>
|
|
151
|
+
)}
|
|
152
|
+
</section>
|
|
153
|
+
)
|
|
154
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @miadi/episode-ui — the episode-vessel experience, as React.
|
|
3
|
+
*
|
|
4
|
+
* gmtermux proved this experience on a phone in vanilla JS
|
|
5
|
+
* (`web/lib/file-browser.js`, `web/lib/fullscreen-editor.js`,
|
|
6
|
+
* `web/lib/episode-shelf-filter.js`) where no React surface could reach it. The
|
|
7
|
+
* Miadi episode room meanwhile rendered its files as text it could not open.
|
|
8
|
+
* These components are that experience in a form both can hold.
|
|
9
|
+
*
|
|
10
|
+
* The contract, in one line: **data and callbacks in, intent out.** No
|
|
11
|
+
* component fetches, holds a URL or token, knows a chronicle root, or decides
|
|
12
|
+
* what a person is allowed to do. The host resolves capabilities (see
|
|
13
|
+
* `@miadi/episode-vessel/permissions`) and remains the authority; a capability
|
|
14
|
+
* here decides only what is offered.
|
|
15
|
+
*
|
|
16
|
+
* Styles are one stylesheet of plain classes over CSS custom properties:
|
|
17
|
+
* `import "@miadi/episode-ui/styles.css"`, or write your own against the same
|
|
18
|
+
* class names.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export { EpisodeFileBrowser, type EpisodeFileBrowserProps } from "./file-browser.js"
|
|
22
|
+
export {
|
|
23
|
+
EpisodeTextEditor,
|
|
24
|
+
EpisodeTextViewer,
|
|
25
|
+
type EpisodeTextEditorProps,
|
|
26
|
+
type EpisodeTextViewerProps,
|
|
27
|
+
type SaveOutcome,
|
|
28
|
+
} from "./text-editor.js"
|
|
29
|
+
export {
|
|
30
|
+
CeremonyNoteEditor,
|
|
31
|
+
CeremonyAttachments,
|
|
32
|
+
type CeremonyNoteEditorProps,
|
|
33
|
+
type CeremonyAttachmentsProps,
|
|
34
|
+
} from "./ceremony.js"
|
|
35
|
+
export { EpisodeShelfFilter, type EpisodeShelfFilterProps } from "./shelf-filter.js"
|
|
36
|
+
export {
|
|
37
|
+
DEFAULT_THEME,
|
|
38
|
+
themeVars,
|
|
39
|
+
formatBytes,
|
|
40
|
+
iconFor,
|
|
41
|
+
type FileKind,
|
|
42
|
+
type EpisodeFileView,
|
|
43
|
+
type FileRevisionView,
|
|
44
|
+
type EpisodeTextView,
|
|
45
|
+
type EpisodeCapabilitiesView,
|
|
46
|
+
type CeremonyAttachmentView,
|
|
47
|
+
type EpisodeShelfEntry,
|
|
48
|
+
type EpisodeUITheme,
|
|
49
|
+
} from "./types.js"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from "react"
|
|
4
|
+
import type { CSSProperties } from "react"
|
|
5
|
+
import { DEFAULT_THEME, themeVars, type EpisodeShelfEntry, type EpisodeUITheme } from "./types.js"
|
|
6
|
+
|
|
7
|
+
export interface EpisodeShelfFilterProps {
|
|
8
|
+
episodes: EpisodeShelfEntry[]
|
|
9
|
+
selected?: string | null
|
|
10
|
+
onSelect?: (episode: EpisodeShelfEntry) => void
|
|
11
|
+
/** Render each entry yourself when the host has its own card. */
|
|
12
|
+
renderEntry?: (episode: EpisodeShelfEntry, selected: boolean) => React.ReactNode
|
|
13
|
+
placeholder?: string
|
|
14
|
+
theme?: EpisodeUITheme
|
|
15
|
+
className?: string
|
|
16
|
+
style?: CSSProperties
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Find an episode by number, title or slug.
|
|
21
|
+
*
|
|
22
|
+
* `349`, `ep349`, `episode 349` and a word from the title all reach the same
|
|
23
|
+
* vessel — on a phone, typing the number is the fastest way there, and it was
|
|
24
|
+
* the first thing gmtermux's shelf learned.
|
|
25
|
+
*/
|
|
26
|
+
export function EpisodeShelfFilter({
|
|
27
|
+
episodes,
|
|
28
|
+
selected = null,
|
|
29
|
+
onSelect,
|
|
30
|
+
renderEntry,
|
|
31
|
+
placeholder = "Find an episode by number or title",
|
|
32
|
+
theme = DEFAULT_THEME,
|
|
33
|
+
className,
|
|
34
|
+
style,
|
|
35
|
+
}: EpisodeShelfFilterProps) {
|
|
36
|
+
const [query, setQuery] = useState("")
|
|
37
|
+
|
|
38
|
+
const shown = useMemo(() => {
|
|
39
|
+
const needle = query.trim().toLowerCase().replace(/^(?:ep(?:isode)?\s*)/, "")
|
|
40
|
+
if (!needle) return episodes
|
|
41
|
+
return episodes.filter((episode) =>
|
|
42
|
+
[
|
|
43
|
+
String(episode.number),
|
|
44
|
+
episode.numberLabel,
|
|
45
|
+
episode.title,
|
|
46
|
+
episode.episodePath,
|
|
47
|
+
episode.goal ?? "",
|
|
48
|
+
]
|
|
49
|
+
.join(" ")
|
|
50
|
+
.toLowerCase()
|
|
51
|
+
.includes(needle),
|
|
52
|
+
)
|
|
53
|
+
}, [episodes, query])
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
className={["episode-shelf", className].filter(Boolean).join(" ")}
|
|
58
|
+
style={{ ...themeVars(theme), ...style } as CSSProperties}
|
|
59
|
+
>
|
|
60
|
+
<input
|
|
61
|
+
className="episode-shelf-filter"
|
|
62
|
+
type="search"
|
|
63
|
+
value={query}
|
|
64
|
+
placeholder={placeholder}
|
|
65
|
+
aria-label={placeholder}
|
|
66
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
67
|
+
/>
|
|
68
|
+
<span className="episode-shelf-count">
|
|
69
|
+
{shown.length} of {episodes.length}
|
|
70
|
+
</span>
|
|
71
|
+
<ul className="episode-shelf-list">
|
|
72
|
+
{shown.map((episode) => {
|
|
73
|
+
const isSelected = episode.episodePath === selected
|
|
74
|
+
return (
|
|
75
|
+
<li key={episode.episodePath} className={isSelected ? "is-selected" : undefined}>
|
|
76
|
+
{renderEntry ? (
|
|
77
|
+
renderEntry(episode, isSelected)
|
|
78
|
+
) : (
|
|
79
|
+
<button type="button" className="episode-shelf-entry" onClick={() => onSelect?.(episode)}>
|
|
80
|
+
<span className="episode-shelf-number">{episode.numberLabel}</span>
|
|
81
|
+
<span className="episode-shelf-body">
|
|
82
|
+
<strong>{episode.title}</strong>
|
|
83
|
+
{episode.goal && <span>{episode.goal}</span>}
|
|
84
|
+
<small>
|
|
85
|
+
{episode.date}
|
|
86
|
+
{episode.status ? ` · ${episode.status}` : ""}
|
|
87
|
+
{episode.registered === false ? " · vessel only" : ""}
|
|
88
|
+
</small>
|
|
89
|
+
</span>
|
|
90
|
+
</button>
|
|
91
|
+
)}
|
|
92
|
+
</li>
|
|
93
|
+
)
|
|
94
|
+
})}
|
|
95
|
+
</ul>
|
|
96
|
+
</div>
|
|
97
|
+
)
|
|
98
|
+
}
|