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,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,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
|
+
}
|