@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.
@@ -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
+ }