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,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Pages
5
+ module Manage
6
+ class NodesController < BaseController
7
+ include EditsPages
8
+
9
+ def update
10
+ changed(Operations.arrange(page.document, node_id: params[:id], setting: params.require(:setting), value: params.require(:value)))
11
+ end
12
+
13
+ def move
14
+ changed(Operations.move(page.document, node_id: params[:id], into: params[:into].presence, position: params.require(:position)))
15
+ end
16
+
17
+ def condition
18
+ changed(Operations.show_when(page.document, node_id: params[:id], name: params[:name], equals: params[:equals]))
19
+ end
20
+
21
+ def duplicate
22
+ changed(Operations.duplicate(page.document, node_id: params[:id]))
23
+ end
24
+
25
+ def destroy
26
+ changed(Operations.remove(page.document, node_id: params[:id]))
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "keystone_ui/pages/printer"
4
+
5
+ module KeystoneUi
6
+ module Pages
7
+ module Manage
8
+ class PagesController < BaseController
9
+ def index
10
+ @pages = owned_pages.order(:name)
11
+ @printable = Printer.chrome.present?
12
+ end
13
+
14
+ def new
15
+ end
16
+
17
+ def create
18
+ page = Page.create!(params.require(:page).permit(:name, :slug).merge(owner: page_owner))
19
+ redirect_to manage_page_path(page)
20
+ end
21
+
22
+ def show
23
+ @page = owned_pages.find(params[:id])
24
+ (session[:keystone_ui_pages_return_to] ||= {})[@page.id.to_s] = params[:return_to] if local_address?(params[:return_to])
25
+ end
26
+
27
+ def publish
28
+ page = owned_pages.find(params[:id])
29
+ page.publish
30
+ redirect_to manage_page_path(page)
31
+ end
32
+
33
+ private
34
+
35
+ def local_address?(address)
36
+ address.to_s.match?(%r{\A/(?![/\\])})
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Pages
5
+ module Manage
6
+ class PdfsController < BaseController
7
+ def show
8
+ page = owned_pages.find(params[:page_id])
9
+ return refuse if page.live_version.nil?
10
+
11
+ pdf = KeystoneUi::Pages.pdf(page, values: sample_values(page))
12
+ send_data pdf.file.download, filename: "#{page.slug}.pdf", type: "application/pdf"
13
+ end
14
+
15
+ private
16
+
17
+ def sample_values(page)
18
+ page.uses_host_values? ? Values.sample(page, helpers.chosen_sample(page)) : {}
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Pages
5
+ module Manage
6
+ class RowsController < BaseController
7
+ include EditsPages
8
+
9
+ def create
10
+ changed(Operations.add_row(page.document, section_id: params.require(:section_id)))
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Pages
5
+ module Manage
6
+ class SamplesController < BaseController
7
+ include EditsPages
8
+
9
+ def update
10
+ (session[:keystone_ui_pages_samples] ||= {})[page.id.to_s] = params.require(:choice)
11
+ changed(page.document)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Pages
5
+ module Manage
6
+ class SectionsController < BaseController
7
+ include EditsPages
8
+
9
+ def create
10
+ changed(Operations.add_section(page.document))
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Pages
5
+ module Manage
6
+ class VersionsController < BaseController
7
+ def index
8
+ @page = owned_pages.find(params[:page_id])
9
+ @versions = @page.versions.reorder(number: :desc)
10
+ end
11
+
12
+ def show
13
+ @page = owned_pages.find(params[:page_id])
14
+ @version = @page.versions.find(params[:id])
15
+ end
16
+
17
+ def restore
18
+ page = owned_pages.find(params[:page_id])
19
+ page.update!(document: page.versions.find(params[:id]).document)
20
+ redirect_to manage_page_path(page)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Pages
5
+ class PagesController < KeystoneUi::Pages.base_controller.constantize
6
+ include HostChecks
7
+ helper KeystoneUiHelper
8
+ helper DrawingHelper
9
+ layout -> { KeystoneUi::Pages.layout }
10
+
11
+ def show
12
+ page = owned_pages.find_by(slug: params[:slug])
13
+ @version = page&.live_version
14
+ refuse if @version.nil? || !viewable?(page)
15
+ end
16
+
17
+ private
18
+
19
+ def viewable?(page)
20
+ return true unless KeystoneUi::Pages.view_check_method
21
+
22
+ send(KeystoneUi::Pages.view_check_method, page)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "keystone_ui/pages/operations"
4
+
5
+ module KeystoneUi
6
+ module Pages
7
+ module DrawingHelper
8
+ NARROW_SPANS = { "1/1" => "col-span-12", "3/4" => "col-span-9", "2/3" => "col-span-8", "1/2" => "col-span-6", "1/3" => "col-span-4", "1/4" => "col-span-3" }.freeze
9
+ WIDE_SPANS = { "1/1" => "@sm:col-span-12", "3/4" => "@sm:col-span-9", "2/3" => "@sm:col-span-8", "1/2" => "@sm:col-span-6", "1/3" => "@sm:col-span-4", "1/4" => "@sm:col-span-3" }.freeze
10
+ ELEMENT_ALIGNMENTS = { "stretch" => "flex flex-col", "start" => "flex justify-start", "center" => "flex justify-center", "end" => "flex justify-end" }.freeze
11
+ COLUMN_ALIGNMENTS = { "top" => "justify-start", "center" => "justify-center", "bottom" => "justify-end" }.freeze
12
+ PLAIN_FIGURE_DIVIDER = "@sm:[.ks-pages-column:has(.ks-metric-plain)+&:has(.ks-metric-plain)]:border-l-(length:--ks-border-width) " \
13
+ "@sm:[.ks-pages-column:has(.ks-metric-plain)+&:has(.ks-metric-plain)]:border-(color:--ks-color-border) " \
14
+ "dark:@sm:[.ks-pages-column:has(.ks-metric-plain)+&:has(.ks-metric-plain)]:border-(color:--ks-color-border-dark)"
15
+
16
+ def drawn_version(version, values: {})
17
+ key = [ "keystone_ui_pages", version.id, Operations.digest(values.to_h.stringify_keys) ]
18
+ Rails.cache.fetch(key) { drawn_document(version.document, values: values).to_str }.html_safe
19
+ end
20
+
21
+ def drawn_document(document, editing: false, values: {})
22
+ @drawn_values = values.to_h.stringify_keys
23
+ safe_join(document["sections"].map { |section| drawn_section(section, editing) })
24
+ end
25
+
26
+ private
27
+
28
+ def drawn_section(section, editing)
29
+ return if hidden?(section) && !editing
30
+
31
+ spacing = Keystone::Ui::SectionComponent::SPACING_CLASSES.fetch((section["spacing"] || Operations::DEFAULTS[:section]["spacing"]).to_sym)
32
+ data = editing ? { section_id: section["id"], hidden: hidden?(section) || nil }.compact : {}
33
+ tag.section(safe_join(section["rows"].map { |row| drawn_row(row, editing) }), class: "ks-pages-section flex flex-col gap-6 print:break-inside-avoid #{spacing}", data: data)
34
+ end
35
+
36
+ def drawn_row(row, editing)
37
+ return if hidden?(row) && !editing
38
+
39
+ gap = Keystone::Ui::GridComponent::GAP_CLASSES.fetch((row["gap"] || Operations::DEFAULTS[:row]["gap"]).to_sym)
40
+ share = Operations::SHARES.fetch(row["columns"].size, "1/1")
41
+ columns = row["columns"].map { |column| drawn_column(column, editing, share) }
42
+ data = editing ? { row_id: row["id"], hidden: hidden?(row) || nil }.compact : {}
43
+ tag.div(safe_join(columns), class: "ks-pages-row grid grid-cols-12 #{gap}", data: data)
44
+ end
45
+
46
+ def drawn_column(column, editing, share)
47
+ elements = safe_join(column["elements"].map { |element| editing ? edited_element(element) : drawn_element(element) })
48
+ spans = "#{PLAIN_FIGURE_DIVIDER} #{NARROW_SPANS.fetch(column["narrow"] || Operations::DEFAULTS[:column]["narrow"])} #{WIDE_SPANS.fetch(column['wide'] || share)} #{COLUMN_ALIGNMENTS.fetch(column["align"] || Operations::DEFAULTS[:column]["align"])}"
49
+ return tag.div(elements, class: "ks-pages-column flex flex-col gap-4 #{spans}") unless editing
50
+
51
+ tag.div(elements, class: "ks-pages-column flex flex-col gap-4 #{spans}", data: { column_id: column["id"] })
52
+ end
53
+
54
+ def edited_element(element)
55
+ wrapped_element(element, data: { element_id: element["id"], hidden: hidden?(element) || nil }.compact)
56
+ end
57
+
58
+ def drawn_element(element)
59
+ wrapped_element(element) unless hidden?(element)
60
+ end
61
+
62
+ def hidden?(node)
63
+ condition = node["show_when"]
64
+ condition && (@drawn_values || {})[condition["value"]].to_s != condition["equals"].to_s
65
+ end
66
+
67
+ def wrapped_element(element, data: {})
68
+ tag.div(component_for(element), class: "ks-pages-element print:[&_tr]:break-inside-avoid print:[&_.ks-value-list-row]:break-inside-avoid #{wrapper_classes(element)} #{ELEMENT_ALIGNMENTS.fetch(element["align"] || KeystoneUi::Pages.catalog.find(element["key"])&.align || Operations::DEFAULTS[:element]["align"])}", data: data)
69
+ end
70
+
71
+ def field_value(field, value)
72
+ return value.map { |entry| entry.is_a?(Hash) ? entry.symbolize_keys : entry } if field[:kind] == :data && value.is_a?(Array)
73
+ return value.map { |entry| Keystone::Ui::Column.new(entry["key"].to_s.to_sym, entry["label"], align: entry["align"].presence&.to_sym) } if field[:as] == :pairs && value.is_a?(Array)
74
+ return value.map { |entry| entry_with_defaults(field, entry) } if field[:kind] == :list && value.is_a?(Array)
75
+ return [] if (field[:kind] == :list || (field[:kind] == :data && field[:default].is_a?(Array))) && value.blank?
76
+ return nil if field[:kind] == :data && value.blank?
77
+ return value if value.blank?
78
+ return value.to_sym if field[:as] == :key
79
+ return value.to_s == "true" if field[:kind] == :yes_or_no
80
+ return value.to_sym if field[:kind] == :one_of
81
+ return value unless field[:kind] == :number
82
+
83
+ value.to_s.include?(".") ? value.to_f : value.to_i
84
+ end
85
+
86
+ def entry_with_defaults(field, entry)
87
+ parts = Array(field[:entry]).index_by { |part| part[:key] }
88
+ defaults = parts.transform_values { |part| part[:default] }.compact
89
+ given = entry.to_h.symbolize_keys.to_h { |key, value| [ key, parts[key] ? field_value(parts[key], value) : value ] }
90
+ defaults.merge(given.compact_blank)
91
+ end
92
+
93
+ def filled(stored)
94
+ return stored.presence unless stored.is_a?(Hash)
95
+
96
+ (@drawn_values || {})[stored["value"]].presence || stored["fallback"].presence || ""
97
+ end
98
+
99
+ def wrapper_classes(element)
100
+ definition = KeystoneUi::Pages.catalog.find(element["key"])
101
+ definition.fields.select { |field| field[:classes] }
102
+ .map { |field| field[:classes][element["fields"][field[:key].to_s].presence || field[:default]] }.compact.join(" ")
103
+ end
104
+
105
+ def component_for(element)
106
+ definition = KeystoneUi::Pages.catalog.find(element["key"])
107
+ values = definition.fields.reject { |field| field[:classes] }.to_h { |field| [ field[:key].to_sym, field_value(field, filled(element["fields"][field[:key].to_s]) || field[:default]) ] }
108
+ return send(definition.component, **values) unless definition.body
109
+
110
+ body = values.delete(definition.body)
111
+ send(definition.component, **values) { body }
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "keystone_ui/pages/operations"
4
+
5
+ module KeystoneUi
6
+ module Pages
7
+ module EditorHelper
8
+ def editor_props(page)
9
+ {
10
+ elements: KeystoneUi::Pages.catalog.elements.map { |element| element.to_h.slice(:key, :name, :fields, :icon, :group, :align) },
11
+ canvas: editor_canvas(page),
12
+ document: page.document,
13
+ elements_url: manage_page_elements_path(page),
14
+ sections_url: manage_page_sections_path(page),
15
+ columns_url: manage_page_columns_path(page),
16
+ rows_url: manage_page_rows_path(page),
17
+ nodes_url: "#{manage_page_path(page)}/nodes",
18
+ arrangements: Operations::ARRANGEMENTS,
19
+ defaults: Operations::DEFAULTS,
20
+ shares: Operations::SHARES,
21
+ value_names: Values.names,
22
+ looks: KeystoneUi.configuration.looks.keys,
23
+ samples: Values.samples_for(page),
24
+ sample: chosen_sample(page),
25
+ samples_url: manage_page_sample_path(page),
26
+ history_url: manage_page_versions_path(page),
27
+ undo_url: manage_page_undo_path(page),
28
+ redo_url: manage_page_redo_path(page),
29
+ undoable: page.undo_history.any?,
30
+ redoable: page.undone_changes.any?,
31
+ back_url: session.dig(:keystone_ui_pages_return_to, page.id.to_s) || manage_pages_path,
32
+ live_url: (manage_page_live_path(page) if page.live_version),
33
+ token: form_authenticity_token
34
+ }
35
+ end
36
+
37
+ def editor_canvas(page)
38
+ drawn_document(page.document, editing: true, values: Values.sample(page, chosen_sample(page)))
39
+ end
40
+
41
+ def chosen_sample(page)
42
+ session.dig(:keystone_ui_pages_samples, page.id.to_s) || Values.samples_for(page).first&.last
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Pages
5
+ module HostHelper
6
+ include KeystoneUiHelper
7
+ include DrawingHelper
8
+
9
+ def keystone_ui_page(page, values: {})
10
+ version = page.live_version
11
+ return if version.nil?
12
+
13
+ tag.div(drawn_version(version, values: values), class: "@container")
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,21 @@
1
+ import React from "react"
2
+
3
+ export const DEVICES = [
4
+ { key: "phone", label: "Phone", width: "390px" },
5
+ { key: "tablet", label: "Tablet", width: "768px" },
6
+ { key: "desktop", label: "Desktop", width: "100%" },
7
+ { key: "print", label: "Print", width: "794px", theme: "light" }
8
+ ]
9
+
10
+ export default function DeviceWidth({ chosen, onChoose }) {
11
+ return (
12
+ <div data-device-width role="group" aria-label="Preview width" className="flex rounded-md border border-(--ks-color-border) dark:border-(--ks-color-border-dark) p-0.5">
13
+ {DEVICES.map((device) => (
14
+ <button key={device.key} type="button" aria-pressed={chosen === device.key} onClick={() => onChoose(device.key)}
15
+ className="rounded px-3 py-1 text-sm aria-pressed:bg-(--ks-color-accent) aria-pressed:text-white">
16
+ {device.label}
17
+ </button>
18
+ ))}
19
+ </div>
20
+ )
21
+ }
@@ -0,0 +1,78 @@
1
+ import React from "react"
2
+ import Input from "keystone_ui-react/src/Input.jsx"
3
+ import Select from "keystone_ui-react/src/Select.jsx"
4
+ import Textarea from "keystone_ui-react/src/Textarea.jsx"
5
+ import Checkbox from "keystone_ui-react/src/Checkbox.jsx"
6
+ import Button from "keystone_ui-react/src/Button.jsx"
7
+ import { Label } from "keystone_ui-react/src/FieldText.jsx"
8
+ import ListField from "./ListField"
9
+
10
+ const FieldInput = ({ field, value, onChange }) => {
11
+ const shown = value ?? field.default ?? ""
12
+ const shared = {
13
+ id: `ks-pages-field-${field.key}`,
14
+ defaultValue: shown,
15
+ onBlur: (event) => event.target.value !== shown && onChange(field.key, event.target.value)
16
+ }
17
+
18
+ if (field.kind === "yes_or_no") {
19
+ return <Checkbox id={shared.id} defaultChecked={value === "true"} onChange={(event) => onChange(field.key, String(event.target.checked))} />
20
+ }
21
+ if (field.kind === "one_of") {
22
+ return (
23
+ <Select id={shared.id} defaultValue={value ?? field.default ?? ""} onChange={(event) => onChange(field.key, event.target.value)}>
24
+ {field.options.map((option) => <option key={option} value={option}>{option}</option>)}
25
+ </Select>
26
+ )
27
+ }
28
+ if (field.kind === "long_text") return <Textarea {...shared} rows={4} />
29
+ if (field.kind === "number") return <Input {...shared} type="number" />
30
+ if (field.kind === "address") return <Input {...shared} type="url" />
31
+ return <Input {...shared} type="text" />
32
+ }
33
+
34
+ const BINDABLE = ["text", "long_text", "address", "number", "list", "data", undefined]
35
+
36
+ const isBound = (stored) => Boolean(stored) && typeof stored === "object" && !Array.isArray(stored) && "value" in stored
37
+
38
+ const ValueChoice = ({ field, stored, valueNames, onChange, onBind }) => {
39
+ const bound = isBound(stored) ? stored : null
40
+ const typed = bound ? bound.fallback ?? "" : stored ?? field.default ?? ""
41
+
42
+ return (
43
+ <Select id={`ks-pages-value-${field.key}`} aria-label={`${field.label} value`} value={bound?.value ?? ""}
44
+ onChange={(event) => (event.target.value ? onBind(field.key, event.target.value, typed) : onChange(field.key, typed))}>
45
+ <option value="">Typed text</option>
46
+ {Object.entries(valueNames).map(([name, label]) => <option key={name} value={name}>{label}</option>)}
47
+ </Select>
48
+ )
49
+ }
50
+
51
+ export default function ElementFields({ element, definition, alignments, valueNames = {}, onChange, onArrange, onBind, onRemove }) {
52
+ return (
53
+ <aside data-element-fields className="flex flex-col gap-3">
54
+ {definition.fields.map((field) => (
55
+ <div key={`${element.id}-${field.key}`} className="flex flex-col gap-1">
56
+ <Label htmlFor={`ks-pages-field-${field.key}`}>{field.label}</Label>
57
+ {BINDABLE.includes(field.kind) && Object.keys(valueNames).length > 0 &&
58
+ <ValueChoice field={field} stored={element.fields[field.key]} valueNames={valueNames} onChange={onChange} onBind={onBind} />}
59
+ {field.kind === "list" && !isBound(element.fields[field.key]) &&
60
+ <ListField field={field} entries={element.fields[field.key] ?? field.default ?? []} onChange={onChange} />}
61
+ {field.kind === "data" && <p className="ks-hint">Filled with data the host supplies on a template page</p>}
62
+ {field.kind !== "list" && field.kind !== "data" && <FieldInput key={JSON.stringify(element.fields[field.key] ?? null)} field={field}
63
+ value={isBound(element.fields[field.key]) ? element.fields[field.key].fallback ?? "" : element.fields[field.key]}
64
+ onChange={(key, text) => (isBound(element.fields[key]) ? onBind(key, element.fields[key].value, text) : onChange(key, text))} />}
65
+ {isBound(element.fields[field.key]) &&
66
+ <p className="ks-hint">Shows {valueNames[element.fields[field.key].value] ?? element.fields[field.key].value}, or this text when it has no value</p>}
67
+ </div>
68
+ ))}
69
+ <div className="flex flex-col gap-1">
70
+ <Label htmlFor="ks-pages-element-align">Horizontal alignment</Label>
71
+ <Select key={`${element.id}-align`} id="ks-pages-element-align" defaultValue={element.align ?? definition.align ?? "stretch"} onChange={(event) => onArrange(element.id, "align", event.target.value)}>
72
+ {alignments.map((value) => <option key={value} value={value}>{value}</option>)}
73
+ </Select>
74
+ </div>
75
+ <Button type="button" variant="danger" onClick={() => onRemove(element.id)}>Remove element</Button>
76
+ </aside>
77
+ )
78
+ }
@@ -0,0 +1,62 @@
1
+ import React, { forwardRef, useState } from "react"
2
+ import Input from "keystone_ui-react/src/Input.jsx"
3
+
4
+ const ICONS = {
5
+ cursor: "M5 3l14 8-6 2-2 6z",
6
+ tag: "M3 7v6l8 8 8-8-8-8H5a2 2 0 0 0-2 2zM7.5 7.5h.01",
7
+ copy: "M8 8h11v11H8zM5 16V5h11",
8
+ heading: "M6 4v16M18 4v16M6 12h12",
9
+ text: "M4 6h16M4 12h16M4 18h10",
10
+ list: "M4 6h4M12 6h8M4 12h4M12 12h8M4 18h4M12 18h8",
11
+ info: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 16v-4M12 8h.01",
12
+ code: "M8 6l-6 6 6 6M16 6l6 6-6 6",
13
+ alert: "M12 3l10 18H2zM12 10v4M12 17h.01",
14
+ card: "M3 5h18v14H3zM3 10h18",
15
+ megaphone: "M3 11v2l13 5V6zM16 9a3 3 0 0 1 0 6M6 14l1 5h3l-1-4",
16
+ chart: "M4 20V10M10 20V4M16 20v-7M22 20H2",
17
+ bars: "M3 21h18M6 17v-6h3v6M11 17V7h3v10M16 17v-4h3v4",
18
+ number: "M10 3L8 21M16 3l-2 18M4 9h17M3 15h17",
19
+ progress: "M3 12h18M3 12h10",
20
+ box: "M3 3h18v18H3z"
21
+ }
22
+
23
+ const grouped = (elements) => {
24
+ const groups = new Map()
25
+ elements.forEach((element) => {
26
+ const group = element.group ?? "Other"
27
+ groups.set(group, [...(groups.get(group) ?? []), element])
28
+ })
29
+ return [...groups]
30
+ }
31
+
32
+ const PaletteItem = ({ element, shown }) => (
33
+ <button type="button" data-element-key={element.key} hidden={!shown}
34
+ className="flex cursor-grab items-center gap-3 rounded-md border border-(--ks-color-border) bg-(--ks-color-surface) px-3 py-2 dark:border-(--ks-color-border-dark) dark:bg-(--ks-color-surface-dark) text-left text-sm hover:border-(--ks-color-accent) dark:bg-transparent">
35
+ <svg viewBox="0 0 24 24" aria-hidden="true" className="size-5 shrink-0 fill-none stroke-current stroke-2 text-(--ks-color-icon)" strokeLinecap="round" strokeLinejoin="round">
36
+ <path d={ICONS[element.icon] ?? ICONS.box} />
37
+ </svg>
38
+ <span>{element.name}</span>
39
+ </button>
40
+ )
41
+
42
+ const ElementPalette = forwardRef(function ElementPalette({ elements, children }, palette) {
43
+ const [search, setSearch] = useState("")
44
+ const matches = (element) => element.name.toLowerCase().includes(search.trim().toLowerCase())
45
+
46
+ return (
47
+ <aside ref={palette} data-element-palette className="flex w-64 shrink-0 flex-col gap-5 overflow-y-auto border-r border-(--ks-color-border) bg-(--ks-color-surface) p-4 dark:border-(--ks-color-border-dark) dark:bg-(--ks-color-surface-dark)">
48
+ <Input type="search" aria-label="Search elements" placeholder="Search elements" value={search} onChange={(event) => setSearch(event.target.value)} />
49
+ {grouped(elements).map(([group, members]) => (
50
+ <section key={group} data-element-group={group} hidden={!members.some(matches)} className="flex flex-col gap-2">
51
+ <h2 className="text-xs font-semibold uppercase tracking-wide text-(--ks-color-icon)">{group}</h2>
52
+ {members.map((element) => (
53
+ <PaletteItem key={element.key} element={element} shown={matches(element)} />
54
+ ))}
55
+ </section>
56
+ ))}
57
+ {children}
58
+ </aside>
59
+ )
60
+ })
61
+
62
+ export default ElementPalette
@@ -0,0 +1,41 @@
1
+ import React from "react"
2
+ import Button from "keystone_ui-react/src/Button.jsx"
3
+ import Select from "keystone_ui-react/src/Select.jsx"
4
+ import Alert from "keystone_ui-react/src/Alert.jsx"
5
+ import { Label } from "keystone_ui-react/src/FieldText.jsx"
6
+
7
+ const LABELS = {
8
+ section: { spacing: "Section spacing" },
9
+ row: { gap: "Row gap" },
10
+ column: { wide: "Width on wide screens", narrow: "Width on narrow screens", align: "Vertical alignment" }
11
+ }
12
+
13
+ export default function LayoutPanel({ node, values, arrangements, refusal, onAddColumn, onAddRow, onArrange, onSelect, onRemove }) {
14
+ const labels = LABELS[node.kind]
15
+ const settings = Object.keys(arrangements[node.kind]).filter((setting) => labels[setting])
16
+
17
+ return (
18
+ <aside data-layout-panel className="flex flex-col gap-3">
19
+ {refusal && <div data-layout-refusal><Alert type="error" message={refusal} /></div>}
20
+ {settings.map((setting) => (
21
+ <div key={`${node.id}-${setting}`} className="flex flex-col gap-1">
22
+ <Label htmlFor={`ks-pages-${setting}`}>{labels[setting]}</Label>
23
+ <Select id={`ks-pages-${setting}`} defaultValue={values[setting] ?? ""} onChange={(event) => onArrange(node.id, setting, event.target.value)}>
24
+ {arrangements[node.kind][setting].map((value) => <option key={value} value={value}>{value}</option>)}
25
+ </Select>
26
+ </div>
27
+ ))}
28
+ {node.kind === "column" && (
29
+ <>
30
+ <Button type="button" variant="secondary" onClick={() => onAddColumn(node.rowId)}>Add column</Button>
31
+ <Button type="button" variant="secondary" onClick={() => onAddRow(node.sectionId)}>Add row</Button>
32
+ <Button type="button" variant="secondary" onClick={() => onSelect({ kind: "row", id: node.rowId, sectionId: node.sectionId })}>Select row</Button>
33
+ </>
34
+ )}
35
+ {node.kind === "row" && (
36
+ <Button type="button" variant="secondary" onClick={() => onSelect({ kind: "section", id: node.sectionId })}>Select section</Button>
37
+ )}
38
+ <Button type="button" variant="danger" onClick={() => onRemove(node.id)}>Remove {node.kind}</Button>
39
+ </aside>
40
+ )
41
+ }
@@ -0,0 +1,49 @@
1
+ import React from "react"
2
+ import Input from "keystone_ui-react/src/Input.jsx"
3
+ import Button from "keystone_ui-react/src/Button.jsx"
4
+ import Checkbox from "keystone_ui-react/src/Checkbox.jsx"
5
+ import Select from "keystone_ui-react/src/Select.jsx"
6
+ import { Label } from "keystone_ui-react/src/FieldText.jsx"
7
+
8
+ const swapped = (entries, from, to) => {
9
+ const changed = [...entries]
10
+ ;[changed[from], changed[to]] = [changed[to], changed[from]]
11
+ return changed
12
+ }
13
+
14
+ export default function ListField({ field, entries, onChange }) {
15
+ const parts = (field.entry ?? []).filter((part) => !part.hidden)
16
+ const saved = (changed) => onChange(field.key, changed)
17
+
18
+ return (
19
+ <div data-list-field={field.key} className="flex flex-col gap-2">
20
+ {entries.map((entry, index) => (
21
+ <fieldset key={`${index}-${JSON.stringify(entry)}`} data-list-entry className="flex flex-col gap-2 rounded-md border border-(--ks-color-border) dark:border-(--ks-color-border-dark) p-2">
22
+ {parts.map((part) => (
23
+ <div key={part.key} className="flex flex-col gap-1">
24
+ <Label htmlFor={`ks-pages-${field.key}-${index}-${part.key}`}>{part.label}</Label>
25
+ {part.kind === "one_of" &&
26
+ <Select id={`ks-pages-${field.key}-${index}-${part.key}`} defaultValue={entry[part.key] ?? part.default ?? ""}
27
+ onChange={(event) => saved(entries.map((kept, place) => (place === index ? { ...kept, [part.key]: event.target.value } : kept)))}>
28
+ <option value=""></option>
29
+ {part.options.map((option) => <option key={option} value={option}>{option}</option>)}
30
+ </Select>}
31
+ {part.kind !== "one_of" && (part.kind === "yes_or_no"
32
+ ? <Checkbox id={`ks-pages-${field.key}-${index}-${part.key}`} className="self-start" defaultChecked={entry[part.key] === "true"}
33
+ onChange={(event) => saved(entries.map((kept, place) => (place === index ? { ...kept, [part.key]: String(event.target.checked) } : kept)))} />
34
+ : <Input id={`ks-pages-${field.key}-${index}-${part.key}`} type="text" defaultValue={entry[part.key] ?? part.default ?? ""}
35
+ onBlur={(event) => event.target.value !== (entry[part.key] ?? part.default ?? "") &&
36
+ saved(entries.map((kept, place) => (place === index ? { ...kept, [part.key]: event.target.value } : kept)))} />)}
37
+ </div>
38
+ ))}
39
+ <div className="flex flex-wrap gap-2">
40
+ <Button type="button" size="sm" variant="secondary" disabled={index === 0} onClick={() => saved(swapped(entries, index, index - 1))}>Move up</Button>
41
+ <Button type="button" size="sm" variant="secondary" disabled={index === entries.length - 1} onClick={() => saved(swapped(entries, index, index + 1))}>Move down</Button>
42
+ <Button type="button" size="sm" variant="danger" onClick={() => saved(entries.filter((_kept, place) => place !== index))}>Remove item</Button>
43
+ </div>
44
+ </fieldset>
45
+ ))}
46
+ <Button type="button" size="sm" variant="secondary" onClick={() => saved([...entries, {}])}>Add item</Button>
47
+ </div>
48
+ )
49
+ }
@@ -0,0 +1,17 @@
1
+ import React from "react"
2
+ import Select from "keystone_ui-react/src/Select.jsx"
3
+
4
+ const titled = (name) => name.replace(/_/g, " ").replace(/^./, (first) => first.toUpperCase())
5
+
6
+ export default function LookChoice({ looks }) {
7
+ if (looks.length === 0) return null
8
+
9
+ return (
10
+ <label className="flex items-center gap-2 text-sm">
11
+ <span>Look</span>
12
+ <Select data-look-choice defaultValue={document.documentElement.dataset.look} onChange={(event) => { document.documentElement.dataset.look = event.target.value }} className="py-1">
13
+ {looks.map((name) => <option key={name} value={name}>{titled(name)}</option>)}
14
+ </Select>
15
+ </label>
16
+ )
17
+ }