@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
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
export type FileKind = "text" | "document" | "media";
|
|
11
|
+
export interface EpisodeFileView {
|
|
12
|
+
relativePath: string;
|
|
13
|
+
name: string;
|
|
14
|
+
kind: FileKind;
|
|
15
|
+
size: number;
|
|
16
|
+
modifiedAt: string;
|
|
17
|
+
previewable: boolean;
|
|
18
|
+
editable: boolean;
|
|
19
|
+
guidance: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface FileRevisionView {
|
|
22
|
+
sha256: string;
|
|
23
|
+
modifiedAt: string;
|
|
24
|
+
bytes: number;
|
|
25
|
+
}
|
|
26
|
+
export interface EpisodeTextView {
|
|
27
|
+
content: string;
|
|
28
|
+
contentType: string;
|
|
29
|
+
size: number;
|
|
30
|
+
revision: FileRevisionView;
|
|
31
|
+
}
|
|
32
|
+
export interface EpisodeCapabilitiesView {
|
|
33
|
+
browse: boolean;
|
|
34
|
+
read: boolean;
|
|
35
|
+
edit: boolean;
|
|
36
|
+
upload: boolean;
|
|
37
|
+
attachCapture: boolean;
|
|
38
|
+
writeCeremonyNote: boolean;
|
|
39
|
+
attachToCeremony: boolean;
|
|
40
|
+
}
|
|
41
|
+
export interface CeremonyAttachmentView {
|
|
42
|
+
relativePath: string;
|
|
43
|
+
attachedBy: string;
|
|
44
|
+
attachedAt: string;
|
|
45
|
+
note?: string;
|
|
46
|
+
}
|
|
47
|
+
export interface EpisodeShelfEntry {
|
|
48
|
+
episodePath: string;
|
|
49
|
+
number: number;
|
|
50
|
+
numberLabel: string;
|
|
51
|
+
title: string;
|
|
52
|
+
date: string;
|
|
53
|
+
goal?: string;
|
|
54
|
+
status?: string;
|
|
55
|
+
/** True when the medicine wheel also holds this episode. */
|
|
56
|
+
registered?: boolean;
|
|
57
|
+
}
|
|
58
|
+
/** Colours and fonts the host already has. Every component takes the same set. */
|
|
59
|
+
export interface EpisodeUITheme {
|
|
60
|
+
background: string;
|
|
61
|
+
card: string;
|
|
62
|
+
text: string;
|
|
63
|
+
textDim: string;
|
|
64
|
+
muted: string;
|
|
65
|
+
accent: string;
|
|
66
|
+
border: string;
|
|
67
|
+
success: string;
|
|
68
|
+
warning: string;
|
|
69
|
+
mono: string;
|
|
70
|
+
}
|
|
71
|
+
export declare const DEFAULT_THEME: EpisodeUITheme;
|
|
72
|
+
export declare function themeVars(theme: EpisodeUITheme): Record<string, string>;
|
|
73
|
+
export declare function formatBytes(bytes: number): string;
|
|
74
|
+
export declare function iconFor(file: {
|
|
75
|
+
name: string;
|
|
76
|
+
kind: FileKind;
|
|
77
|
+
}): string;
|
|
78
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,UAAU,GAAG,OAAO,CAAA;AAEpD,MAAM,WAAW,eAAe;IAC9B,YAAY,EAAE,MAAM,CAAA;IACpB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,QAAQ,CAAA;IACd,IAAI,EAAE,MAAM,CAAA;IACZ,UAAU,EAAE,MAAM,CAAA;IAClB,WAAW,EAAE,OAAO,CAAA;IACpB,QAAQ,EAAE,OAAO,CAAA;IACjB,QAAQ,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,CAAA;IACd,UAAU,EAAE,MAAM,CAAA;IAClB,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,gBAAgB,CAAA;CAC3B;AAED,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,OAAO,CAAA;IACf,IAAI,EAAE,OAAO,CAAA;IACb,IAAI,EAAE,OAAO,CAAA;IACb,MAAM,EAAE,OAAO,CAAA;IACf,aAAa,EAAE,OAAO,CAAA;IACtB,iBAAiB,EAAE,OAAO,CAAA;IAC1B,gBAAgB,EAAE,OAAO,CAAA;CAC1B;AAED,MAAM,WAAW,sBAAsB;IACrC,YAAY,EAAE,MAAM,CAAA;IACpB,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,EAAE,MAAM,CAAA;IACnB,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,4DAA4D;IAC5D,UAAU,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,kFAAkF;AAClF,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAA;IAClB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;CACb;AAED,eAAO,MAAM,aAAa,EAAE,cAW3B,CAAA;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAavE;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKjD;AAED,wBAAgB,OAAO,CAAC,IAAI,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,GAAG,MAAM,CAUtE"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
export const DEFAULT_THEME = {
|
|
11
|
+
background: "#0d0f12",
|
|
12
|
+
card: "#15181d",
|
|
13
|
+
text: "#e8e6e1",
|
|
14
|
+
textDim: "#b3afa6",
|
|
15
|
+
muted: "#7d7a72",
|
|
16
|
+
accent: "#ffd700",
|
|
17
|
+
border: "#2a2e35",
|
|
18
|
+
success: "#5ac37d",
|
|
19
|
+
warning: "#e0a458",
|
|
20
|
+
mono: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
21
|
+
};
|
|
22
|
+
export function themeVars(theme) {
|
|
23
|
+
return {
|
|
24
|
+
"--episode-bg": theme.background,
|
|
25
|
+
"--episode-card": theme.card,
|
|
26
|
+
"--episode-text": theme.text,
|
|
27
|
+
"--episode-text-dim": theme.textDim,
|
|
28
|
+
"--episode-muted": theme.muted,
|
|
29
|
+
"--episode-accent": theme.accent,
|
|
30
|
+
"--episode-border": theme.border,
|
|
31
|
+
"--episode-success": theme.success,
|
|
32
|
+
"--episode-warning": theme.warning,
|
|
33
|
+
"--episode-mono": theme.mono,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function formatBytes(bytes) {
|
|
37
|
+
if (!Number.isFinite(bytes) || bytes < 0)
|
|
38
|
+
return "";
|
|
39
|
+
if (bytes < 1024)
|
|
40
|
+
return `${bytes} B`;
|
|
41
|
+
if (bytes < 1024 * 1024)
|
|
42
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
43
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
44
|
+
}
|
|
45
|
+
export function iconFor(file) {
|
|
46
|
+
const extension = /\.([^.]+)$/.exec(file.name)?.[1]?.toLowerCase() ?? "";
|
|
47
|
+
if (["m4a", "mp3", "wav", "opus", "aac", "amr", "ogg"].includes(extension))
|
|
48
|
+
return "🎙";
|
|
49
|
+
if (["mp4", "webm", "mov"].includes(extension))
|
|
50
|
+
return "🎬";
|
|
51
|
+
if (extension === "mid")
|
|
52
|
+
return "🎹";
|
|
53
|
+
if (extension === "md")
|
|
54
|
+
return "📘";
|
|
55
|
+
if (["json", "yaml", "yml"].includes(extension))
|
|
56
|
+
return "🧾";
|
|
57
|
+
if (["html", "htm"].includes(extension))
|
|
58
|
+
return "🖼";
|
|
59
|
+
if (extension === "pdf")
|
|
60
|
+
return "📕";
|
|
61
|
+
return "📄";
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAuEH,MAAM,CAAC,MAAM,aAAa,GAAmB;IAC3C,UAAU,EAAE,SAAS;IACrB,IAAI,EAAE,SAAS;IACf,IAAI,EAAE,SAAS;IACf,OAAO,EAAE,SAAS;IAClB,KAAK,EAAE,SAAS;IAChB,MAAM,EAAE,SAAS;IACjB,MAAM,EAAE,SAAS;IACjB,OAAO,EAAE,SAAS;IAClB,OAAO,EAAE,SAAS;IAClB,IAAI,EAAE,gDAAgD;CACvD,CAAA;AAED,MAAM,UAAU,SAAS,CAAC,KAAqB;IAC7C,OAAO;QACL,cAAc,EAAE,KAAK,CAAC,UAAU;QAChC,gBAAgB,EAAE,KAAK,CAAC,IAAI;QAC5B,gBAAgB,EAAE,KAAK,CAAC,IAAI;QAC5B,oBAAoB,EAAE,KAAK,CAAC,OAAO;QACnC,iBAAiB,EAAE,KAAK,CAAC,KAAK;QAC9B,kBAAkB,EAAE,KAAK,CAAC,MAAM;QAChC,kBAAkB,EAAE,KAAK,CAAC,MAAM;QAChC,mBAAmB,EAAE,KAAK,CAAC,OAAO;QAClC,mBAAmB,EAAE,KAAK,CAAC,OAAO;QAClC,gBAAgB,EAAE,KAAK,CAAC,IAAI;KAC7B,CAAA;AACH,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,KAAa;IACvC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC;QAAE,OAAO,EAAE,CAAA;IACnD,IAAI,KAAK,GAAG,IAAI;QAAE,OAAO,GAAG,KAAK,IAAI,CAAA;IACrC,IAAI,KAAK,GAAG,IAAI,GAAG,IAAI;QAAE,OAAO,GAAG,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAA;IACjE,OAAO,GAAG,CAAC,KAAK,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAA;AACnD,CAAC;AAED,MAAM,UAAU,OAAO,CAAC,IAAsC;IAC5D,MAAM,SAAS,GAAG,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,IAAI,EAAE,CAAA;IACxE,IAAI,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC;QAAE,OAAO,IAAI,CAAA;IACvF,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC;QAAE,OAAO,IAAI,CAAA;IAC3D,IAAI,SAAS,KAAK,KAAK;QAAE,OAAO,IAAI,CAAA;IACpC,IAAI,SAAS,KAAK,IAAI;QAAE,OAAO,IAAI,CAAA;IACnC,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC;QAAE,OAAO,IAAI,CAAA;IAC5D,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC;QAAE,OAAO,IAAI,CAAA;IACpD,IAAI,SAAS,KAAK,KAAK;QAAE,OAAO,IAAI,CAAA;IACpC,OAAO,IAAI,CAAA;AACb,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@miadi/episode-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Host-driven React components for an episode vessel: browse its files, read one, edit one with a conflict that never loses the draft, keep a ceremony's working notes and name the artefacts a circle was held about. No component fetches, holds a URL or a token, or knows a chronicle root — files, content, capabilities and callbacks come in; intent comes out.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"sideEffects": [
|
|
11
|
+
"**/*.css"
|
|
12
|
+
],
|
|
13
|
+
"style": "./src/episode-ui.css",
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./styles.css": "./src/episode-ui.css",
|
|
23
|
+
"./package.json": "./package.json"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"src",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@miadi/episode-vessel": "^0.1.0"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"react": ">=18",
|
|
35
|
+
"react-dom": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/react": "^19",
|
|
39
|
+
"@types/react-dom": "^19",
|
|
40
|
+
"react": "^19",
|
|
41
|
+
"react-dom": "^19",
|
|
42
|
+
"typescript": "^5.4.0"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=20"
|
|
46
|
+
},
|
|
47
|
+
"keywords": [
|
|
48
|
+
"miadi",
|
|
49
|
+
"chronicle",
|
|
50
|
+
"episode",
|
|
51
|
+
"vessel",
|
|
52
|
+
"react",
|
|
53
|
+
"headless",
|
|
54
|
+
"file-browser",
|
|
55
|
+
"ceremony"
|
|
56
|
+
],
|
|
57
|
+
"repository": {
|
|
58
|
+
"type": "git",
|
|
59
|
+
"url": "git+https://github.com/jgwill/Miadi.git",
|
|
60
|
+
"directory": "packages/episode-ui"
|
|
61
|
+
},
|
|
62
|
+
"bugs": {
|
|
63
|
+
"url": "https://github.com/jgwill/Miadi/issues"
|
|
64
|
+
},
|
|
65
|
+
"scripts": {
|
|
66
|
+
"build": "../../node_modules/.bin/tsc -p tsconfig.build.json",
|
|
67
|
+
"type-check": "../../node_modules/.bin/tsc -p tsconfig.build.json --noEmit",
|
|
68
|
+
"test": "npm run build && node --test test/*.test.mjs"
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/ceremony.tsx
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react"
|
|
4
|
+
import type { CSSProperties } from "react"
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_THEME,
|
|
7
|
+
themeVars,
|
|
8
|
+
type CeremonyAttachmentView,
|
|
9
|
+
type EpisodeCapabilitiesView,
|
|
10
|
+
type EpisodeUITheme,
|
|
11
|
+
} from "./types.js"
|
|
12
|
+
|
|
13
|
+
export interface CeremonyNoteEditorProps {
|
|
14
|
+
ceremonyId: string
|
|
15
|
+
/** Where the note lives in the vessel, shown so a person can find it later. */
|
|
16
|
+
relativePath: string
|
|
17
|
+
content: string
|
|
18
|
+
exists: boolean
|
|
19
|
+
capabilities: EpisodeCapabilitiesView
|
|
20
|
+
onSave: (content: string) => Promise<{ ok: boolean; message?: string }>
|
|
21
|
+
label?: string
|
|
22
|
+
placeholder?: string
|
|
23
|
+
theme?: EpisodeUITheme
|
|
24
|
+
className?: string
|
|
25
|
+
style?: CSSProperties
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The working notes of one ceremony, kept in the episode vessel.
|
|
30
|
+
*
|
|
31
|
+
* The medicine wheel owns the ceremony record; this is what the circle wrote
|
|
32
|
+
* down, and it stays in the folder so it travels with the episode and survives
|
|
33
|
+
* a wheel that is offline. A person without `writeCeremonyNote` reads it.
|
|
34
|
+
*/
|
|
35
|
+
export function CeremonyNoteEditor({
|
|
36
|
+
ceremonyId,
|
|
37
|
+
relativePath,
|
|
38
|
+
content,
|
|
39
|
+
exists,
|
|
40
|
+
capabilities,
|
|
41
|
+
onSave,
|
|
42
|
+
label = "Working notes",
|
|
43
|
+
placeholder = "What should remain with this ceremony?",
|
|
44
|
+
theme = DEFAULT_THEME,
|
|
45
|
+
className,
|
|
46
|
+
style,
|
|
47
|
+
}: CeremonyNoteEditorProps) {
|
|
48
|
+
const [draft, setDraft] = useState(content)
|
|
49
|
+
const [state, setState] = useState<"idle" | "saving" | "saved" | "error">("idle")
|
|
50
|
+
const [message, setMessage] = useState("")
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
setDraft(content)
|
|
54
|
+
setState("idle")
|
|
55
|
+
}, [ceremonyId, content])
|
|
56
|
+
|
|
57
|
+
const mayWrite = capabilities.writeCeremonyNote
|
|
58
|
+
|
|
59
|
+
const save = async () => {
|
|
60
|
+
setState("saving")
|
|
61
|
+
try {
|
|
62
|
+
const outcome = await onSave(draft)
|
|
63
|
+
setState(outcome.ok ? "saved" : "error")
|
|
64
|
+
setMessage(outcome.message ?? (outcome.ok ? `Saved to ${relativePath}` : "It could not be saved."))
|
|
65
|
+
} catch (error) {
|
|
66
|
+
setState("error")
|
|
67
|
+
setMessage(error instanceof Error ? error.message : "It could not be saved.")
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div
|
|
73
|
+
className={["ceremony-note-editor", className].filter(Boolean).join(" ")}
|
|
74
|
+
style={{ ...themeVars(theme), ...style } as CSSProperties}
|
|
75
|
+
>
|
|
76
|
+
<div className="ceremony-note-head">
|
|
77
|
+
<label className="ceremony-note-label" htmlFor={`ceremony-note-${ceremonyId}`}>
|
|
78
|
+
{label}
|
|
79
|
+
</label>
|
|
80
|
+
<span className={`ceremony-note-state is-${exists ? "saved" : "pending"}`} title={relativePath}>
|
|
81
|
+
{exists ? "notes.md" : "not saved yet"}
|
|
82
|
+
</span>
|
|
83
|
+
</div>
|
|
84
|
+
<textarea
|
|
85
|
+
id={`ceremony-note-${ceremonyId}`}
|
|
86
|
+
className="ceremony-note-field"
|
|
87
|
+
value={draft}
|
|
88
|
+
maxLength={65536}
|
|
89
|
+
readOnly={!mayWrite}
|
|
90
|
+
placeholder={mayWrite ? placeholder : undefined}
|
|
91
|
+
onChange={(event) => {
|
|
92
|
+
setDraft(event.target.value)
|
|
93
|
+
if (state !== "idle") setState("idle")
|
|
94
|
+
}}
|
|
95
|
+
/>
|
|
96
|
+
{mayWrite && (
|
|
97
|
+
<div className="ceremony-note-actions">
|
|
98
|
+
<button type="button" disabled={state === "saving" || draft === content} onClick={save}>
|
|
99
|
+
Save notes
|
|
100
|
+
</button>
|
|
101
|
+
{message && (
|
|
102
|
+
<span className={`ceremony-note-message is-${state}`} role="status" aria-live="polite">
|
|
103
|
+
{message}
|
|
104
|
+
</span>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface CeremonyAttachmentsProps {
|
|
113
|
+
attachments: CeremonyAttachmentView[]
|
|
114
|
+
capabilities: EpisodeCapabilitiesView
|
|
115
|
+
onOpen?: (relativePath: string) => void
|
|
116
|
+
onDetach?: (relativePath: string) => void
|
|
117
|
+
emptyLabel?: string
|
|
118
|
+
title?: string
|
|
119
|
+
theme?: EpisodeUITheme
|
|
120
|
+
className?: string
|
|
121
|
+
style?: CSSProperties
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* What a circle was held about.
|
|
126
|
+
*
|
|
127
|
+
* A ceremony with no attachment shows nothing rather than an empty frame — an
|
|
128
|
+
* absence stated loudly is still noise. When there are attachments, each one
|
|
129
|
+
* says who put it on the table and when, because that is the part a reader
|
|
130
|
+
* cannot reconstruct afterwards.
|
|
131
|
+
*/
|
|
132
|
+
export function CeremonyAttachments({
|
|
133
|
+
attachments,
|
|
134
|
+
capabilities,
|
|
135
|
+
onOpen,
|
|
136
|
+
onDetach,
|
|
137
|
+
emptyLabel,
|
|
138
|
+
title = "On the table",
|
|
139
|
+
theme = DEFAULT_THEME,
|
|
140
|
+
className,
|
|
141
|
+
style,
|
|
142
|
+
}: CeremonyAttachmentsProps) {
|
|
143
|
+
if (attachments.length === 0 && !emptyLabel) return null
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div
|
|
147
|
+
className={["ceremony-attachments", className].filter(Boolean).join(" ")}
|
|
148
|
+
style={{ ...themeVars(theme), ...style } as CSSProperties}
|
|
149
|
+
>
|
|
150
|
+
<span className="ceremony-attachments-title">{title}</span>
|
|
151
|
+
{attachments.length === 0 ? (
|
|
152
|
+
<p className="ceremony-attachments-empty">{emptyLabel}</p>
|
|
153
|
+
) : (
|
|
154
|
+
<ul className="ceremony-attachments-list">
|
|
155
|
+
{attachments.map((item) => (
|
|
156
|
+
<li key={item.relativePath} className="ceremony-attachment">
|
|
157
|
+
<button
|
|
158
|
+
type="button"
|
|
159
|
+
className="ceremony-attachment-open"
|
|
160
|
+
disabled={!capabilities.read || !onOpen}
|
|
161
|
+
onClick={() => onOpen?.(item.relativePath)}
|
|
162
|
+
>
|
|
163
|
+
{item.relativePath}
|
|
164
|
+
</button>
|
|
165
|
+
<span className="ceremony-attachment-meta">
|
|
166
|
+
{item.attachedBy} · {item.attachedAt.slice(0, 16).replace("T", " ")}
|
|
167
|
+
</span>
|
|
168
|
+
{item.note && <span className="ceremony-attachment-note">{item.note}</span>}
|
|
169
|
+
{onDetach && capabilities.attachToCeremony && (
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
className="ceremony-attachment-detach"
|
|
173
|
+
title="Take this off the table"
|
|
174
|
+
onClick={() => onDetach(item.relativePath)}
|
|
175
|
+
>
|
|
176
|
+
remove
|
|
177
|
+
</button>
|
|
178
|
+
)}
|
|
179
|
+
</li>
|
|
180
|
+
))}
|
|
181
|
+
</ul>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
)
|
|
185
|
+
}
|