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,71 @@
1
+ /**
2
+ * The block list's link to the rest of the editor: which block row is hovered, and
3
+ * where the add-block picker is open. One pair of stores per editor (a WeakMap, so
4
+ * the server render's editors don't share anything with the browser's).
5
+ *
6
+ * ## The hover hook (for the preview link)
7
+ *
8
+ * blockHover(editor) Store<string | null>: the hovered block's key
9
+ * setBlockHover(editor, key | null)
10
+ *
11
+ * The block list sets it while the pointer is over a row (or a row has keyboard
12
+ * focus) and draws a row as hovered (`.hov`) whenever it holds that row's key, so it
13
+ * works both ways: the preview's overlays can read it to outline the block on the
14
+ * page (`data-cms-block` = `editor.doc.indexOf(key)`), and set it when the pointer
15
+ * is over a block on the page to light up its row.
16
+ *
17
+ * **Selected** is `editor.view.block` (the block level's block, kept when you go
18
+ * back up to the list), drawn as `.is-current`.
19
+ *
20
+ * ## The picker
21
+ *
22
+ * pickerAt(editor) Store<number | null>: the index a picked block goes in, or closed
23
+ * openPicker(editor, at) opens it on the document level (the "Add block" row, the empty
24
+ * page, and later the preview's insert points)
25
+ * closePicker(editor)
26
+ */
27
+ import type { Editor } from '../editor.ts';
28
+ import { createStore, type Store } from '../state/store.ts';
29
+
30
+ interface Link {
31
+ hover: Store<string | null>;
32
+ picker: Store<number | null>;
33
+ }
34
+
35
+ const links = new WeakMap<Editor, Link>();
36
+
37
+ function link(editor: Editor): Link {
38
+ let found = links.get(editor);
39
+ if (!found) {
40
+ found = { hover: createStore<string | null>(null), picker: createStore<number | null>(null) };
41
+ const made = found;
42
+ // Another document opened: nothing is hovered, and the picker closes.
43
+ editor.doc.subscribe((_, change) => {
44
+ if (change.kind === 'load') {
45
+ made.hover.set(null);
46
+ made.picker.set(null);
47
+ }
48
+ if (change.kind === 'remove' && made.hover.get() === change.key) made.hover.set(null);
49
+ });
50
+ // The picker belongs to the document level: leaving it (up, or into a block) closes it.
51
+ editor.view.subscribe((view) => {
52
+ if (view.level !== 'document' && made.picker.get() !== null) made.picker.set(null);
53
+ });
54
+ links.set(editor, found);
55
+ }
56
+ return found;
57
+ }
58
+
59
+ export const blockHover = (editor: Editor): Store<string | null> => link(editor).hover;
60
+ export const setBlockHover = (editor: Editor, key: string | null) => link(editor).hover.set(key);
61
+
62
+ export const pickerAt = (editor: Editor): Store<number | null> => link(editor).picker;
63
+
64
+ export function openPicker(editor: Editor, at: number) {
65
+ const length = editor.doc.get().document.blocks.length;
66
+ editor.setPop(null);
67
+ editor.goTo('document');
68
+ link(editor).picker.set(Math.max(0, Math.min(length, Math.trunc(at))));
69
+ }
70
+
71
+ export const closePicker = (editor: Editor) => link(editor).picker.set(null);
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Reordering rows by drag and by keys (M2's keys rule): the block list's mechanism,
3
+ * shared with M5a's list field (Decision 5: rows reorder "as blocks do").
4
+ *
5
+ * - `useRowDrag`: HTML drag and drop over a list of rows. The lifted row turns into
6
+ * a ghost a frame later (so the drag image is the real row); the drop lands before
7
+ * or after the row under the pointer, by its middle; the drop line is full width
8
+ * (the row's `drop-before` / `drop-after` class, never a side rail).
9
+ * - `rowKeyAction`: what a key on a focused row does: ⌥↑/⌥↓ move it, ⌫ removes it,
10
+ * ↑/↓ move focus between rows.
11
+ */
12
+ import { useState, type DragEvent, type KeyboardEvent } from 'react';
13
+
14
+ export interface RowDrag {
15
+ key: string;
16
+ /** Where it would land: before or after this row. */
17
+ drop: { key: string; after: boolean } | null;
18
+ /** The lifted row is drawn as a ghost (a frame later, so the drag image is the real row). */
19
+ ghost: boolean;
20
+ }
21
+
22
+ export interface RowDragApi {
23
+ drag: RowDrag | null;
24
+ cancel(): void;
25
+ /** On a row's `dragstart`. */
26
+ start(event: DragEvent, key: string): void;
27
+ /** On the list's `dragover`. */
28
+ over(event: DragEvent): void;
29
+ /** On the list's `drop`. Moves the dragged row by `onMove(key, toIndex)`. */
30
+ drop(event: DragEvent): void;
31
+ /** The row's drag classes: `is-ghost`, `drop-before`, `drop-after`. */
32
+ classes(key: string): string;
33
+ }
34
+
35
+ export function useRowDrag(options: {
36
+ /** The row element an event is in (its key read by `keyOf`), or null. */
37
+ rowOf: (target: Element) => HTMLElement | null;
38
+ keyOf: (row: HTMLElement) => string;
39
+ indexOf: (key: string) => number;
40
+ onMove: (key: string, to: number) => void;
41
+ onStart?: () => void;
42
+ }): RowDragApi {
43
+ const [drag, setDrag] = useState<RowDrag | null>(null);
44
+ return {
45
+ drag,
46
+ cancel: () => setDrag(null),
47
+ start(event, key) {
48
+ event.dataTransfer.effectAllowed = 'move';
49
+ event.dataTransfer.setData('text/plain', key);
50
+ options.onStart?.();
51
+ setDrag({ key, drop: null, ghost: false });
52
+ requestAnimationFrame(() => setDrag((d) => (d && d.key === key ? { ...d, ghost: true } : d)));
53
+ },
54
+ over(event) {
55
+ if (!drag) return;
56
+ event.preventDefault();
57
+ event.dataTransfer.dropEffect = 'move';
58
+ const row = options.rowOf(event.target as Element);
59
+ if (!row) return;
60
+ const box = row.getBoundingClientRect();
61
+ const drop = { key: options.keyOf(row), after: event.clientY - box.top > box.height / 2 };
62
+ if (drag.drop?.key !== drop.key || drag.drop.after !== drop.after) setDrag({ ...drag, drop });
63
+ },
64
+ drop(event) {
65
+ if (!drag?.drop) return;
66
+ event.preventDefault();
67
+ const from = options.indexOf(drag.key);
68
+ let to = options.indexOf(drag.drop.key) + (drag.drop.after ? 1 : 0);
69
+ if (from < to) to -= 1;
70
+ setDrag(null);
71
+ options.onMove(drag.key, to);
72
+ },
73
+ classes(key) {
74
+ if (!drag) return '';
75
+ const lifted = drag.key === key;
76
+ return [lifted && drag.ghost ? 'is-ghost' : '', drag.drop?.key === key && !lifted ? (drag.drop.after ? 'drop-after' : 'drop-before') : ''].filter(Boolean).join(' ');
77
+ },
78
+ };
79
+ }
80
+
81
+ export type RowKeyAction = { kind: 'move'; to: number } | { kind: 'remove' } | { kind: 'focus'; to: number } | null;
82
+
83
+ /**
84
+ * A key on a focused row at `index` of `count` (M2's keys rule): ⌥↑/⌥↓ move it,
85
+ * ⌫ (or Delete) removes it, ↑/↓ move focus. Calls preventDefault for what it takes.
86
+ * Null for any other key, or a move or focus past either end.
87
+ */
88
+ export function rowKeyAction(event: KeyboardEvent, index: number, count: number): RowKeyAction {
89
+ if (event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
90
+ event.preventDefault();
91
+ const to = index + (event.key === 'ArrowUp' ? -1 : 1);
92
+ return to < 0 || to >= count ? null : { kind: 'move', to };
93
+ }
94
+ if ((event.key === 'Backspace' || event.key === 'Delete') && !event.metaKey && !event.ctrlKey && !event.altKey) {
95
+ event.preventDefault();
96
+ return { kind: 'remove' };
97
+ }
98
+ if (!event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
99
+ const to = index + (event.key === 'ArrowDown' ? 1 : -1);
100
+ if (to < 0 || to >= count) return null;
101
+ event.preventDefault();
102
+ return { kind: 'focus', to };
103
+ }
104
+ return null;
105
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * What a block row says about its block, pure for the unit tests:
3
+ *
4
+ * blockSummary(site, block) a known block's first lockup or text; an unknown block's
5
+ * type and what's kept
6
+ * keptSummary(data) an unknown block's top-level keys, each with a short preview of
7
+ * its value (Decision 21): `Heading: “Kind words” · Quotes: 3 items`
8
+ * blockIcon(type) the sprite's block icon for a type, if it has one
9
+ * fieldsSummary(block) the picker's line under a type: its fields' labels
10
+ */
11
+ import { own, sharedEntryOf } from '../../../document.ts';
12
+ import { parseField, titleCase, type Field } from '../../../fields.ts';
13
+ import { richTextPlain, type RichTextValue } from '../../../rich-text.ts';
14
+ import type { EditorBlock, EditorSite, ListedDocument } from '../../types.ts';
15
+
16
+ /**
17
+ * A page's shared entry (M5a Decision 8) as listed, when it renders: the shared block
18
+ * exists and its type is one the site knows. Null for a block that isn't an entry, and
19
+ * for an entry that's skipped (no such shared block, or a type the code doesn't know).
20
+ */
21
+ export function sharedOf(site: Pick<EditorSite, 'blocks'>, documents: readonly ListedDocument[], block: Pick<EditorBlock, 'known' | 'data' | 'shared'>): ListedDocument | null {
22
+ const name = sharedEntryOf(block);
23
+ if (name === undefined) return null;
24
+ const found = documents.find((d) => d.collection === 'shared' && d.path === name && !d.warning);
25
+ return found && found.blockType && own(site.blocks, found.blockType) ? found : null;
26
+ }
27
+
28
+ /** "Used on 4 pages" (N counts documents of every collection), or "Not on any page yet". */
29
+ export const usedOnLine = (doc: Pick<ListedDocument, 'usedOn'>) => {
30
+ const n = doc.usedOn?.length ?? 0;
31
+ return n ? `Used on ${n} page${n === 1 ? '' : 's'}` : 'Not on any page yet';
32
+ };
33
+
34
+ /** A short preview of a value. */
35
+ export function previewValue(value: unknown, max = 24): string {
36
+ if (value === null || value === undefined) return 'empty';
37
+ if (typeof value === 'string') {
38
+ const flat = value.replace(/\s+/g, ' ').trim();
39
+ if (!flat) return 'empty';
40
+ return `“${flat.length > max ? `${flat.slice(0, max - 1).trimEnd()}…` : flat}”`;
41
+ }
42
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
43
+ if (Array.isArray(value)) {
44
+ if (value.length === 0) return 'empty';
45
+ if (value.every((v) => typeof v === 'string')) return previewValue(value.join(' / '), max);
46
+ return `${value.length} item${value.length === 1 ? '' : 's'}`;
47
+ }
48
+ if (typeof value === 'object') {
49
+ const n = Object.keys(value as object).length;
50
+ return n === 0 ? 'empty' : `${n} field${n === 1 ? '' : 's'}`;
51
+ }
52
+ return String(value);
53
+ }
54
+
55
+ /** An unknown block's kept keys (its `data` is its raw keys minus `type`), each with a preview. */
56
+ export function keptSummary(data: Record<string, unknown>): { key: string; label: string; preview: string }[] {
57
+ return Object.entries(data).map(([key, value]) => ({ key, label: titleCase(key), preview: previewValue(value) }));
58
+ }
59
+
60
+ export function keptLine(data: Record<string, unknown>): string {
61
+ const kept = keptSummary(data);
62
+ return kept.length ? kept.map((k) => `${k.label}: ${k.preview}`).join(' · ') : 'Nothing but its type';
63
+ }
64
+
65
+ /**
66
+ * The row's second line. A shared-block entry (M5a Decision 8) names its shared block.
67
+ * M6 Decision 17: a block that picks one of the site's `forms` (a select of their ids, the
68
+ * Form block) adds the form: "Get in touch · Contact form".
69
+ */
70
+ export function blockSummary(site: Pick<EditorSite, 'blocks'>, block: EditorBlock, forms: readonly { id: string; label: string }[] = []): string {
71
+ const shared = sharedEntryOf(block);
72
+ if (shared !== undefined) return `Shared block · ${shared}`;
73
+ if (!block.known) return `${block.type || 'No type'} · ${keptLine(block.data)}`;
74
+ const fields = own(site.blocks, block.type)?.fields ?? {};
75
+ const ids = forms.map((f) => f.id).sort().join('\n');
76
+ const picker = ids ? Object.entries(fields).find(([, f]) => f.kind === 'select' && f.options.map((o) => o.value).sort().join('\n') === ids) : undefined;
77
+ if (picker) {
78
+ const chosen = forms.find((f) => f.id === block.data[picker[0]]);
79
+ const first = firstText(fields, block);
80
+ return `${first ? `${first} · ` : ''}${chosen ? `${chosen.label} form` : 'No form chosen'}`;
81
+ }
82
+ return firstText(fields, block) ?? 'Nothing in it yet';
83
+ }
84
+
85
+ function firstText(fields: Record<string, Field>, block: Pick<EditorBlock, 'data'>): string | null {
86
+ for (const [key, field] of Object.entries(fields)) {
87
+ const value = block.data[key];
88
+ // A group's own first text (a two-column block's left heading).
89
+ if (field.kind === 'group' && value && typeof value === 'object') {
90
+ const inner = firstText(field.fields, { data: value as Record<string, unknown> });
91
+ if (inner) return inner;
92
+ }
93
+ if (field.kind === 'lockup' && value && typeof value === 'object') {
94
+ const lines = (value as { lines?: string[] }).lines ?? [];
95
+ if (lines.length) return lines.join(' / ');
96
+ }
97
+ if (field.kind === 'text' && typeof value === 'string' && value.trim()) return value.replace(/\s+/g, ' ').trim();
98
+ // M9 Decision 11: a rich heading's words, without its bold.
99
+ if (field.kind === 'richText' && field.heading) {
100
+ const words = richTextPlain(parseField(field, value) as RichTextValue).replace(/\s+/g, ' ').trim();
101
+ if (words) return words;
102
+ }
103
+ }
104
+ return null;
105
+ }
106
+
107
+ /** The block icons the shell's sprite has (shell/Icons.tsx, `b-*`). */
108
+ const SPRITE = new Set(['hero', 'head', 'tm', 'cases', 'cta', 'footer', 'team', 'quote', 'gallery', 'video', 'form', 'faq']);
109
+
110
+ export function blockIcon(block: { type: string; known: boolean }): string {
111
+ if (!block.known) return 'b-unknown';
112
+ return SPRITE.has(block.type) ? `b-${block.type}` : 'b-tm';
113
+ }
114
+
115
+ export function fieldsSummary(schema: { fields: Record<string, { label?: string }> } | undefined): string {
116
+ const labels = Object.entries(schema?.fields ?? {}).map(([key, f]) => f.label ?? titleCase(key));
117
+ return labels.length ? labels.join(', ') : 'Nothing to set';
118
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * M6c (G5): a flat collection's documents as a grid of cards over the stage, beside
3
+ * the table (CollectionTable.tsx draws the head, the find, filters, order and the
4
+ * switch; the rows here are the same view's: viewRows). Mockup: m6c-downloads-grid.html
5
+ * → #grid, #grid-text, #grid-loading.
6
+ *
7
+ * A card is the sidebar's row grown up: its picture, the title with the row's marks,
8
+ * and up to two captions (table.ts → captionColumns). The picture arrives already
9
+ * cropped to the collection's ratio by the server, signed, in the listing
10
+ * (`doc.card`, editor/server/cards.ts): the box holds the ratio before it lands (no
11
+ * layout shift, no skeleton) and the image fades in once, when it has loaded. No
12
+ * picture: the text card, the title set in the picture's place. Someone's lock is a
13
+ * chip on the picture; the open document is a wash with a full ring (no side rails).
14
+ */
15
+ import type { CSSProperties } from 'react';
16
+ import { useEditor, useStore } from '../context.tsx';
17
+ import { Icon } from '../shell/Icons.tsx';
18
+ import { docIcon } from '../nav/DocRow.tsx';
19
+ import { Avatar, DocMarksView } from '../live/marks.tsx';
20
+ import { displayName } from '../live/format.ts';
21
+ import { fmtDur } from '../fields/MediaField.tsx';
22
+ import { docId, type CardPicture, type EditorAnyCollection, type ListedDocument } from '../../types.ts';
23
+ import { ratioValue } from '../../../cards.ts';
24
+ import { cellText, type RefIndex, type TableColumn } from './table.ts';
25
+
26
+ export function CollectionGrid(props: {
27
+ collection: EditorAnyCollection;
28
+ rows: readonly ListedDocument[];
29
+ captions: readonly TableColumn[];
30
+ currentId: string;
31
+ edited: (ms: number) => string;
32
+ /** Every listed document, for a reference caption's title (table.ts → refIndex). */
33
+ refs: RefIndex;
34
+ open: (doc: ListedDocument) => void;
35
+ }) {
36
+ const ratio = props.collection.view?.ratio ?? '4:3';
37
+ const style = { '--card-ratio': String(ratioValue(ratio)) } as CSSProperties;
38
+ return (
39
+ <div className="cgrid" data-ratio={ratio} style={style}>
40
+ {props.rows.map((d) => (
41
+ <Card key={d.path} doc={d} collection={props.collection} captions={props.captions} current={docId(d) === props.currentId} edited={props.edited} refs={props.refs} open={props.open} />
42
+ ))}
43
+ </div>
44
+ );
45
+ }
46
+
47
+ function Card(props: { doc: ListedDocument; collection: EditorAnyCollection; captions: readonly TableColumn[]; current: boolean; edited: (ms: number) => string; refs: RefIndex; open: (doc: ListedDocument) => void }) {
48
+ const editor = useEditor();
49
+ const { doc } = props;
50
+ const id = `card-${docId(doc).replace(/[^a-zA-Z0-9-]/g, '-')}`;
51
+ const live = editor.mode === 'live';
52
+ return (
53
+ <button
54
+ type="button"
55
+ className={['card', props.current ? 'is-current' : '', doc.warning ? 'is-warned' : ''].filter(Boolean).join(' ')}
56
+ data-doc={docId(doc)}
57
+ aria-current={props.current ? 'page' : undefined}
58
+ aria-labelledby={`${id}-t`}
59
+ aria-describedby={props.captions.length ? `${id}-c` : undefined}
60
+ title={doc.warning?.message}
61
+ onClick={() => props.open(doc)}
62
+ >
63
+ <span className="card-pic">
64
+ {doc.card ? <Picture card={doc.card} /> : (
65
+ <span className="ph tcard" data-card="text">
66
+ <Icon name={docIcon(props.collection)} />
67
+ <span className="tc-t">{doc.label}</span>
68
+ </span>
69
+ )}
70
+ {live ? <CardLock file={doc.file} /> : null}
71
+ </span>
72
+ <span className="card-txt">
73
+ <span className="card-t" id={`${id}-t`}>
74
+ <span className="nm">{doc.label}</span>
75
+ <span className="meta">
76
+ {doc.warning ? (
77
+ <span className="wmark" data-warning={doc.warning.code} title={doc.warning.message}>
78
+ <Icon name="warn" size="xs" />
79
+ <span className="sr">, can't be opened: {doc.warning.message}</span>
80
+ </span>
81
+ ) : null}
82
+ {live ? <DocMarksView file={doc.file} compact lock={false} /> : null}
83
+ {live ? <CardLock file={doc.file} sr /> : null}
84
+ </span>
85
+ </span>
86
+ {props.captions.length ? (
87
+ <span id={`${id}-c`} className="card-cs">
88
+ {props.captions.map((c) => {
89
+ const text = cellText(doc, c, props.edited, props.refs);
90
+ return text ? (
91
+ <span key={c.key} className={`card-c${c.kind === 'address' ? ' mono' : ''}`}>
92
+ {c.kind === 'edited' ? `Edited ${text}` : text}
93
+ </span>
94
+ ) : null;
95
+ })}
96
+ </span>
97
+ ) : null}
98
+ </span>
99
+ </button>
100
+ );
101
+ }
102
+
103
+ /** The picture: an `<img>` at the card's ratio from the listing's signed URLs, which fades in once it has loaded (at once if it already had). */
104
+ function Picture({ card }: { card: CardPicture }) {
105
+ return (
106
+ <span className="ph" data-card={card.kind}>
107
+ <img
108
+ key={card.src}
109
+ className="ph-img card-img"
110
+ src={card.src}
111
+ srcSet={`${card.src} 1x, ${card.src2x} 2x`}
112
+ alt={card.alt}
113
+ loading="lazy"
114
+ decoding="async"
115
+ style={card.kind === 'image' && card.position ? { objectPosition: card.position } : undefined}
116
+ ref={(img) => {
117
+ if (img?.complete && img.naturalWidth > 0) img.classList.add('in', 'now');
118
+ }}
119
+ onLoad={(e) => e.currentTarget.classList.add('in')}
120
+ />
121
+ {card.kind === 'clip' ? (
122
+ <span className="pbadge" title="A clip’s poster">
123
+ <Icon name="play" />
124
+ {fmtDur(card.duration)}
125
+ </span>
126
+ ) : null}
127
+ </span>
128
+ );
129
+ }
130
+
131
+ /** Someone else's lock, said on the picture's top right (`sr`: said only to a screen reader, in the title's marks, so the card's name carries it). */
132
+ function CardLock({ file, sr = false }: { file: string; sr?: boolean }) {
133
+ const editor = useEditor();
134
+ const editing = useStore(editor.marks, (all) => all.get(file)?.editing ?? null);
135
+ if (!editing) return null;
136
+ if (sr) return <span className="sr">, {displayName(editing)} is editing</span>;
137
+ return (
138
+ <span className="chip lock card-lock" data-mark="editing" aria-hidden="true">
139
+ <Avatar person={editing} size="sm" />
140
+ {displayName(editing)} is editing
141
+ </span>
142
+ );
143
+ }