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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/app/assets/builds/keystone_ui_pages/editor.js +67 -0
- data/app/controllers/concerns/keystone_ui/pages/edits_pages.rb +32 -0
- data/app/controllers/concerns/keystone_ui/pages/host_checks.rb +25 -0
- data/app/controllers/keystone_ui/pages/manage/base_controller.rb +28 -0
- data/app/controllers/keystone_ui/pages/manage/columns_controller.rb +15 -0
- data/app/controllers/keystone_ui/pages/manage/documents_controller.rb +15 -0
- data/app/controllers/keystone_ui/pages/manage/edit_history_controller.rb +21 -0
- data/app/controllers/keystone_ui/pages/manage/elements_controller.rb +30 -0
- data/app/controllers/keystone_ui/pages/manage/lives_controller.rb +20 -0
- data/app/controllers/keystone_ui/pages/manage/nodes_controller.rb +31 -0
- data/app/controllers/keystone_ui/pages/manage/pages_controller.rb +41 -0
- data/app/controllers/keystone_ui/pages/manage/pdfs_controller.rb +23 -0
- data/app/controllers/keystone_ui/pages/manage/rows_controller.rb +15 -0
- data/app/controllers/keystone_ui/pages/manage/samples_controller.rb +16 -0
- data/app/controllers/keystone_ui/pages/manage/sections_controller.rb +15 -0
- data/app/controllers/keystone_ui/pages/manage/versions_controller.rb +25 -0
- data/app/controllers/keystone_ui/pages/pages_controller.rb +26 -0
- data/app/helpers/keystone_ui/pages/drawing_helper.rb +115 -0
- data/app/helpers/keystone_ui/pages/editor_helper.rb +46 -0
- data/app/helpers/keystone_ui/pages/host_helper.rb +17 -0
- data/app/javascript/keystone_ui_pages/DeviceWidth.jsx +21 -0
- data/app/javascript/keystone_ui_pages/ElementFields.jsx +78 -0
- data/app/javascript/keystone_ui_pages/ElementPalette.jsx +62 -0
- data/app/javascript/keystone_ui_pages/LayoutPanel.jsx +41 -0
- data/app/javascript/keystone_ui_pages/ListField.jsx +49 -0
- data/app/javascript/keystone_ui_pages/LookChoice.jsx +17 -0
- data/app/javascript/keystone_ui_pages/PageEditor.jsx +260 -0
- data/app/javascript/keystone_ui_pages/SampleChoice.jsx +15 -0
- data/app/javascript/keystone_ui_pages/SelectionPath.jsx +19 -0
- data/app/javascript/keystone_ui_pages/SelectionToolbar.jsx +14 -0
- data/app/javascript/keystone_ui_pages/ShowWhen.jsx +32 -0
- data/app/javascript/keystone_ui_pages/ThemeMode.jsx +23 -0
- data/app/javascript/keystone_ui_pages/document.js +10 -0
- data/app/javascript/keystone_ui_pages/editor.jsx +8 -0
- data/app/javascript/keystone_ui_pages/operations.js +32 -0
- data/app/jobs/keystone_ui/pages/pdf_job.rb +11 -0
- data/app/models/keystone_ui/pages/page.rb +55 -0
- data/app/models/keystone_ui/pages/pdf.rb +12 -0
- data/app/models/keystone_ui/pages/version.rb +13 -0
- data/app/views/keystone_ui/pages/manage/pages/index.html.erb +35 -0
- data/app/views/keystone_ui/pages/manage/pages/new.html.erb +13 -0
- data/app/views/keystone_ui/pages/manage/pages/show.html.erb +8 -0
- data/app/views/keystone_ui/pages/manage/versions/index.html.erb +28 -0
- data/app/views/keystone_ui/pages/manage/versions/show.html.erb +11 -0
- data/app/views/keystone_ui/pages/pages/show.html.erb +3 -0
- data/app/views/keystone_ui/pages/pdfs/show.html.erb +12 -0
- data/config/routes.rb +31 -0
- data/db/migrate/20261014120000_create_keystone_ui_pages_pages.rb +17 -0
- data/db/migrate/20261014120001_create_keystone_ui_pages_versions.rb +18 -0
- data/db/migrate/20261015130000_create_keystone_ui_pages_pdfs.rb +14 -0
- data/db/migrate/20261016120000_add_edit_history_to_keystone_ui_pages_pages.rb +8 -0
- data/lib/keystone_ui/pages/catalog.rb +29 -0
- data/lib/keystone_ui/pages/document.rb +29 -0
- data/lib/keystone_ui/pages/engine.rb +25 -0
- data/lib/keystone_ui/pages/keystone_elements.rb +109 -0
- data/lib/keystone_ui/pages/operations.rb +246 -0
- data/lib/keystone_ui/pages/pdf_document.rb +17 -0
- data/lib/keystone_ui/pages/pdfs.rb +20 -0
- data/lib/keystone_ui/pages/printer.rb +43 -0
- data/lib/keystone_ui/pages/values.rb +28 -0
- data/lib/keystone_ui/pages/version.rb +7 -0
- data/lib/keystone_ui/pages.rb +45 -0
- data/lib/keystone_ui-pages.rb +3 -0
- data/the_local/agents/keystone_ui-pages-develop.md +277 -0
- data/the_local/agents/keystone_ui-pages-info.md +71 -0
- data/the_local/agents/keystone_ui-pages-install.md +167 -0
- data/the_local/interface.yml +56 -0
- 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,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,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,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>
|