caracms 0.0.0-stage → 0.2.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.
Files changed (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -2
  3. package/bin/caracms.mjs +174 -0
  4. package/bin/gate.mjs +275 -0
  5. package/bin/init.mjs +712 -0
  6. package/bin/media.mjs +662 -0
  7. package/bin/site-runner.mjs +61 -0
  8. package/bin/templates/Page.astro.txt +31 -0
  9. package/bin/templates/TextBlock.astro.txt +13 -0
  10. package/bin/templates/caracms.config.ts.txt +22 -0
  11. package/bin/templates/catch-all.astro.txt +25 -0
  12. package/bin/templates/health.ts.txt +6 -0
  13. package/bin/templates/railway.ts.txt +147 -0
  14. package/package.json +66 -4
  15. package/skills/caracms-adopt/SKILL.md +508 -0
  16. package/skills/caracms-adopt/look-same.mjs +957 -0
  17. package/skills/caracms-blocks/SKILL.md +376 -0
  18. package/skills/caracms-content/SKILL.md +201 -0
  19. package/skills/caracms-tina/SKILL.md +427 -0
  20. package/skills/caracms-tina/field-kinds.md +88 -0
  21. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  22. package/src/block-runs.ts +84 -0
  23. package/src/cards.ts +158 -0
  24. package/src/cli/schemas.ts +122 -0
  25. package/src/cli/site.ts +66 -0
  26. package/src/cli/validate.ts +471 -0
  27. package/src/code-routes-disk.ts +67 -0
  28. package/src/code-routes.ts +134 -0
  29. package/src/columns.ts +39 -0
  30. package/src/components/Block.astro +46 -0
  31. package/src/components/BlockRunTree.astro +45 -0
  32. package/src/components/Blocks.astro +139 -0
  33. package/src/components/Buttons.astro +116 -0
  34. package/src/components/Fonts.astro +14 -0
  35. package/src/components/Form.astro +221 -0
  36. package/src/components/Head.astro +62 -0
  37. package/src/components/Link.astro +68 -0
  38. package/src/components/Lockup.astro +116 -0
  39. package/src/components/Media.astro +124 -0
  40. package/src/components/MediaPlayer.astro +102 -0
  41. package/src/components/RenderDocument.astro +64 -0
  42. package/src/components/RichText.astro +59 -0
  43. package/src/components/Text.astro +81 -0
  44. package/src/components/attributes.ts +33 -0
  45. package/src/components/descriptor.ts +45 -0
  46. package/src/components/form-client.ts +182 -0
  47. package/src/components/media-player.ts +46 -0
  48. package/src/components/media-props.ts +35 -0
  49. package/src/components/sitemap.xml.ts +36 -0
  50. package/src/config.ts +825 -0
  51. package/src/content-schema.ts +166 -0
  52. package/src/content.ts +422 -0
  53. package/src/document.ts +579 -0
  54. package/src/editor/README.md +405 -0
  55. package/src/editor/addresses.ts +61 -0
  56. package/src/editor/brand.ts +118 -0
  57. package/src/editor/client/block-list/BlockList.tsx +342 -0
  58. package/src/editor/client/block-list/Picker.tsx +105 -0
  59. package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
  60. package/src/editor/client/block-list/insert-points.ts +56 -0
  61. package/src/editor/client/block-list/link.ts +71 -0
  62. package/src/editor/client/block-list/reorder.ts +105 -0
  63. package/src/editor/client/block-list/summary.ts +118 -0
  64. package/src/editor/client/collections/CollectionGrid.tsx +143 -0
  65. package/src/editor/client/collections/CollectionTable.tsx +406 -0
  66. package/src/editor/client/collections/table.ts +270 -0
  67. package/src/editor/client/context.tsx +27 -0
  68. package/src/editor/client/documents/DocMenu.tsx +154 -0
  69. package/src/editor/client/documents/actions.ts +192 -0
  70. package/src/editor/client/documents/dialogs.tsx +687 -0
  71. package/src/editor/client/editor.ts +995 -0
  72. package/src/editor/client/fields/ButtonsField.tsx +87 -0
  73. package/src/editor/client/fields/DateField.tsx +200 -0
  74. package/src/editor/client/fields/FieldControl.tsx +61 -0
  75. package/src/editor/client/fields/FieldFrame.tsx +109 -0
  76. package/src/editor/client/fields/GroupField.tsx +113 -0
  77. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  78. package/src/editor/client/fields/LinkField.tsx +307 -0
  79. package/src/editor/client/fields/ListField.tsx +642 -0
  80. package/src/editor/client/fields/LockupField.tsx +365 -0
  81. package/src/editor/client/fields/MediaField.tsx +823 -0
  82. package/src/editor/client/fields/PageFields.tsx +82 -0
  83. package/src/editor/client/fields/ReferenceField.tsx +209 -0
  84. package/src/editor/client/fields/RichTextField.tsx +306 -0
  85. package/src/editor/client/fields/SelectField.tsx +63 -0
  86. package/src/editor/client/fields/SeoGroup.tsx +282 -0
  87. package/src/editor/client/fields/SimpleFields.tsx +89 -0
  88. package/src/editor/client/fields/TextField.tsx +51 -0
  89. package/src/editor/client/fields/date.ts +73 -0
  90. package/src/editor/client/fields/host.tsx +172 -0
  91. package/src/editor/client/fields/list-summary.ts +85 -0
  92. package/src/editor/client/fields/lockup-rules.ts +181 -0
  93. package/src/editor/client/fields/parts.tsx +458 -0
  94. package/src/editor/client/fields/rich-text-commands.ts +183 -0
  95. package/src/editor/client/fields/rich-text-doc.ts +156 -0
  96. package/src/editor/client/fields/rich-text-schema.ts +224 -0
  97. package/src/editor/client/fields/stale.ts +58 -0
  98. package/src/editor/client/inbox/InboxStage.tsx +272 -0
  99. package/src/editor/client/inbox/store.ts +311 -0
  100. package/src/editor/client/live/format.ts +77 -0
  101. package/src/editor/client/live/marks.tsx +90 -0
  102. package/src/editor/client/live/notices.tsx +213 -0
  103. package/src/editor/client/live/publish.tsx +617 -0
  104. package/src/editor/client/live/session.tsx +162 -0
  105. package/src/editor/client/main.tsx +12 -0
  106. package/src/editor/client/media/LibraryStage.tsx +75 -0
  107. package/src/editor/client/media/ReplaceDialog.tsx +355 -0
  108. package/src/editor/client/media/store.ts +618 -0
  109. package/src/editor/client/nav/DocRow.tsx +122 -0
  110. package/src/editor/client/nav/search.ts +71 -0
  111. package/src/editor/client/nav/tree.ts +93 -0
  112. package/src/editor/client/overlays/OverlayMount.tsx +26 -0
  113. package/src/editor/client/overlays/editor-marks.ts +159 -0
  114. package/src/editor/client/overlays/index.ts +50 -0
  115. package/src/editor/client/overlays/layer.ts +437 -0
  116. package/src/editor/client/overlays/overlay-css.ts +177 -0
  117. package/src/editor/client/overlays/rich-text-bar.ts +717 -0
  118. package/src/editor/client/pane/Pane.tsx +91 -0
  119. package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
  120. package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
  121. package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
  122. package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
  123. package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
  124. package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
  125. package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
  126. package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
  127. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
  128. package/src/editor/client/pane/parts.tsx +179 -0
  129. package/src/editor/client/pane/switcher.tsx +165 -0
  130. package/src/editor/client/preview/driver.ts +640 -0
  131. package/src/editor/client/preview/field-text.ts +71 -0
  132. package/src/editor/client/preview/follow-links.ts +234 -0
  133. package/src/editor/client/preview/layout.ts +37 -0
  134. package/src/editor/client/preview/link-target.ts +50 -0
  135. package/src/editor/client/preview/markers.ts +86 -0
  136. package/src/editor/client/preview/page-edit.ts +632 -0
  137. package/src/editor/client/preview/page-editor-registry.ts +63 -0
  138. package/src/editor/client/preview/page-editor.ts +235 -0
  139. package/src/editor/client/preview/render-sequencer.ts +113 -0
  140. package/src/editor/client/preview/selection.ts +116 -0
  141. package/src/editor/client/preview/warnings.ts +61 -0
  142. package/src/editor/client/shell/App.tsx +101 -0
  143. package/src/editor/client/shell/Bar.tsx +197 -0
  144. package/src/editor/client/shell/Brand.tsx +33 -0
  145. package/src/editor/client/shell/Icons.tsx +87 -0
  146. package/src/editor/client/shell/NarrowNote.tsx +47 -0
  147. package/src/editor/client/shell/Popovers.tsx +47 -0
  148. package/src/editor/client/shell/Stage.tsx +132 -0
  149. package/src/editor/client/shell/Toast.tsx +61 -0
  150. package/src/editor/client/state/document-store.ts +465 -0
  151. package/src/editor/client/state/keeper.ts +713 -0
  152. package/src/editor/client/state/live.ts +257 -0
  153. package/src/editor/client/state/schema.ts +42 -0
  154. package/src/editor/client/state/store.ts +39 -0
  155. package/src/editor/fonts/OFL-GeistMono.txt +93 -0
  156. package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
  157. package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
  158. package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
  159. package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
  160. package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
  161. package/src/editor/middleware.ts +62 -0
  162. package/src/editor/routes/_live.ts +26 -0
  163. package/src/editor/routes/admin.astro +80 -0
  164. package/src/editor/routes/create.ts +19 -0
  165. package/src/editor/routes/delete.ts +19 -0
  166. package/src/editor/routes/document.ts +39 -0
  167. package/src/editor/routes/documents.ts +30 -0
  168. package/src/editor/routes/github.ts +23 -0
  169. package/src/editor/routes/history.ts +9 -0
  170. package/src/editor/routes/lock.ts +9 -0
  171. package/src/editor/routes/login/assets.d.ts +10 -0
  172. package/src/editor/routes/login/login.ts +43 -0
  173. package/src/editor/routes/media.ts +16 -0
  174. package/src/editor/routes/move.ts +17 -0
  175. package/src/editor/routes/notice.ts +9 -0
  176. package/src/editor/routes/preview.astro +77 -0
  177. package/src/editor/routes/publish.ts +9 -0
  178. package/src/editor/routes/status.ts +9 -0
  179. package/src/editor/routes/submissions.ts +16 -0
  180. package/src/editor/routes/try-again.ts +9 -0
  181. package/src/editor/routes/write.ts +18 -0
  182. package/src/editor/server/admin-props.ts +151 -0
  183. package/src/editor/server/cards.ts +61 -0
  184. package/src/editor/server/create.ts +414 -0
  185. package/src/editor/server/drafts-store.ts +127 -0
  186. package/src/editor/server/media/alt.ts +107 -0
  187. package/src/editor/server/media/bucket.ts +295 -0
  188. package/src/editor/server/media/config.ts +78 -0
  189. package/src/editor/server/media/handler.ts +221 -0
  190. package/src/editor/server/media/library.ts +254 -0
  191. package/src/editor/server/media/memory.ts +184 -0
  192. package/src/editor/server/media/mux.ts +241 -0
  193. package/src/editor/server/media/sniff.ts +599 -0
  194. package/src/editor/server/media/upload.ts +175 -0
  195. package/src/editor/server/mode.ts +63 -0
  196. package/src/editor/server/move.ts +295 -0
  197. package/src/editor/server/render.tsx +9 -0
  198. package/src/editor/server/replace.ts +72 -0
  199. package/src/editor/server/settings.ts +35 -0
  200. package/src/editor/server/write.ts +366 -0
  201. package/src/editor/styles/editor.css +502 -0
  202. package/src/editor/styles/fonts.css +40 -0
  203. package/src/editor/styles/levels/README.md +4 -0
  204. package/src/editor/styles/levels/collections.css +99 -0
  205. package/src/editor/styles/levels/document-level.css +10 -0
  206. package/src/editor/styles/levels/documents.css +9 -0
  207. package/src/editor/styles/levels/fields.css +171 -0
  208. package/src/editor/styles/levels/files.css +95 -0
  209. package/src/editor/styles/levels/live.css +10 -0
  210. package/src/editor/styles/levels/m5a-documents.css +51 -0
  211. package/src/editor/styles/levels/m6.css +82 -0
  212. package/src/editor/styles/levels/media.css +175 -0
  213. package/src/editor/styles/levels/switcher.css +3 -0
  214. package/src/editor/styles/tokens.css +114 -0
  215. package/src/editor/tsconfig.json +16 -0
  216. package/src/editor/types.ts +881 -0
  217. package/src/editor/ui-state.ts +113 -0
  218. package/src/email.ts +67 -0
  219. package/src/fields.ts +1129 -0
  220. package/src/files-route.ts +201 -0
  221. package/src/files.ts +116 -0
  222. package/src/forms/email.ts +149 -0
  223. package/src/forms/endpoint.ts +347 -0
  224. package/src/forms/inbox.ts +273 -0
  225. package/src/forms/rate-limit.ts +130 -0
  226. package/src/forms/route.ts +24 -0
  227. package/src/forms/rules.ts +278 -0
  228. package/src/forms/store.ts +486 -0
  229. package/src/head.ts +125 -0
  230. package/src/index.ts +434 -0
  231. package/src/json-source.ts +116 -0
  232. package/src/links.ts +121 -0
  233. package/src/live/README.md +43 -0
  234. package/src/live/auth/allow-rules.ts +70 -0
  235. package/src/live/auth/allow.ts +128 -0
  236. package/src/live/auth/auth.ts +155 -0
  237. package/src/live/auth/email.ts +84 -0
  238. package/src/live/auth/guard.ts +111 -0
  239. package/src/live/auth/index.ts +68 -0
  240. package/src/live/auth/links.ts +101 -0
  241. package/src/live/auth/login.ts +245 -0
  242. package/src/live/auth/middleware.ts +53 -0
  243. package/src/live/auth/request.ts +56 -0
  244. package/src/live/auth/routes.ts +9 -0
  245. package/src/live/auth/session.ts +181 -0
  246. package/src/live/auth/views.ts +355 -0
  247. package/src/live/autosaves.ts +306 -0
  248. package/src/live/backend.ts +652 -0
  249. package/src/live/branches.ts +19 -0
  250. package/src/live/drafts-store.ts +591 -0
  251. package/src/live/editor.ts +465 -0
  252. package/src/live/git.ts +1021 -0
  253. package/src/live/github.ts +271 -0
  254. package/src/live/locals.ts +24 -0
  255. package/src/live/locks.ts +273 -0
  256. package/src/live/merge.ts +197 -0
  257. package/src/live/messages.ts +151 -0
  258. package/src/live/mode.ts +210 -0
  259. package/src/live/redis.ts +533 -0
  260. package/src/live/runtime.ts +164 -0
  261. package/src/live/status.ts +109 -0
  262. package/src/live/sweep.ts +228 -0
  263. package/src/live/timings.ts +47 -0
  264. package/src/live/webhook.ts +39 -0
  265. package/src/lockup-css.ts +58 -0
  266. package/src/measure.ts +145 -0
  267. package/src/media-value.ts +239 -0
  268. package/src/media.ts +255 -0
  269. package/src/read.ts +256 -0
  270. package/src/redirect-middleware.ts +49 -0
  271. package/src/redirects.ts +76 -0
  272. package/src/relink.ts +293 -0
  273. package/src/reserved.ts +24 -0
  274. package/src/rich-text.ts +690 -0
  275. package/src/schema-key.ts +63 -0
  276. package/src/seo.ts +124 -0
  277. package/src/serialize.ts +697 -0
  278. package/src/skipped.ts +35 -0
  279. package/src/slug.ts +121 -0
  280. package/src/text-styles.ts +62 -0
  281. package/src/virtual.d.ts +28 -0
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The session in live mode (M4 Decisions 11 and 14; the mockup's #account,
3
+ * #signed-out-edit and #offline):
4
+ *
5
+ * - `AccountButton` / `AccountPop`: the signed-in avatar in the bar, opening who is
6
+ * signed in and **Sign out** (`POST /admin/login/signout`, then its `location`).
7
+ * - `SignedOutModal`: "You've been signed out". The edits stay in the tab; **Sign in
8
+ * again** opens sign-in in a new tab, and this tab sends them once its next poll
9
+ * answers (editor.ts → recheck). A person removed from the whitelist gets the
10
+ * plain sentence that their latest changes couldn't be kept, and no way back.
11
+ * - `OfflineExtras`: the offline note's live-mode lines: the unsent count, "Trying
12
+ * again in 12 s." (the retry timer runs only while offline) and **Try now**.
13
+ */
14
+ import { useEffect, useRef, useState } from 'react';
15
+ import { useEditor, useStore } from '../context.tsx';
16
+ import { Icon } from '../shell/Icons.tsx';
17
+ import { displayName } from './format.ts';
18
+ import { Avatar } from './marks.tsx';
19
+
20
+ export function AccountButton() {
21
+ const editor = useEditor();
22
+ const pop = useStore(editor.view, (v) => v.pop);
23
+ const you = useStore(editor.liveState, (s) => s.status?.you ?? editor.you);
24
+ if (!you) return <span data-slot="account" hidden />;
25
+ return (
26
+ <button
27
+ type="button"
28
+ className="me"
29
+ data-slot="account"
30
+ aria-haspopup="dialog"
31
+ aria-expanded={pop === 'account'}
32
+ aria-label={`Signed in as ${displayName(you)}`}
33
+ onClick={() => editor.setPop(pop === 'account' ? null : 'account')}
34
+ >
35
+ <Avatar person={you} />
36
+ </button>
37
+ );
38
+ }
39
+
40
+ export function AccountPop() {
41
+ const editor = useEditor();
42
+ const you = useStore(editor.liveState, (s) => s.status?.you ?? editor.you);
43
+ const ref = useRef<HTMLDivElement>(null);
44
+ const [leaving, setLeaving] = useState(false);
45
+ useEffect(() => {
46
+ const onDown = (event: MouseEvent) => {
47
+ const target = event.target as Element | null;
48
+ if (!target || ref.current?.contains(target) || target.closest?.('.me')) return;
49
+ editor.setPop(null);
50
+ };
51
+ document.addEventListener('mousedown', onDown);
52
+ return () => document.removeEventListener('mousedown', onDown);
53
+ }, [editor]);
54
+ if (!you) return null;
55
+ return (
56
+ <div ref={ref} className="pop" role="dialog" aria-label="Account" data-pop="account" style={{ right: 8, width: 280 }}>
57
+ <div className="acct">
58
+ <div className="who">
59
+ <Avatar person={you} size="lg" />
60
+ <div>
61
+ <b>{displayName(you)}</b>
62
+ <small>{you.email}</small>
63
+ </div>
64
+ </div>
65
+ <hr />
66
+ <button
67
+ type="button"
68
+ className="btn block"
69
+ data-act="sign-out"
70
+ disabled={leaving}
71
+ onClick={() => {
72
+ setLeaving(true);
73
+ void editor.signOut();
74
+ }}
75
+ >
76
+ <Icon name="signout" size="sm" />
77
+ Sign out
78
+ </button>
79
+ </div>
80
+ </div>
81
+ );
82
+ }
83
+
84
+ export function SignedOutModal() {
85
+ const editor = useEditor();
86
+ const signedOut = useStore(editor.liveState, (s) => s.signedOut);
87
+ const unsent = useStore(editor.keep, (k) => k.unsent ?? 0);
88
+ const title = useStore(editor.doc.state, (s) => s.document.title || s.entry.label);
89
+ const button = useRef<HTMLButtonElement>(null);
90
+ useEffect(() => {
91
+ if (signedOut) button.current?.focus();
92
+ }, [signedOut]);
93
+ if (!signedOut) return null;
94
+ if (signedOut.reason === 'removed') {
95
+ return (
96
+ <div className="scrim" data-modal="removed">
97
+ <div className="dialog" role="alertdialog" aria-modal="true" aria-labelledby="dlg-h" aria-describedby="dlg-d">
98
+ <div className="icon-h" style={{ background: 'var(--status-danger-wash)', color: 'var(--status-danger)' }}>
99
+ <Icon name="signout" />
100
+ </div>
101
+ <h2 id="dlg-h">You can't sign in to {editor.brand.get().name} any more</h2>
102
+ <p id="dlg-d">You can no longer sign in, so your latest changes couldn't be kept.</p>
103
+ </div>
104
+ </div>
105
+ );
106
+ }
107
+ return (
108
+ <div className="scrim" data-modal="signed-out">
109
+ <div className="dialog" role="alertdialog" aria-modal="true" aria-labelledby="dlg-h">
110
+ <div className="icon-h" style={{ background: 'var(--status-danger-wash)', color: 'var(--status-danger)' }}>
111
+ <Icon name="signout" />
112
+ </div>
113
+ <h2 id="dlg-h">You've been signed out</h2>
114
+ {unsent > 0 ? (
115
+ <>
116
+ <p>
117
+ {unsent} change{unsent === 1 ? '' : 's'} to {title} {unsent === 1 ? "hasn't" : "haven't"} reached the server.
118
+ </p>
119
+ <p>Keep this tab open and sign in again.</p>
120
+ </>
121
+ ) : (
122
+ <p>Sign in again to keep editing.</p>
123
+ )}
124
+ <div className="acts">
125
+ <button ref={button} type="button" className="btn ink" data-act="signin-again" onClick={() => editor.signInAgain()}>
126
+ <Icon name="ext" size="sm" />
127
+ Sign in again
128
+ </button>
129
+ </div>
130
+ <p className="field-help" style={{ marginTop: 'var(--space-3)', textAlign: 'right' }}>
131
+ Opens in a new tab.
132
+ </p>
133
+ </div>
134
+ </div>
135
+ );
136
+ }
137
+
138
+ /** The offline note's countdown, Try now and the unsent count (live mode; M3 left their slots). */
139
+ export function OfflineExtras() {
140
+ const editor = useEditor();
141
+ const retryAt = useStore(editor.keep, (k) => k.retryAt ?? null);
142
+ const [now, setNow] = useState(() => Date.now());
143
+ useEffect(() => {
144
+ if (retryAt === null) return;
145
+ setNow(Date.now());
146
+ const t = setInterval(() => setNow(Date.now()), 1000);
147
+ return () => clearInterval(t);
148
+ }, [retryAt]);
149
+ const seconds = retryAt === null ? null : Math.max(0, Math.ceil((retryAt - now) / 1000));
150
+ return (
151
+ <>
152
+ <p style={{ marginTop: 6 }} data-slot="retry-countdown">
153
+ {seconds === null || seconds === 0 ? 'Trying again…' : `Trying again in ${seconds} s.`}
154
+ </p>
155
+ <div className="acts" data-slot="try-now">
156
+ <button type="button" className="btn sm" data-act="try-now" onClick={() => editor.keeper?.tryNow()}>
157
+ Try now
158
+ </button>
159
+ </div>
160
+ </>
161
+ );
162
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The editor's browser entry: hydrate the server-rendered shell (Decision 2) from
3
+ * the props the admin route left in the page.
4
+ */
5
+ import { hydrateRoot } from 'react-dom/client';
6
+ import type { AdminProps } from '../types.ts';
7
+ import { createEditor } from './editor.ts';
8
+ import { EditorApp } from './shell/App.tsx';
9
+
10
+ const props = JSON.parse(document.getElementById('caracms-props')!.textContent!) as AdminProps;
11
+ const editor = createEditor(props, { client: true });
12
+ hydrateRoot(document.getElementById('viewport')!, <EditorApp editor={editor} />);
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The stage under the library (M5b; the mockup's #library and #library-image): the last
3
+ * page stays, dimmed (shell/Stage.tsx), and this sits over it: the big drop target for
4
+ * images, clips and files, or, on an image's own level, the whole picture, large; on a
5
+ * file's (M6c G4, the mockup's #file), its type tile, large, with Open in a new tab.
6
+ */
7
+ import { fileAddress, isFileKey } from '../../../files.ts';
8
+ import { useEditor, useStore } from '../context.tsx';
9
+ import { useDrop } from '../fields/MediaField.tsx';
10
+ import { FileTile, UploadButton } from '../pane/levels/MediaLevel.tsx';
11
+ import { Icon } from '../shell/Icons.tsx';
12
+ import { clipId, clipStill, fileSize, mediaFor } from './store.ts';
13
+
14
+ const TAKES = 'JPEG, PNG, WebP, AVIF, HEIC, GIF or SVG';
15
+
16
+ export function LibraryStage({ back, onBack }: { back: string; onBack: () => void }) {
17
+ const editor = useEditor();
18
+ const media = mediaFor(editor);
19
+ const item = useStore(editor.view, (v) => v.media?.item ?? null);
20
+ const images = useStore(media.images, (l) => l.list);
21
+ const clips = useStore(media.clips, (l) => l.list);
22
+ const files = useStore(media.files, (l) => l.list);
23
+ const drop = useDrop((list) => media.upload(list));
24
+ const file = item && isFileKey(item) ? files?.find((f) => f.key === item) : null;
25
+ if (file) {
26
+ return (
27
+ <div className="stage-lb" data-file-stage="">
28
+ <FileTile type={file.type} size="lg" />
29
+ <h2>{file.name}</h2>
30
+ <p className="fine">
31
+ {file.type}
32
+ {file.bytes !== null ? ` · ${fileSize(file.bytes)}` : ''}
33
+ </p>
34
+ <a className="btn" href={fileAddress(file.key)} target="_blank" rel="noopener">
35
+ <Icon name="ext" size="sm" />
36
+ Open in a new tab
37
+ </a>
38
+ </div>
39
+ );
40
+ }
41
+ if (item) {
42
+ const image = images?.find((x) => x.key === item);
43
+ const clip = clips?.find((c) => clipId(c) === item);
44
+ const x = image ?? clip;
45
+ if (x) {
46
+ const ratio = x.w && x.h ? (x.w / x.h).toFixed(4) : '1.5';
47
+ const src = image ? image.thumbnail2x : clip?.playbackId ? clipStill(clip.playbackId, 0, 1280) : null;
48
+ return (
49
+ <div className="stage-lb">
50
+ <span className="ph" style={{ ['--r' as string]: ratio }}>
51
+ {src ? <img className="ph-img" src={src} alt="" /> : null}
52
+ </span>
53
+ <p className="cap">
54
+ <b>{image ? image.name : (clip?.title ?? 'A clip')}</b>
55
+ {x.w && x.h ? ` · ${x.w} × ${x.h}` : ''}
56
+ </p>
57
+ </div>
58
+ );
59
+ }
60
+ }
61
+ return (
62
+ <div className={`stage-drop${drop.over ? ' is-over' : ''}`} data-drop="lib" {...drop.props}>
63
+ <Icon name="upload" />
64
+ <h2>Drop images, clips or files here</h2>
65
+ <p className="fine">{TAKES}, up to 40 MB and 50 megapixels. Files: PDF, Word, Excel, PowerPoint, CSV and Zip, up to 40 MB.</p>
66
+ <div className="acts">
67
+ <UploadButton className="btn" />
68
+ <button type="button" className="btn ghost" onClick={onBack}>
69
+ <Icon name="chev-l" size="sm" />
70
+ Back to {back}
71
+ </button>
72
+ </div>
73
+ </div>
74
+ );
75
+ }
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Replace… on a file (M6c G4, Decision 9; the mockup's #replace, #replace-uploading,
3
+ * #replace-ready, #replace-blocked, #replace-same, #replace-failed, #replace-done), in the
4
+ * editor's `#modal-root` (documents/dialogs.tsx → `{ kind: 'replace', key }`).
5
+ *
6
+ * 1. **Choose**: drop or choose the new version, any type the library takes.
7
+ * 2. **Uploading**: its real progress (media/store.ts → `uploadFile`); nothing changes yet.
8
+ * 3. **Ready**: the old file → the new one, every document that will change (the
9
+ * listing's `usedIn`), that they become waiting changes (live mode), and that the old
10
+ * address keeps working.
11
+ * 4. **Replace and update N links** (documents/actions.ts → `replaceFile`): refused while
12
+ * this tab holds unsent edits (it can start once they've gone through: the button wakes
13
+ * when the bar says Kept); in live mode a failure part way leaves every document as it
14
+ * was ("Nothing was changed", Try again).
15
+ * 5. **Done**: where the links moved; the old file stays in the library, at its address.
16
+ *
17
+ * **The same bytes as the old file** (the upload answered with the old file's own key):
18
+ * there's nothing to replace, and it says so. Bytes already in the library under another
19
+ * name are fine: Replace uses that file (Decision 9), which is also how a retry after a
20
+ * failed or cancelled Replace works, the new bytes having gone up first.
21
+ */
22
+ import { useEffect, useMemo, useRef, useState } from 'react';
23
+ import { useEditor, useStore } from '../context.tsx';
24
+ import { closeDialog, Frame } from '../documents/dialogs.tsx';
25
+ import { replaceFile } from '../documents/actions.ts';
26
+ import { useDrop } from '../fields/MediaField.tsx';
27
+ import { FileTile, FILE_ACCEPT, rowDate, timeZoneOf } from '../pane/levels/MediaLevel.tsx';
28
+ import { Icon } from '../shell/Icons.tsx';
29
+ import type { KeepStatus } from '../state/keeper.ts';
30
+ import { createStore } from '../state/store.ts';
31
+ import type { FileUse, LibraryFile } from '../../types.ts';
32
+ import { fileSize, mediaFor } from './store.ts';
33
+
34
+ type Phase =
35
+ | { at: 'choose' }
36
+ | { at: 'uploading'; name: string; bytes: number; tag: string }
37
+ | { at: 'ready'; to: LibraryFile; busy?: boolean; failed?: string; blocked?: string }
38
+ | { at: 'same'; name: string; match: LibraryFile }
39
+ | { at: 'done'; to: LibraryFile; relinked: FileUse[] };
40
+
41
+ const links = (n: number) => `${n} link${n === 1 ? '' : 's'}`;
42
+ const docs = (n: number) => `${n} document${n === 1 ? '' : 's'}`;
43
+ /** "Services", "Services and About", "Services, About and Contact". */
44
+ const names = (list: readonly string[]) => (list.length <= 1 ? (list[0] ?? '') : `${list.slice(0, -1).join(', ')} and ${list.at(-1)}`);
45
+
46
+ let tags = 0;
47
+
48
+ export function ReplaceDialog({ fileKey }: { fileKey: string }) {
49
+ const editor = useEditor();
50
+ const media = mediaFor(editor);
51
+ const files = useStore(media.files, (l) => l.list);
52
+ const uploads = useStore(media.uploads);
53
+ const kept = useMemo(() => createStore<KeepStatus>({ state: 'kept', error: null }), []);
54
+ const keep = useStore(editor.keeper?.status ?? kept, (k) => k.state);
55
+ const [phase, setPhase] = useState<Phase>({ at: 'choose' });
56
+ const [error, setError] = useState<string | null>(null);
57
+ const input = useRef<HTMLInputElement>(null);
58
+ const old = files?.find((f) => f.key === fileKey) ?? null;
59
+ const live = editor.mode === 'live';
60
+ const zone = timeZoneOf(editor);
61
+
62
+ // Blocked by unsent edits: the button wakes once the bar says Kept (the keeper retries on its own).
63
+ useEffect(() => {
64
+ if (phase.at === 'ready' && phase.blocked && keep === 'kept') setPhase({ ...phase, blocked: undefined });
65
+ }, [keep, phase]);
66
+
67
+ const take = async (file: File) => {
68
+ setError(null);
69
+ const tag = `replace-${++tags}`;
70
+ setPhase({ at: 'uploading', name: file.name, bytes: file.size, tag });
71
+ const sent = await media.uploadFile(file, tag);
72
+ if (!sent.ok) {
73
+ setPhase({ at: 'choose' });
74
+ if (sent.error) setError(sent.error);
75
+ return;
76
+ }
77
+ // The same bytes are the same key: the old file itself, so nothing to replace. A different file already in
78
+ // the library (`created: false`, e.g. the new bytes from an earlier try) is used as it is.
79
+ if (sent.file.key === fileKey) setPhase({ at: 'same', name: file.name, match: sent.file });
80
+ else setPhase({ at: 'ready', to: sent.file });
81
+ };
82
+ const drop = useDrop((list) => {
83
+ if (list[0] && (phase.at === 'choose' || phase.at === 'same' || phase.at === 'ready')) void take(list[0]);
84
+ });
85
+
86
+ if (!old) return null;
87
+ const uses = old.usedIn;
88
+ const total = uses.reduce((n, u) => n + u.links, 0);
89
+
90
+ const go = async () => {
91
+ if (phase.at !== 'ready') return;
92
+ setPhase({ ...phase, busy: true, failed: undefined });
93
+ const answer = await replaceFile(editor, old.key, phase.to.key);
94
+ if (answer.ok) {
95
+ void media.loadImages();
96
+ setPhase({ at: 'done', to: phase.to, relinked: answer.body.relinked });
97
+ const n = answer.body.relinked.reduce((k, r) => k + r.links, 0);
98
+ editor.announce(n ? `${links(n)} now point at ${phase.to.name}.` : `Nothing linked to ${old.name}.`);
99
+ } else if (answer.code === 'unsent') setPhase({ ...phase, busy: false, blocked: answer.error });
100
+ else setPhase({ ...phase, busy: false, failed: answer.error });
101
+ };
102
+
103
+ const oldRow = (
104
+ <div className="frow old">
105
+ <FileTile />
106
+ <span className="ft">
107
+ <b>{old.name}</b>
108
+ <small>
109
+ <span className="ty">{old.type}</span>
110
+ {old.bytes !== null ? ` · ${fileSize(old.bytes)}` : ''}
111
+ {old.uploadedAt ? ` · ${rowDate(old.uploadedAt, zone)}` : ''} · linked from {uses.length}
112
+ </small>
113
+ </span>
114
+ </div>
115
+ );
116
+ const arrow = (
117
+ <div className="arrow">
118
+ <Icon name="down" size="xs" />
119
+ replaced by
120
+ </div>
121
+ );
122
+ const change = (
123
+ <button type="button" className="btn sm ghost" onClick={() => input.current?.click()}>
124
+ Change…
125
+ </button>
126
+ );
127
+ const picker = (
128
+ <input
129
+ ref={input}
130
+ type="file"
131
+ hidden
132
+ accept={FILE_ACCEPT}
133
+ data-replace-file=""
134
+ onChange={(e) => {
135
+ const file = e.target.files?.[0];
136
+ e.target.value = '';
137
+ if (file) void take(file);
138
+ }}
139
+ />
140
+ );
141
+ const head = (
142
+ <>
143
+ <h2 id="dlg-h">Replace {old.name}</h2>
144
+ <p>Every link moves to the new version.</p>
145
+ </>
146
+ );
147
+ const cancel = (
148
+ <button type="button" className="btn" onClick={() => closeDialog(editor)}>
149
+ Cancel
150
+ </button>
151
+ );
152
+
153
+ if (phase.at === 'done') {
154
+ const moved = phase.relinked.reduce((n, r) => n + r.links, 0);
155
+ return (
156
+ <Frame label="replace-done">
157
+ <div className="icon-h live">
158
+ <Icon name="check" />
159
+ </div>
160
+ <h2 id="dlg-h">{moved ? 'The links now point at the new file' : 'Nothing linked to the old file'}</h2>
161
+ {moved ? (
162
+ <p>
163
+ {links(moved)} in {names(phase.relinked.map((r) => r.title))} point at <b style={{ color: 'var(--text)' }}>{phase.to.name}</b>.{' '}
164
+ {live ? 'Live at the next Publish.' : 'The pages are saved with the new address.'}
165
+ </p>
166
+ ) : null}
167
+ {phase.relinked.length ? (
168
+ <div className="affects" style={{ background: 'var(--surface)' }}>
169
+ <ul style={{ margin: 0 }}>
170
+ {phase.relinked.map((r) => (
171
+ <li key={`${r.collection}/${r.path}`}>
172
+ <Icon name="page" size="sm" />
173
+ <span className="nm">{r.title}</span>
174
+ <span className="meta">{links(r.links)}</span>
175
+ {live ? <span className="dot" title="Changes waiting" /> : null}
176
+ </li>
177
+ ))}
178
+ </ul>
179
+ </div>
180
+ ) : null}
181
+ <div className="acts">
182
+ <button
183
+ type="button"
184
+ className="btn"
185
+ onClick={() => {
186
+ closeDialog(editor);
187
+ editor.openMedia({ item: phase.to.key, tab: 'files' });
188
+ }}
189
+ >
190
+ Open the new file
191
+ </button>
192
+ <button type="button" className="btn ink" data-autofocus="" onClick={() => closeDialog(editor)}>
193
+ Done
194
+ </button>
195
+ </div>
196
+ </Frame>
197
+ );
198
+ }
199
+
200
+ let middle;
201
+ let note = null;
202
+ let action = (
203
+ <button type="button" className="btn ink" disabled>
204
+ Replace
205
+ </button>
206
+ );
207
+ if (phase.at === 'choose') {
208
+ middle = (
209
+ <div className="swap">
210
+ {oldRow}
211
+ {arrow}
212
+ <button type="button" className={`mdrop${drop.over ? ' is-over' : ''}`} data-replace-drop="" onClick={() => input.current?.click()} {...drop.props}>
213
+ <Icon name="upload" />
214
+ <b>Drop the new version here</b>
215
+ <span>or choose a file</span>
216
+ <span className="fine">PDF, Word, Excel, PowerPoint, CSV or Zip, up to 40 MB</span>
217
+ </button>
218
+ </div>
219
+ );
220
+ if (error)
221
+ note = (
222
+ <div className="note danger" role="alert">
223
+ <Icon name="warn" />
224
+ <div>
225
+ <b>That file wasn’t taken</b>
226
+ <p>{error}</p>
227
+ </div>
228
+ </div>
229
+ );
230
+ } else if (phase.at === 'uploading') {
231
+ const pct = uploads.find((u) => u.id === phase.tag)?.pct ?? 0;
232
+ middle = (
233
+ <div className="swap">
234
+ {oldRow}
235
+ {arrow}
236
+ <div className="frow new" role="status" aria-label={`${phase.name}, uploading, ${pct}%`}>
237
+ <FileTile />
238
+ <span className="ft">
239
+ <b>{phase.name}</b>
240
+ <small>
241
+ {fileSize(phase.bytes)} · {pct ? `Uploading · ${pct}%` : 'Waiting…'}
242
+ </small>
243
+ <span className="mbar">
244
+ <i style={{ ['--p' as string]: `${pct}%` }} />
245
+ </span>
246
+ </span>
247
+ </div>
248
+ </div>
249
+ );
250
+ } else if (phase.at === 'same') {
251
+ middle = (
252
+ <div className="swap" {...drop.props}>
253
+ {oldRow}
254
+ {arrow}
255
+ <div className="frow new">
256
+ <FileTile />
257
+ <span className="ft">
258
+ <b>{phase.name}</b>
259
+ <small>
260
+ <span className="ty">{phase.match.type}</span>
261
+ {phase.match.bytes !== null ? ` · ${fileSize(phase.match.bytes)}` : ''} · the same file
262
+ </small>
263
+ </span>
264
+ {change}
265
+ </div>
266
+ </div>
267
+ );
268
+ note = (
269
+ <div className="note" role="status" data-replace-same="">
270
+ <Icon name="copy" />
271
+ <div>
272
+ <b>That’s the file that’s already here</b>
273
+ <p>Same contents as {old.name}. Choose the new version.</p>
274
+ </div>
275
+ </div>
276
+ );
277
+ } else {
278
+ middle = (
279
+ <>
280
+ <div className="swap" {...drop.props}>
281
+ {oldRow}
282
+ {arrow}
283
+ <div className="frow new">
284
+ <FileTile />
285
+ <span className="ft">
286
+ <b>{phase.to.name}</b>
287
+ <small>
288
+ <span className="ty">{phase.to.type}</span>
289
+ {phase.to.bytes !== null ? ` · ${fileSize(phase.to.bytes)}` : ''} · <span className="new">Just uploaded</span>
290
+ </small>
291
+ </span>
292
+ {phase.busy ? null : change}
293
+ </div>
294
+ </div>
295
+ <div className="affects">
296
+ <b>{total ? `Updates ${links(total)} in ${docs(uses.length)}` : 'Nothing links to it yet'}</b>
297
+ <p>{total ? (live ? 'Live at the next Publish.' : 'Each page is saved with the new address.') : 'No page changes.'}</p>
298
+ {uses.length ? (
299
+ <ul>
300
+ {uses.map((u) => (
301
+ <li key={`${u.collection}/${u.path}`}>
302
+ <Icon name="page" size="sm" />
303
+ <span className="nm">{u.title}</span>
304
+ <span className="meta">{links(u.links)}</span>
305
+ </li>
306
+ ))}
307
+ </ul>
308
+ ) : null}
309
+ </div>
310
+ <p className="keeps">
311
+ <Icon name="clock" size="sm" />
312
+ <span>The old address keeps working.</span>
313
+ </p>
314
+ </>
315
+ );
316
+ if (phase.blocked)
317
+ note = (
318
+ <div className="note warn" role="alert" data-replace-blocked="">
319
+ <Icon name="warn" />
320
+ <div>
321
+ <b>{phase.blocked}</b>
322
+ <p>It starts once they have.</p>
323
+ </div>
324
+ </div>
325
+ );
326
+ else if (phase.failed)
327
+ note = (
328
+ <div className="note danger" role="alert" data-replace-failed="">
329
+ <Icon name="warn" />
330
+ <div>
331
+ <b>Nothing was changed</b>
332
+ <p>{phase.failed} Try again.</p>
333
+ </div>
334
+ </div>
335
+ );
336
+ action = (
337
+ <button type="button" className="btn ink" data-act="replace-go" disabled={!!phase.busy || !!phase.blocked} onClick={() => void go()}>
338
+ {phase.busy ? 'Replacing…' : phase.failed ? 'Try again' : total ? `Replace and update ${links(total)}` : 'Replace'}
339
+ </button>
340
+ );
341
+ }
342
+
343
+ return (
344
+ <Frame label="replace">
345
+ {head}
346
+ {middle}
347
+ {note}
348
+ {picker}
349
+ <div className="acts">
350
+ {cancel}
351
+ {action}
352
+ </div>
353
+ </Frame>
354
+ );
355
+ }