@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,230 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react"
|
|
4
|
+
import type { CSSProperties, ReactNode } from "react"
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_THEME,
|
|
7
|
+
themeVars,
|
|
8
|
+
type EpisodeTextView,
|
|
9
|
+
type EpisodeUITheme,
|
|
10
|
+
type FileRevisionView,
|
|
11
|
+
} from "./types.js"
|
|
12
|
+
|
|
13
|
+
export interface SaveOutcome {
|
|
14
|
+
ok: boolean
|
|
15
|
+
/** Present when the file moved under the writer. */
|
|
16
|
+
conflict?: { currentRevision: FileRevisionView; currentContent?: string }
|
|
17
|
+
message?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface EpisodeTextEditorProps {
|
|
21
|
+
/** Vessel-relative path, shown as the editor's identity. */
|
|
22
|
+
file: string
|
|
23
|
+
/** What the host read. `revision.sha256` is the guard carried into the save. */
|
|
24
|
+
loaded: EpisodeTextView
|
|
25
|
+
/**
|
|
26
|
+
* Save. Resolve `{ ok: true }` when it landed, or `{ ok: false, conflict }`
|
|
27
|
+
* when the vessel refused with 409. Throwing is also handled โ the draft is
|
|
28
|
+
* kept either way.
|
|
29
|
+
*/
|
|
30
|
+
onSave: (content: string, expectedRevision: string) => Promise<SaveOutcome>
|
|
31
|
+
/** Re-read the file. Called when a person chooses to reload after a conflict. */
|
|
32
|
+
onReload?: () => void
|
|
33
|
+
readOnly?: boolean
|
|
34
|
+
theme?: EpisodeUITheme
|
|
35
|
+
className?: string
|
|
36
|
+
style?: CSSProperties
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Edit one text artefact of a vessel.
|
|
41
|
+
*
|
|
42
|
+
* The whole point of this component is the conflict path. When someone else
|
|
43
|
+
* writes the file between load and save, the vessel refuses and hands back the
|
|
44
|
+
* revision on disk. This editor then:
|
|
45
|
+
*
|
|
46
|
+
* - keeps the draft in the textarea, always, and says so;
|
|
47
|
+
* - shows what is on disk now rather than a status code;
|
|
48
|
+
* - offers reload as a choice the person makes, never as something that
|
|
49
|
+
* happens to their typing.
|
|
50
|
+
*
|
|
51
|
+
* A save is never retried automatically. Overwriting someone's work silently is
|
|
52
|
+
* the failure this guard exists to prevent, and a retry is exactly that.
|
|
53
|
+
*/
|
|
54
|
+
export function EpisodeTextEditor({
|
|
55
|
+
file,
|
|
56
|
+
loaded,
|
|
57
|
+
onSave,
|
|
58
|
+
onReload,
|
|
59
|
+
readOnly = false,
|
|
60
|
+
theme = DEFAULT_THEME,
|
|
61
|
+
className,
|
|
62
|
+
style,
|
|
63
|
+
}: EpisodeTextEditorProps) {
|
|
64
|
+
const [draft, setDraft] = useState(loaded.content)
|
|
65
|
+
const [baseRevision, setBaseRevision] = useState(loaded.revision.sha256)
|
|
66
|
+
const [state, setState] = useState<"idle" | "saving" | "saved" | "conflict" | "error">("idle")
|
|
67
|
+
const [message, setMessage] = useState<string>("")
|
|
68
|
+
const [theirRevision, setTheirRevision] = useState<FileRevisionView | null>(null)
|
|
69
|
+
const fileRef = useRef(file)
|
|
70
|
+
|
|
71
|
+
// A different file is a different draft; the same file re-read is a new base.
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (fileRef.current !== file) {
|
|
74
|
+
fileRef.current = file
|
|
75
|
+
setDraft(loaded.content)
|
|
76
|
+
setBaseRevision(loaded.revision.sha256)
|
|
77
|
+
setState("idle")
|
|
78
|
+
setMessage("")
|
|
79
|
+
setTheirRevision(null)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
if (loaded.revision.sha256 !== baseRevision && state !== "conflict") {
|
|
83
|
+
setDraft(loaded.content)
|
|
84
|
+
setBaseRevision(loaded.revision.sha256)
|
|
85
|
+
}
|
|
86
|
+
}, [file, loaded, baseRevision, state])
|
|
87
|
+
|
|
88
|
+
const dirty = draft !== loaded.content || state === "conflict"
|
|
89
|
+
|
|
90
|
+
const save = async () => {
|
|
91
|
+
setState("saving")
|
|
92
|
+
setMessage("")
|
|
93
|
+
try {
|
|
94
|
+
const outcome = await onSave(draft, baseRevision)
|
|
95
|
+
if (outcome.ok) {
|
|
96
|
+
setState("saved")
|
|
97
|
+
setMessage(outcome.message ?? "Saved.")
|
|
98
|
+
setTheirRevision(null)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
if (outcome.conflict) {
|
|
102
|
+
setState("conflict")
|
|
103
|
+
setTheirRevision(outcome.conflict.currentRevision)
|
|
104
|
+
setMessage(
|
|
105
|
+
outcome.message ??
|
|
106
|
+
"This file changed on disk while you were writing. Nothing was overwritten and your draft is still here.",
|
|
107
|
+
)
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
setState("error")
|
|
111
|
+
setMessage(outcome.message ?? "It could not be saved.")
|
|
112
|
+
} catch (error) {
|
|
113
|
+
setState("error")
|
|
114
|
+
setMessage(error instanceof Error ? error.message : "It could not be saved.")
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const reload = () => {
|
|
119
|
+
setState("idle")
|
|
120
|
+
setMessage("")
|
|
121
|
+
setTheirRevision(null)
|
|
122
|
+
onReload?.()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<section
|
|
127
|
+
className={["episode-text-editor", className].filter(Boolean).join(" ")}
|
|
128
|
+
style={{ ...themeVars(theme), ...style } as CSSProperties}
|
|
129
|
+
aria-label={`Editing ${file}`}
|
|
130
|
+
>
|
|
131
|
+
<header className="episode-text-editor-head">
|
|
132
|
+
<span className="episode-text-editor-file">{file}</span>
|
|
133
|
+
<span className={`episode-text-editor-state is-${state}`} role="status" aria-live="polite">
|
|
134
|
+
{state === "saving" && "Savingโฆ"}
|
|
135
|
+
{state === "saved" && "Saved"}
|
|
136
|
+
{state === "conflict" && "Changed on disk"}
|
|
137
|
+
{state === "error" && "Not saved"}
|
|
138
|
+
{state === "idle" && (dirty ? "Unsaved changes" : "Up to date")}
|
|
139
|
+
</span>
|
|
140
|
+
</header>
|
|
141
|
+
|
|
142
|
+
{message && (
|
|
143
|
+
<p className={`episode-text-editor-message is-${state}`}>
|
|
144
|
+
{message}
|
|
145
|
+
{theirRevision && (
|
|
146
|
+
<span className="episode-text-editor-their-revision">
|
|
147
|
+
{" "}
|
|
148
|
+
On disk now: {theirRevision.bytes} bytes, {theirRevision.modifiedAt}.
|
|
149
|
+
</span>
|
|
150
|
+
)}
|
|
151
|
+
</p>
|
|
152
|
+
)}
|
|
153
|
+
|
|
154
|
+
<textarea
|
|
155
|
+
className="episode-text-editor-field"
|
|
156
|
+
value={draft}
|
|
157
|
+
readOnly={readOnly}
|
|
158
|
+
spellCheck={false}
|
|
159
|
+
aria-label={`Contents of ${file}`}
|
|
160
|
+
onChange={(event) => {
|
|
161
|
+
setDraft(event.target.value)
|
|
162
|
+
if (state === "saved" || state === "error") setState("idle")
|
|
163
|
+
}}
|
|
164
|
+
/>
|
|
165
|
+
|
|
166
|
+
{!readOnly && (
|
|
167
|
+
<div className="episode-text-editor-actions">
|
|
168
|
+
<button type="button" className="episode-text-editor-save" disabled={state === "saving" || !dirty} onClick={save}>
|
|
169
|
+
Save
|
|
170
|
+
</button>
|
|
171
|
+
{state === "conflict" && onReload && (
|
|
172
|
+
<button type="button" className="episode-text-editor-reload" onClick={reload}>
|
|
173
|
+
Discard my draft and reload
|
|
174
|
+
</button>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
</section>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface EpisodeTextViewerProps {
|
|
183
|
+
file: string
|
|
184
|
+
loaded: EpisodeTextView
|
|
185
|
+
/**
|
|
186
|
+
* How to render markdown. The package ships no markdown engine: Miadi
|
|
187
|
+
* already renders with `react-markdown`, gmtermux with `marked` +
|
|
188
|
+
* `sanitize-html`, and a second one would be a second sanitisation boundary.
|
|
189
|
+
* Omit it and the source is shown as-is, which is always safe.
|
|
190
|
+
*/
|
|
191
|
+
renderMarkdown?: (source: string) => ReactNode
|
|
192
|
+
theme?: EpisodeUITheme
|
|
193
|
+
className?: string
|
|
194
|
+
style?: CSSProperties
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Read one text artefact โ rendered when the host supplies a renderer, else source. */
|
|
198
|
+
export function EpisodeTextViewer({
|
|
199
|
+
file,
|
|
200
|
+
loaded,
|
|
201
|
+
renderMarkdown,
|
|
202
|
+
theme = DEFAULT_THEME,
|
|
203
|
+
className,
|
|
204
|
+
style,
|
|
205
|
+
}: EpisodeTextViewerProps) {
|
|
206
|
+
const isMarkdown = loaded.contentType === ".md"
|
|
207
|
+
const [rich, setRich] = useState(Boolean(renderMarkdown) && isMarkdown)
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<section
|
|
211
|
+
className={["episode-text-viewer", className].filter(Boolean).join(" ")}
|
|
212
|
+
style={{ ...themeVars(theme), ...style } as CSSProperties}
|
|
213
|
+
aria-label={`Reading ${file}`}
|
|
214
|
+
>
|
|
215
|
+
<header className="episode-text-viewer-head">
|
|
216
|
+
<span className="episode-text-viewer-file">{file}</span>
|
|
217
|
+
{renderMarkdown && isMarkdown && (
|
|
218
|
+
<button type="button" className="episode-text-viewer-toggle" onClick={() => setRich((value) => !value)}>
|
|
219
|
+
{rich ? "Source" : "Rendered"}
|
|
220
|
+
</button>
|
|
221
|
+
)}
|
|
222
|
+
</header>
|
|
223
|
+
{rich && renderMarkdown ? (
|
|
224
|
+
<div className="episode-text-viewer-rich">{renderMarkdown(loaded.content)}</div>
|
|
225
|
+
) : (
|
|
226
|
+
<pre className="episode-text-viewer-source">{loaded.content}</pre>
|
|
227
|
+
)}
|
|
228
|
+
</section>
|
|
229
|
+
)
|
|
230
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shapes these components take in.
|
|
3
|
+
*
|
|
4
|
+
* They are re-declared structurally rather than imported from
|
|
5
|
+
* `@miadi/episode-vessel` at runtime: a browser bundle must not pull `node:fs`
|
|
6
|
+
* in, and a host that reaches a vessel over HTTP receives JSON, not the
|
|
7
|
+
* package's classes. The vessel package is a type-only dependency here, and the
|
|
8
|
+
* two definitions are kept honest by `test/contract.test.mjs`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type FileKind = "text" | "document" | "media"
|
|
12
|
+
|
|
13
|
+
export interface EpisodeFileView {
|
|
14
|
+
relativePath: string
|
|
15
|
+
name: string
|
|
16
|
+
kind: FileKind
|
|
17
|
+
size: number
|
|
18
|
+
modifiedAt: string
|
|
19
|
+
previewable: boolean
|
|
20
|
+
editable: boolean
|
|
21
|
+
guidance: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface FileRevisionView {
|
|
25
|
+
sha256: string
|
|
26
|
+
modifiedAt: string
|
|
27
|
+
bytes: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface EpisodeTextView {
|
|
31
|
+
content: string
|
|
32
|
+
contentType: string
|
|
33
|
+
size: number
|
|
34
|
+
revision: FileRevisionView
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface EpisodeCapabilitiesView {
|
|
38
|
+
browse: boolean
|
|
39
|
+
read: boolean
|
|
40
|
+
edit: boolean
|
|
41
|
+
upload: boolean
|
|
42
|
+
attachCapture: boolean
|
|
43
|
+
writeCeremonyNote: boolean
|
|
44
|
+
attachToCeremony: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CeremonyAttachmentView {
|
|
48
|
+
relativePath: string
|
|
49
|
+
attachedBy: string
|
|
50
|
+
attachedAt: string
|
|
51
|
+
note?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface EpisodeShelfEntry {
|
|
55
|
+
episodePath: string
|
|
56
|
+
number: number
|
|
57
|
+
numberLabel: string
|
|
58
|
+
title: string
|
|
59
|
+
date: string
|
|
60
|
+
goal?: string
|
|
61
|
+
status?: string
|
|
62
|
+
/** True when the medicine wheel also holds this episode. */
|
|
63
|
+
registered?: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Colours and fonts the host already has. Every component takes the same set. */
|
|
67
|
+
export interface EpisodeUITheme {
|
|
68
|
+
background: string
|
|
69
|
+
card: string
|
|
70
|
+
text: string
|
|
71
|
+
textDim: string
|
|
72
|
+
muted: string
|
|
73
|
+
accent: string
|
|
74
|
+
border: string
|
|
75
|
+
success: string
|
|
76
|
+
warning: string
|
|
77
|
+
mono: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const DEFAULT_THEME: EpisodeUITheme = {
|
|
81
|
+
background: "#0d0f12",
|
|
82
|
+
card: "#15181d",
|
|
83
|
+
text: "#e8e6e1",
|
|
84
|
+
textDim: "#b3afa6",
|
|
85
|
+
muted: "#7d7a72",
|
|
86
|
+
accent: "#ffd700",
|
|
87
|
+
border: "#2a2e35",
|
|
88
|
+
success: "#5ac37d",
|
|
89
|
+
warning: "#e0a458",
|
|
90
|
+
mono: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function themeVars(theme: EpisodeUITheme): Record<string, string> {
|
|
94
|
+
return {
|
|
95
|
+
"--episode-bg": theme.background,
|
|
96
|
+
"--episode-card": theme.card,
|
|
97
|
+
"--episode-text": theme.text,
|
|
98
|
+
"--episode-text-dim": theme.textDim,
|
|
99
|
+
"--episode-muted": theme.muted,
|
|
100
|
+
"--episode-accent": theme.accent,
|
|
101
|
+
"--episode-border": theme.border,
|
|
102
|
+
"--episode-success": theme.success,
|
|
103
|
+
"--episode-warning": theme.warning,
|
|
104
|
+
"--episode-mono": theme.mono,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function formatBytes(bytes: number): string {
|
|
109
|
+
if (!Number.isFinite(bytes) || bytes < 0) return ""
|
|
110
|
+
if (bytes < 1024) return `${bytes} B`
|
|
111
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
112
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function iconFor(file: { name: string; kind: FileKind }): string {
|
|
116
|
+
const extension = /\.([^.]+)$/.exec(file.name)?.[1]?.toLowerCase() ?? ""
|
|
117
|
+
if (["m4a", "mp3", "wav", "opus", "aac", "amr", "ogg"].includes(extension)) return "๐"
|
|
118
|
+
if (["mp4", "webm", "mov"].includes(extension)) return "๐ฌ"
|
|
119
|
+
if (extension === "mid") return "๐น"
|
|
120
|
+
if (extension === "md") return "๐"
|
|
121
|
+
if (["json", "yaml", "yml"].includes(extension)) return "๐งพ"
|
|
122
|
+
if (["html", "htm"].includes(extension)) return "๐ผ"
|
|
123
|
+
if (extension === "pdf") return "๐"
|
|
124
|
+
return "๐"
|
|
125
|
+
}
|