keystone_ui-pages 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.
Files changed (70) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/app/assets/builds/keystone_ui_pages/editor.js +67 -0
  4. data/app/controllers/concerns/keystone_ui/pages/edits_pages.rb +32 -0
  5. data/app/controllers/concerns/keystone_ui/pages/host_checks.rb +25 -0
  6. data/app/controllers/keystone_ui/pages/manage/base_controller.rb +28 -0
  7. data/app/controllers/keystone_ui/pages/manage/columns_controller.rb +15 -0
  8. data/app/controllers/keystone_ui/pages/manage/documents_controller.rb +15 -0
  9. data/app/controllers/keystone_ui/pages/manage/edit_history_controller.rb +21 -0
  10. data/app/controllers/keystone_ui/pages/manage/elements_controller.rb +30 -0
  11. data/app/controllers/keystone_ui/pages/manage/lives_controller.rb +20 -0
  12. data/app/controllers/keystone_ui/pages/manage/nodes_controller.rb +31 -0
  13. data/app/controllers/keystone_ui/pages/manage/pages_controller.rb +41 -0
  14. data/app/controllers/keystone_ui/pages/manage/pdfs_controller.rb +23 -0
  15. data/app/controllers/keystone_ui/pages/manage/rows_controller.rb +15 -0
  16. data/app/controllers/keystone_ui/pages/manage/samples_controller.rb +16 -0
  17. data/app/controllers/keystone_ui/pages/manage/sections_controller.rb +15 -0
  18. data/app/controllers/keystone_ui/pages/manage/versions_controller.rb +25 -0
  19. data/app/controllers/keystone_ui/pages/pages_controller.rb +26 -0
  20. data/app/helpers/keystone_ui/pages/drawing_helper.rb +115 -0
  21. data/app/helpers/keystone_ui/pages/editor_helper.rb +46 -0
  22. data/app/helpers/keystone_ui/pages/host_helper.rb +17 -0
  23. data/app/javascript/keystone_ui_pages/DeviceWidth.jsx +21 -0
  24. data/app/javascript/keystone_ui_pages/ElementFields.jsx +78 -0
  25. data/app/javascript/keystone_ui_pages/ElementPalette.jsx +62 -0
  26. data/app/javascript/keystone_ui_pages/LayoutPanel.jsx +41 -0
  27. data/app/javascript/keystone_ui_pages/ListField.jsx +49 -0
  28. data/app/javascript/keystone_ui_pages/LookChoice.jsx +17 -0
  29. data/app/javascript/keystone_ui_pages/PageEditor.jsx +260 -0
  30. data/app/javascript/keystone_ui_pages/SampleChoice.jsx +15 -0
  31. data/app/javascript/keystone_ui_pages/SelectionPath.jsx +19 -0
  32. data/app/javascript/keystone_ui_pages/SelectionToolbar.jsx +14 -0
  33. data/app/javascript/keystone_ui_pages/ShowWhen.jsx +32 -0
  34. data/app/javascript/keystone_ui_pages/ThemeMode.jsx +23 -0
  35. data/app/javascript/keystone_ui_pages/document.js +10 -0
  36. data/app/javascript/keystone_ui_pages/editor.jsx +8 -0
  37. data/app/javascript/keystone_ui_pages/operations.js +32 -0
  38. data/app/jobs/keystone_ui/pages/pdf_job.rb +11 -0
  39. data/app/models/keystone_ui/pages/page.rb +55 -0
  40. data/app/models/keystone_ui/pages/pdf.rb +12 -0
  41. data/app/models/keystone_ui/pages/version.rb +13 -0
  42. data/app/views/keystone_ui/pages/manage/pages/index.html.erb +35 -0
  43. data/app/views/keystone_ui/pages/manage/pages/new.html.erb +13 -0
  44. data/app/views/keystone_ui/pages/manage/pages/show.html.erb +8 -0
  45. data/app/views/keystone_ui/pages/manage/versions/index.html.erb +28 -0
  46. data/app/views/keystone_ui/pages/manage/versions/show.html.erb +11 -0
  47. data/app/views/keystone_ui/pages/pages/show.html.erb +3 -0
  48. data/app/views/keystone_ui/pages/pdfs/show.html.erb +12 -0
  49. data/config/routes.rb +31 -0
  50. data/db/migrate/20261014120000_create_keystone_ui_pages_pages.rb +17 -0
  51. data/db/migrate/20261014120001_create_keystone_ui_pages_versions.rb +18 -0
  52. data/db/migrate/20261015130000_create_keystone_ui_pages_pdfs.rb +14 -0
  53. data/db/migrate/20261016120000_add_edit_history_to_keystone_ui_pages_pages.rb +8 -0
  54. data/lib/keystone_ui/pages/catalog.rb +29 -0
  55. data/lib/keystone_ui/pages/document.rb +29 -0
  56. data/lib/keystone_ui/pages/engine.rb +25 -0
  57. data/lib/keystone_ui/pages/keystone_elements.rb +109 -0
  58. data/lib/keystone_ui/pages/operations.rb +246 -0
  59. data/lib/keystone_ui/pages/pdf_document.rb +17 -0
  60. data/lib/keystone_ui/pages/pdfs.rb +20 -0
  61. data/lib/keystone_ui/pages/printer.rb +43 -0
  62. data/lib/keystone_ui/pages/values.rb +28 -0
  63. data/lib/keystone_ui/pages/version.rb +7 -0
  64. data/lib/keystone_ui/pages.rb +45 -0
  65. data/lib/keystone_ui-pages.rb +3 -0
  66. data/the_local/agents/keystone_ui-pages-develop.md +277 -0
  67. data/the_local/agents/keystone_ui-pages-info.md +71 -0
  68. data/the_local/agents/keystone_ui-pages-install.md +167 -0
  69. data/the_local/interface.yml +56 -0
  70. metadata +169 -0
@@ -0,0 +1,260 @@
1
+ import React, { useEffect, useLayoutEffect, useRef, useState } from "react"
2
+ import { DragDropManager, Draggable, Droppable, KeyboardSensor, PointerSensor } from "@dnd-kit/dom"
3
+ import { Sortable } from "@dnd-kit/dom/sortable"
4
+ import Button from "keystone_ui-react/src/Button.jsx"
5
+ import { addColumn, addElement, addRow, addSection, arrange, bindField, chooseSample, duplicateNode, moveNode, removeNode, setField, showWhen, stepHistory } from "./operations"
6
+ import LayoutPanel from "./LayoutPanel"
7
+ import DeviceWidth, { DEVICES } from "./DeviceWidth"
8
+ import SelectionToolbar from "./SelectionToolbar"
9
+ import ElementPalette from "./ElementPalette"
10
+ import SelectionPath from "./SelectionPath"
11
+ import SampleChoice from "./SampleChoice"
12
+ import ShowWhen from "./ShowWhen"
13
+ import ThemeMode from "./ThemeMode"
14
+ import LookChoice from "./LookChoice"
15
+ import ElementFields from "./ElementFields"
16
+ import { elementIn, nodeIn } from "./document"
17
+
18
+ const LAYOUT = [
19
+ { kind: "section", selector: "[data-section-id]", id: (element) => element.dataset.sectionId, group: () => "page" },
20
+ { kind: "row", selector: "[data-row-id]", id: (element) => element.dataset.rowId, group: (element) => element.closest("[data-section-id]").dataset.sectionId },
21
+ { kind: "column", selector: "[data-column-id]", id: (element) => element.dataset.columnId, group: (element) => element.closest("[data-row-id]").dataset.rowId }
22
+ ]
23
+
24
+ const NODES = [["[data-element-id]", "Element"], ["[data-column-id]", "Column"], ["[data-row-id]", "Row"], ["[data-section-id]", "Section"]]
25
+
26
+ const innermost = (target) => NODES.map(([selector, kind]) => [target.closest(selector), kind]).filter(([node]) => node)
27
+ .reduce((inner, candidate) => (!inner || inner[0].contains(candidate[0]) ? candidate : inner), null)
28
+
29
+ const PLACED_SENSORS = [PointerSensor.configure({ preventActivation: () => false }), KeyboardSensor]
30
+
31
+ export default function PageEditor({ name, publish_url, elements, canvas: drawn, document: saved, elements_url, sections_url, columns_url, rows_url, nodes_url, arrangements, defaults, shares, value_names, looks = [], samples = [], sample, samples_url, history_url, live_url, back_url, undo_url, redo_url, undoable: couldUndo, redoable: couldRedo, token }) {
32
+ const [canvas, setCanvas] = useState(drawn)
33
+ const [undoable, setUndoable] = useState(couldUndo)
34
+ const [redoable, setRedoable] = useState(couldRedo)
35
+ const [document, setDocument] = useState(saved)
36
+ const [selected, setSelected] = useState(null)
37
+ const [node, setNode] = useState(null)
38
+ const [refusal, setRefusal] = useState(null)
39
+ const [device, setDevice] = useState("desktop")
40
+ const sheet = useRef(null)
41
+ const moveHandle = useRef(null)
42
+ const [toolbarPlace, setToolbarPlace] = useState(null)
43
+ const [selectionPath, setSelectionPath] = useState([])
44
+ const manager = useRef(null)
45
+ const palette = useRef(null)
46
+ const page = useRef(null)
47
+
48
+ const shown = ({ canvas, document, undoable, redoable }) => {
49
+ setRefusal(null)
50
+ setCanvas(canvas)
51
+ setDocument(document)
52
+ if (undoable !== undefined) setUndoable(undoable)
53
+ if (redoable !== undefined) setRedoable(redoable)
54
+ }
55
+
56
+ const refused = ({ error }) => setRefusal(error)
57
+
58
+ const chose = (chosen) => {
59
+ setRefusal(null)
60
+ setNode(chosen)
61
+ }
62
+
63
+ const hovered = (event) => {
64
+ page.current.querySelectorAll("[data-hovered]").forEach((node) => delete node.dataset.hovered)
65
+ const [node, kind] = innermost(event.target) ?? []
66
+ if (!node) return
67
+ node.dataset.hovered = ""
68
+ const placed = kind === "Element" && elementIn(document, node.dataset.elementId)
69
+ node.dataset.kind = placed ? elements.find((offered) => offered.key === placed.key)?.name ?? kind : kind
70
+ }
71
+
72
+ const select = (event) => {
73
+ const chosen = event.target.closest("[data-element-id]")
74
+ if (chosen) {
75
+ event.preventDefault()
76
+ setNode(null)
77
+ return setSelected(chosen.dataset.elementId)
78
+ }
79
+ const column = event.target.closest("[data-column-id]")
80
+ if (!column) return
81
+ setSelected(null)
82
+ setNode({ kind: "column", id: column.dataset.columnId, rowId: column.closest("[data-row-id]").dataset.rowId, sectionId: column.closest("[data-section-id]").dataset.sectionId })
83
+ }
84
+
85
+ const element = selected && elementIn(document, selected)
86
+ const definition = element && elements.find((offered) => offered.key === element.key)
87
+
88
+ useLayoutEffect(() => {
89
+ page.current.innerHTML = canvas
90
+ }, [canvas])
91
+
92
+ useEffect(() => {
93
+ const dragging = new DragDropManager()
94
+ manager.current = dragging
95
+ const items = [...palette.current.querySelectorAll("[data-element-key]")].map((element) =>
96
+ new Draggable({ id: `palette-${element.dataset.elementKey}`, element, feedback: "clone", data: { key: element.dataset.elementKey } }, dragging))
97
+
98
+ const marked = () => page.current.querySelectorAll("[data-drop-target]").forEach((element) => delete element.dataset.dropTarget)
99
+ dragging.monitor.addEventListener("dragover", ({ operation }) => {
100
+ marked()
101
+ if (operation.target?.element) operation.target.element.dataset.dropTarget = ""
102
+ })
103
+
104
+ dragging.monitor.addEventListener("dragend", ({ operation, canceled }) => {
105
+ marked()
106
+ const { source, target } = operation
107
+ if (canceled || !target) return
108
+ if (source.data?.key) return target.data?.columnId && addElement(elements_url, token, source.data.key, target.data.columnId).then(shown)
109
+ if (source.type !== "element") return moveNode(nodes_url, token, source.id, source.group === "page" ? null : source.group, source.index).then(shown, refused)
110
+ const into = source.group === source.initialGroup && target.data?.columnId && target.data.columnId !== source.group ? target.data.columnId : source.group
111
+ const position = into === source.group ? source.index : 0
112
+ moveNode(nodes_url, token, source.id, into, position).then(shown, refused)
113
+ })
114
+
115
+ return () => {
116
+ items.forEach((item) => item.destroy())
117
+ dragging.destroy()
118
+ }
119
+ }, [])
120
+
121
+ useEffect(() => {
122
+ const columns = [...page.current.querySelectorAll("[data-column-id]")].map((element) =>
123
+ new Droppable({ id: `drop-${element.dataset.columnId}`, element, data: { columnId: element.dataset.columnId }, accept: (source) => !source.type || source.type === "element" }, manager.current))
124
+ const placed = [...page.current.querySelectorAll("[data-element-id]")].map((element) => {
125
+ const column = element.closest("[data-column-id]")
126
+ const index = [...column.children].indexOf(element)
127
+ return new Sortable({ id: element.dataset.elementId, element, index, group: column.dataset.columnId, type: "element", accept: "element", sensors: PLACED_SENSORS }, manager.current)
128
+ })
129
+
130
+ return () => [...columns, ...placed].forEach((droppable) => droppable.destroy())
131
+ }, [canvas])
132
+
133
+ const pathTo = (chosen) => [
134
+ ["section", "[data-section-id]", "sectionId", "Section"],
135
+ ["row", "[data-row-id]", "rowId", "Row"],
136
+ ["column", "[data-column-id]", "columnId", "Column"],
137
+ ["element", "[data-element-id]", "elementId", null]
138
+ ].flatMap(([kind, selector, key, label]) => {
139
+ const holder = chosen.closest(selector)
140
+ if (!holder) return []
141
+ const placed = kind === "element" && elementIn(document, holder.dataset[key])
142
+ return [{ kind, id: holder.dataset[key], label: label ?? elements.find((offered) => offered.key === placed?.key)?.name ?? "Element" }]
143
+ })
144
+
145
+ const choosePart = ({ kind, id }) => {
146
+ setRefusal(null)
147
+ if (kind === "element") {
148
+ setNode(null)
149
+ return setSelected(id)
150
+ }
151
+ const holder = page.current.querySelector(`[data-${kind}-id="${id}"]`)
152
+ setSelected(null)
153
+ setNode({ kind, id, rowId: holder.closest("[data-row-id]")?.dataset.rowId, sectionId: holder.closest("[data-section-id]")?.dataset.sectionId })
154
+ }
155
+
156
+ useEffect(() => {
157
+ const parent = selectionPath[selectionPath.length - 2]
158
+ const escaped = (event) => {
159
+ if (event.key !== "Escape" || !parent || event.target.closest("input, textarea, select")) return
160
+ choosePart(parent)
161
+ }
162
+ window.document.addEventListener("keydown", escaped)
163
+ return () => window.document.removeEventListener("keydown", escaped)
164
+ }, [selectionPath])
165
+
166
+ const selectedId = selected ?? node?.id
167
+
168
+ useEffect(() => {
169
+ page.current.querySelectorAll("[data-selected]").forEach((marked) => delete marked.dataset.selected)
170
+ const chosen = selectedId && page.current.querySelector(`[data-element-id="${selectedId}"], [data-column-id="${selectedId}"], [data-row-id="${selectedId}"], [data-section-id="${selectedId}"]`)
171
+ if (!chosen) {
172
+ setSelectionPath([])
173
+ return setToolbarPlace(null)
174
+ }
175
+ chosen.dataset.selected = ""
176
+ setSelectionPath(pathTo(chosen))
177
+ const outer = sheet.current.getBoundingClientRect()
178
+ const inner = chosen.getBoundingClientRect()
179
+ setToolbarPlace({ top: inner.top - outer.top, left: inner.left - outer.left })
180
+ }, [canvas, selectedId, device])
181
+
182
+ useEffect(() => {
183
+ const unreachable = window.document.createElement("span")
184
+ const layout = LAYOUT.flatMap(({ kind, selector, id, group }) =>
185
+ [...page.current.querySelectorAll(selector)].map((element) => new Sortable({
186
+ id: id(element), element, index: [...element.parentElement.children].indexOf(element), group: group(element), type: kind, accept: kind,
187
+ handle: selectedId === id(element) ? moveHandle.current : unreachable
188
+ }, manager.current)))
189
+
190
+ return () => layout.forEach((sortable) => sortable.destroy())
191
+ }, [canvas, selectedId])
192
+
193
+ const inUse = (chosen) => {
194
+ const row = chosen.kind === "column" && nodeIn(document, chosen.rowId)
195
+ const share = row ? { wide: shares[row.columns.length] ?? "1/1" } : {}
196
+ return { ...defaults[chosen.kind], ...share, ...nodeIn(document, chosen.id) }
197
+ }
198
+
199
+ const removeSelected = () => removeNode(nodes_url, token, selectedId).then((answer) => { shown(answer); setNode(null); setSelected(null) }, refused)
200
+
201
+ return (
202
+ <div data-builder className="fixed inset-0 flex flex-col bg-(--ks-color-surface) text-(--ks-color-text) dark:bg-(--ks-color-surface-dark) dark:text-(--ks-color-text-dark)">
203
+ <header data-builder-top-bar className="flex h-14 shrink-0 items-center justify-between gap-4 border-b border-(--ks-color-border) dark:border-(--ks-color-border-dark) px-4">
204
+ <div className="flex min-w-0 items-center gap-2">
205
+ <a href={back_url} className="text-sm hover:underline">Back</a>
206
+ <h1 className="truncate text-base font-semibold">{name}</h1>
207
+ </div>
208
+ <div className="flex items-center gap-4 max-lg:hidden">
209
+ <DeviceWidth chosen={device} onChoose={setDevice} />
210
+ <ThemeMode />
211
+ <LookChoice looks={looks} />
212
+ <SampleChoice samples={samples} chosen={sample} onChoose={(choice) => chooseSample(samples_url, token, choice).then(shown, refused)} />
213
+ </div>
214
+ <div className="flex items-center gap-2">
215
+ <Button type="button" size="sm" variant="secondary" disabled={!undoable} title={undoable ? "Undo the last change" : "Nothing to undo"} onClick={() => stepHistory(undo_url, token).then(shown, refused)}>↶ Undo</Button>
216
+ <Button type="button" size="sm" variant="secondary" disabled={!redoable} title={redoable ? "Redo the change you undid" : "Nothing to redo"} onClick={() => stepHistory(redo_url, token).then(shown, refused)}>↷ Redo</Button>
217
+ {live_url && <a href={live_url} className="text-sm whitespace-nowrap hover:underline">View live</a>}
218
+ <a href={history_url} className="text-sm hover:underline">History</a>
219
+ <form method="post" action={publish_url}>
220
+ <input type="hidden" name="authenticity_token" value={token} />
221
+ <Button type="submit" size="sm">Publish</Button>
222
+ </form>
223
+ </div>
224
+ </header>
225
+ <div className="relative flex min-h-0 flex-1">
226
+ <ElementPalette ref={palette} elements={elements}>
227
+ <Button type="button" variant="secondary" onClick={() => addSection(sections_url, token).then(shown)}>Add section</Button>
228
+ </ElementPalette>
229
+ <main data-page-frame className="min-w-0 flex-1 overflow-auto bg-surface-200 p-8 dark:bg-surface-900">
230
+ <div ref={sheet} data-page-sheet data-theme={DEVICES.find((choice) => choice.key === device).theme}
231
+ style={{ width: DEVICES.find((choice) => choice.key === device).width, colorScheme: DEVICES.find((choice) => choice.key === device).theme }}
232
+ className="relative mx-auto min-h-full max-w-full bg-[Canvas] shadow-lg ring-1 ring-black/5">
233
+ <div ref={page} data-page-canvas className="@container min-h-full px-6 [&_[data-hidden]]:opacity-40 bg-(--ks-color-page) text-(--ks-color-on-page) dark:bg-(--ks-color-page-dark) dark:text-(--ks-color-on-page-dark) [&_[data-selected]]:relative [&_[data-selected]]:outline-2 [&_[data-selected]]:outline-(--ks-color-accent) [&_[data-hovered]]:relative [&_[data-hovered]]:outline-1 [&_[data-hovered]]:outline-(--ks-color-accent) [&_[data-hovered]]:before:absolute [&_[data-hovered]]:before:-top-5 [&_[data-hovered]]:before:left-0 [&_[data-hovered]]:before:z-10 [&_[data-hovered]]:before:rounded-t [&_[data-hovered]]:before:bg-(--ks-color-accent) [&_[data-hovered]]:before:px-1.5 [&_[data-hovered]]:before:text-xs [&_[data-hovered]]:before:leading-5 [&_[data-hovered]]:before:text-white [&_[data-hovered]]:before:content-[attr(data-kind)] [&_[data-column-id]:empty]:min-h-16 [&_[data-column-id]:empty]:items-center [&_[data-column-id]:empty]:justify-center [&_[data-column-id]:empty]:rounded-md [&_[data-column-id]:empty]:border-2 [&_[data-column-id]:empty]:border-dashed [&_[data-column-id]:empty]:border-(--ks-color-border-strong) [&_[data-column-id]:empty]:after:text-sm [&_[data-column-id]:empty]:after:text-(--ks-color-icon) [&_[data-column-id]:empty]:after:content-['Drop_an_element_here'] [&_[data-drop-target]]:outline-2 [&_[data-drop-target]]:outline-dashed [&_[data-drop-target]]:outline-(--ks-color-accent)" onClick={select} onMouseOver={hovered} onMouseLeave={() => page.current.querySelectorAll("[data-hovered]").forEach((node) => delete node.dataset.hovered)} />
234
+ <SelectionToolbar ref={moveHandle} place={toolbarPlace} onRemove={removeSelected} onDuplicate={() => duplicateNode(nodes_url, token, selectedId).then(shown, refused)} />
235
+ </div>
236
+ </main>
237
+ <aside data-settings-panel className="w-72 shrink-0 overflow-y-auto border-l border-(--ks-color-border) bg-(--ks-color-surface) p-4 dark:border-(--ks-color-border-dark) dark:bg-(--ks-color-surface-dark)">
238
+ <SelectionPath path={selectionPath} onChoose={choosePart} />
239
+ {!node && !definition && <p className="ks-hint">Select something on the page to change it.</p>}
240
+ {node && <LayoutPanel node={node} values={inUse(node)} arrangements={arrangements}
241
+ onArrange={(id, setting, value) => arrange(nodes_url, token, id, setting, value).then(shown, refused)}
242
+ refusal={refusal}
243
+ onSelect={chose}
244
+ onRemove={(id) => removeNode(nodes_url, token, id).then((answer) => { shown(answer); setNode(null) }, refused)}
245
+ onAddColumn={(rowId) => addColumn(columns_url, token, rowId).then(shown)}
246
+ onAddRow={(sectionId) => addRow(rows_url, token, sectionId).then(shown)} />}
247
+ {definition && <ElementFields element={element} definition={definition} alignments={arrangements.element.align}
248
+ onRemove={(id) => removeNode(nodes_url, token, id).then((answer) => { shown(answer); setSelected(null) })}
249
+ onArrange={(id, setting, value) => arrange(nodes_url, token, id, setting, value).then(shown)}
250
+ valueNames={value_names}
251
+ onBind={(field, name, fallback) => bindField(elements_url, token, element.id, field, name, fallback).then(shown, refused)}
252
+ onChange={(field, value) => setField(elements_url, token, element.id, field, value).then(shown)} />}
253
+ {(element || (node && node.kind !== "column")) &&
254
+ <ShowWhen key={element?.id ?? node.id} condition={(element ?? nodeIn(document, node.id))?.show_when} valueNames={value_names}
255
+ onChange={(name, equals) => showWhen(nodes_url, token, element?.id ?? node.id, name, equals).then(shown, refused)} />}
256
+ </aside>
257
+ </div>
258
+ </div>
259
+ )
260
+ }
@@ -0,0 +1,15 @@
1
+ import React from "react"
2
+ import Select from "keystone_ui-react/src/Select.jsx"
3
+
4
+ export default function SampleChoice({ samples, chosen, onChoose }) {
5
+ if (samples.length === 0) return null
6
+
7
+ return (
8
+ <label className="flex items-center gap-2 text-sm">
9
+ <span className="whitespace-nowrap">Preview with</span>
10
+ <Select defaultValue={chosen ?? ""} onChange={(event) => onChoose(event.target.value)} className="py-1">
11
+ {samples.map(([label, value]) => <option key={value} value={value}>{label}</option>)}
12
+ </Select>
13
+ </label>
14
+ )
15
+ }
@@ -0,0 +1,19 @@
1
+ import React from "react"
2
+
3
+ export default function SelectionPath({ path, onChoose }) {
4
+ if (path.length === 0) return null
5
+
6
+ return (
7
+ <nav data-selection-path aria-label="Selection" className="mb-4 flex flex-wrap items-center gap-1 text-xs">
8
+ {path.map((part, index) => (
9
+ <React.Fragment key={part.id}>
10
+ {index > 0 && <span aria-hidden="true" className="text-(--ks-color-icon)">›</span>}
11
+ <button type="button" aria-current={index === path.length - 1 ? "true" : undefined} onClick={() => onChoose(part)}
12
+ className="rounded px-1.5 py-0.5 hover:bg-(--ks-color-fill-muted) aria-[current=true]:font-semibold">
13
+ {part.label}
14
+ </button>
15
+ </React.Fragment>
16
+ ))}
17
+ </nav>
18
+ )
19
+ }
@@ -0,0 +1,14 @@
1
+ import React, { forwardRef } from "react"
2
+
3
+ const SelectionToolbar = forwardRef(function SelectionToolbar({ place, onDuplicate, onRemove }, moveHandle) {
4
+ return (
5
+ <div data-selection-toolbar hidden={!place} style={place ?? {}}
6
+ className="absolute z-20 flex -translate-y-full overflow-hidden rounded-t-md bg-(--ks-color-accent) text-xs text-white shadow">
7
+ <button ref={moveHandle} type="button" data-move-handle className="cursor-grab px-2 py-1 hover:bg-black/20">Move</button>
8
+ <button type="button" onClick={onDuplicate} className="px-2 py-1 hover:bg-black/20">Duplicate</button>
9
+ <button type="button" onClick={onRemove} className="px-2 py-1 hover:bg-black/20">Remove</button>
10
+ </div>
11
+ )
12
+ })
13
+
14
+ export default SelectionToolbar
@@ -0,0 +1,32 @@
1
+ import React, { useState } from "react"
2
+ import Input from "keystone_ui-react/src/Input.jsx"
3
+ import Select from "keystone_ui-react/src/Select.jsx"
4
+ import { Label } from "keystone_ui-react/src/FieldText.jsx"
5
+
6
+ export default function ShowWhen({ condition, valueNames, onChange }) {
7
+ const [name, setName] = useState(condition?.value ?? "")
8
+ const [equals, setEquals] = useState(condition?.equals ?? "")
9
+ const names = Object.entries(valueNames ?? {})
10
+ if (names.length === 0) return null
11
+
12
+ const chosen = (picked) => {
13
+ setName(picked)
14
+ if (picked === "" || equals !== "") onChange(picked, equals)
15
+ }
16
+
17
+ return (
18
+ <div data-show-when className="mt-4 flex flex-col gap-2 border-t border-(--ks-color-border) pt-4 dark:border-(--ks-color-border-dark)">
19
+ <Label htmlFor="ks-pages-show-when-value">Show only when</Label>
20
+ <Select id="ks-pages-show-when-value" value={name} onChange={(event) => chosen(event.target.value)}>
21
+ <option value="">Always shown</option>
22
+ {names.map(([key, label]) => <option key={key} value={key}>{label}</option>)}
23
+ </Select>
24
+ {name !== "" &&
25
+ <>
26
+ <Label htmlFor="ks-pages-show-when-equals">equals</Label>
27
+ <Input id="ks-pages-show-when-equals" type="text" defaultValue={equals}
28
+ onBlur={(event) => { setEquals(event.target.value); if (event.target.value !== condition?.equals) onChange(name, event.target.value) }} />
29
+ </>}
30
+ </div>
31
+ )
32
+ }
@@ -0,0 +1,23 @@
1
+ import React from "react"
2
+ import Select from "keystone_ui-react/src/Select.jsx"
3
+
4
+ const MODES = [["light", "Light"], ["dark", "Dark"], ["system", "System"]]
5
+
6
+ const shown = (mode) => {
7
+ if (mode === "system") delete document.documentElement.dataset.theme
8
+ else document.documentElement.dataset.theme = mode
9
+ }
10
+
11
+ export default function ThemeMode() {
12
+ const current = document.documentElement.dataset.theme ?? "system"
13
+ const offered = current === "custom" ? [...MODES, ["custom", "Custom"]] : MODES
14
+
15
+ return (
16
+ <label className="flex items-center gap-2 text-sm">
17
+ <span>Mode</span>
18
+ <Select data-theme-mode defaultValue={current} onChange={(event) => shown(event.target.value)} className="py-1">
19
+ {offered.map(([value, label]) => <option key={value} value={value}>{label}</option>)}
20
+ </Select>
21
+ </label>
22
+ )
23
+ }
@@ -0,0 +1,10 @@
1
+ const columnsIn = (document) =>
2
+ document.sections.flatMap((section) => section.rows).flatMap((row) => row.columns)
3
+
4
+ export const elementIn = (document, id) =>
5
+ columnsIn(document).flatMap((column) => column.elements).find((element) => element.id === id)
6
+
7
+ export const nodeIn = (document, id) => {
8
+ const rows = document.sections.flatMap((section) => section.rows)
9
+ return [...document.sections, ...rows, ...columnsIn(document)].find((node) => node.id === id)
10
+ }
@@ -0,0 +1,8 @@
1
+ import { createRoot } from "react-dom/client"
2
+ import { register } from "keystone_ui-react/src/registry.js"
3
+ import { startMounting } from "keystone_ui-react/src/mounting.js"
4
+ import PageEditor from "./PageEditor"
5
+
6
+ register("keystone_ui_pages/editor", PageEditor)
7
+
8
+ startMounting(document, createRoot)
@@ -0,0 +1,32 @@
1
+ const sent = (url, token, method, body = {}) =>
2
+ fetch(url, {
3
+ method,
4
+ headers: { "Content-Type": "application/json", Accept: "application/json", "X-CSRF-Token": token },
5
+ body: JSON.stringify(body)
6
+ }).then((response) => response.json().then((answer) => (response.ok ? answer : Promise.reject(answer))))
7
+
8
+ export const addElement = (url, token, key, columnId) => sent(url, token, "POST", { key, column_id: columnId })
9
+
10
+ export const setField = (url, token, elementId, field, value) => sent(`${url}/${elementId}`, token, "PATCH", { field, value })
11
+
12
+ export const addSection = (url, token) => sent(url, token, "POST", {})
13
+
14
+ export const addColumn = (url, token, rowId) => sent(url, token, "POST", { row_id: rowId })
15
+
16
+ export const addRow = (url, token, sectionId) => sent(url, token, "POST", { section_id: sectionId })
17
+
18
+ export const arrange = (url, token, nodeId, setting, value) => sent(`${url}/${nodeId}`, token, "PATCH", { setting, value })
19
+
20
+ export const removeNode = (url, token, nodeId) => sent(`${url}/${nodeId}`, token, "DELETE")
21
+
22
+ export const moveNode = (url, token, nodeId, into, position) => sent(`${url}/${nodeId}/move`, token, "POST", { into, position })
23
+
24
+ export const duplicateNode = (url, token, nodeId) => sent(`${url}/${nodeId}/duplicate`, token, "POST")
25
+
26
+ export const bindField = (url, token, elementId, field, name, fallback) => sent(`${url}/${elementId}/bind`, token, "POST", { field, name, fallback })
27
+
28
+ export const chooseSample = (url, token, choice) => sent(url, token, "POST", { choice })
29
+
30
+ export const showWhen = (url, token, nodeId, name, equals) => sent(`${url}/${nodeId}/condition`, token, "POST", { name, equals })
31
+
32
+ export const stepHistory = (url, token) => sent(url, token, "POST")
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Pages
5
+ class PdfJob < ActiveJob::Base
6
+ def perform(page, values)
7
+ KeystoneUi::Pages.pdf(page, values: values)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "keystone_ui/pages/document"
4
+
5
+ module KeystoneUi
6
+ module Pages
7
+ class Page < ActiveRecord::Base
8
+ self.table_name = "keystone_ui_pages_pages"
9
+
10
+ belongs_to :owner, polymorphic: true, optional: true
11
+ has_many :versions, -> { order(:number) }, class_name: "KeystoneUi::Pages::Version", dependent: :destroy, inverse_of: :page
12
+
13
+ attribute :document, :json, default: -> { Document.blank }
14
+ attribute :undo_history, :json, default: -> { [] }
15
+ attribute :undone_changes, :json, default: -> { [] }
16
+
17
+ validates :name, :slug, presence: true
18
+
19
+ def publish
20
+ transaction do
21
+ live_version&.update!(status: :superseded)
22
+ versions.create!(number: next_number, document: document, status: :live)
23
+ end.tap { PdfJob.perform_later(self, {}) unless uses_host_values? }
24
+ end
25
+
26
+ def uses_host_values?(node = document)
27
+ case node
28
+ when Hash then node.key?("show_when") || (node.key?("value") && node.key?("fallback")) || node.values.any? { |child| uses_host_values?(child) }
29
+ when Array then node.any? { |child| uses_host_values?(child) }
30
+ else false
31
+ end
32
+ end
33
+
34
+ def edit(changed_document)
35
+ update!(document: changed_document, undo_history: undo_history + [ document ], undone_changes: [])
36
+ end
37
+
38
+ def undo
39
+ update!(document: undo_history.last, undo_history: undo_history[0...-1], undone_changes: undone_changes + [ document ])
40
+ end
41
+
42
+ def redo
43
+ update!(document: undone_changes.last, undone_changes: undone_changes[0...-1], undo_history: undo_history + [ document ])
44
+ end
45
+
46
+ def live_version
47
+ versions.find_by(status: :live)
48
+ end
49
+
50
+ def next_number
51
+ (versions.maximum(:number) || 0) + 1
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Pages
5
+ class Pdf < ActiveRecord::Base
6
+ self.table_name = "keystone_ui_pages_pdfs"
7
+
8
+ belongs_to :version, class_name: "KeystoneUi::Pages::Version"
9
+ has_one_attached :file
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Pages
5
+ class Version < ActiveRecord::Base
6
+ self.table_name = "keystone_ui_pages_versions"
7
+
8
+ enum :status, { live: "live", superseded: "superseded" }
9
+
10
+ belongs_to :page, class_name: "KeystoneUi::Pages::Page", inverse_of: :versions
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,35 @@
1
+ <%= ui_page(max_width: :xl) do %>
2
+ <%= ui_page_header(title: "Pages") %>
3
+
4
+ <div class="ks-table overflow-x-auto">
5
+ <table class="relative min-w-full divide-y divide-gray-300 dark:divide-white/15">
6
+ <thead class="ks-table-head">
7
+ <tr>
8
+ <th class="ks-table-header ks-table-header-first text-left text-sm">Name</th>
9
+ <th class="ks-table-header ks-table-header-middle text-left text-sm">Status</th>
10
+ <th class="ks-table-header ks-table-header-middle text-left text-sm">Last published</th>
11
+ <th class="ks-table-header ks-table-header-last text-right text-sm"><span class="sr-only">Open</span></th>
12
+ </tr>
13
+ </thead>
14
+ <tbody class="ks-table-body">
15
+ <% @pages.each do |page| %>
16
+ <% live = page.live_version %>
17
+ <tr data-page="<%= page.slug %>">
18
+ <td class="ks-table-cell-first text-sm whitespace-nowrap"><%= page.name %></td>
19
+ <td class="ks-table-cell-middle text-sm whitespace-nowrap"><%= ui_badge(label: live ? "Published" : "Draft", variant: live ? :success : :neutral) %></td>
20
+ <td class="ks-table-cell-middle text-sm whitespace-nowrap"><%= live&.created_at&.to_date&.to_fs(:long) %></td>
21
+ <td class="ks-table-cell-middle text-sm whitespace-nowrap text-right">
22
+ <%= link_to "Edit", manage_page_path(page) %>
23
+ <% if live %>
24
+ <%= link_to "View live", manage_page_live_path(page), class: "ml-4" %>
25
+ <% if @printable %>
26
+ <%= link_to "PDF", manage_page_pdf_path(page), class: "ml-4" %>
27
+ <% end %>
28
+ <% end %>
29
+ </td>
30
+ </tr>
31
+ <% end %>
32
+ </tbody>
33
+ </table>
34
+ </div>
35
+ <% end %>
@@ -0,0 +1,13 @@
1
+ <%= ui_page(max_width: :xl) do %>
2
+ <%= ui_page_header(title: "New page") %>
3
+
4
+ <%= ui_form(action: manage_pages_path, method: :post) do %>
5
+ <label class="ks-label mb-1">Name</label>
6
+ <%= ui_input(name: "page[name]", placeholder: "e.g. Pricing") %>
7
+
8
+ <label class="ks-label mb-1">Address</label>
9
+ <%= ui_input(name: "page[slug]", placeholder: "e.g. pricing") %>
10
+
11
+ <%= ui_button(label: "Create", type: :submit) %>
12
+ <% end %>
13
+ <% end %>
@@ -0,0 +1,8 @@
1
+ <% content_for :head do %>
2
+ <%= javascript_include_tag "keystone_ui/react", defer: true %>
3
+ <%= javascript_include_tag "keystone_ui_pages/editor", defer: true %>
4
+ <% end %>
5
+
6
+ <div data-page-builder>
7
+ <%= react_ui("keystone_ui_pages/editor", editor_props(@page).merge(name: @page.name, publish_url: publish_manage_page_path(@page))) %>
8
+ </div>
@@ -0,0 +1,28 @@
1
+ <%= ui_page(max_width: :xl) do %>
2
+ <%= ui_page_header(title: "#{@page.name} history") %>
3
+
4
+ <div class="mb-6"><%= ui_button(label: "Back to builder", href: manage_page_path(@page), variant: :secondary) %></div>
5
+
6
+ <div class="ks-table overflow-x-auto">
7
+ <table class="relative min-w-full divide-y divide-gray-300 dark:divide-white/15">
8
+ <thead class="ks-table-head">
9
+ <tr>
10
+ <th class="ks-table-header ks-table-header-first text-left text-sm">Version</th>
11
+ <th class="ks-table-header ks-table-header-middle text-left text-sm">Status</th>
12
+ <th class="ks-table-header ks-table-header-middle text-left text-sm">Published</th>
13
+ <th class="ks-table-header ks-table-header-last text-right text-sm"><span class="sr-only">Open</span></th>
14
+ </tr>
15
+ </thead>
16
+ <tbody class="ks-table-body">
17
+ <% @versions.each do |version| %>
18
+ <tr data-version="<%= version.number %>">
19
+ <td class="ks-table-cell-first text-sm whitespace-nowrap"><%= version.number %></td>
20
+ <td class="ks-table-cell-middle text-sm whitespace-nowrap"><%= ui_badge(label: version.status, variant: version.live? ? :success : :neutral) %></td>
21
+ <td class="ks-table-cell-middle text-sm whitespace-nowrap"><%= version.created_at.to_date.to_fs(:long) %></td>
22
+ <td class="ks-table-cell-middle text-sm whitespace-nowrap text-right"><%= link_to "View", manage_page_version_path(@page, version) %></td>
23
+ </tr>
24
+ <% end %>
25
+ </tbody>
26
+ </table>
27
+ </div>
28
+ <% end %>
@@ -0,0 +1,11 @@
1
+ <%= ui_page(max_width: :xl) do %>
2
+ <%= ui_page_header(title: "#{@page.name}, version #{@version.number}") %>
3
+
4
+ <%= ui_form(action: restore_manage_page_version_path(@page, @version), method: :post) do %>
5
+ <%= ui_button(label: "Restore this version as the draft", type: :submit, variant: :secondary) %>
6
+ <% end %>
7
+
8
+ <div class="@container" data-version-drawing>
9
+ <%= drawn_document(@version.document) %>
10
+ </div>
11
+ <% end %>
@@ -0,0 +1,3 @@
1
+ <div data-live-page class="@container print:bg-white print:[print-color-adjust:exact]">
2
+ <%= drawn_version(@version, values: @values.to_h) %>
3
+ </div>
@@ -0,0 +1,12 @@
1
+ <!DOCTYPE html>
2
+ <html data-theme="light">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <style><%= raw @stylesheet %></style>
6
+ </head>
7
+ <body>
8
+ <div class="@container print:bg-white print:[print-color-adjust:exact]">
9
+ <%= drawn_version(@version, values: @values) %>
10
+ </div>
11
+ </body>
12
+ </html>