keystone_ui-pages 0.3.0 → 0.4.1

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.
@@ -22,7 +22,10 @@ module KeystoneUi
22
22
 
23
23
  def field_value
24
24
  value = params.to_unsafe_h["value"]
25
- value.is_a?(Array) ? value.map { |entry| entry.to_h.stringify_keys } : value.to_s
25
+ return value.map { |entry| entry.to_h.stringify_keys } if value.is_a?(Array)
26
+ return { "html" => value["html"].to_s } if value.is_a?(Hash) && value.key?("html")
27
+
28
+ value.to_s
26
29
  end
27
30
  end
28
31
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KeystoneUi
4
+ module Pages
5
+ module Manage
6
+ class TitlesController < BaseController
7
+ include EditsPages
8
+
9
+ def update
10
+ changed(Operations.title(page.document, params[:title]))
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -9,15 +9,24 @@ module KeystoneUi
9
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
10
  ELEMENT_ALIGNMENTS = { "stretch" => "flex flex-col", "start" => "flex justify-start", "center" => "flex justify-center", "end" => "flex justify-end" }.freeze
11
11
  COLUMN_ALIGNMENTS = { "top" => "justify-start", "center" => "justify-center", "bottom" => "justify-end" }.freeze
12
+ SIZED_SECTION_PADDING = "py-[calc(var(--ks-spacing)*16)] lg:py-[calc(var(--ks-spacing)*24)]"
13
+ SHADED_CLASSES = "bg-(color:--ks-color-fill-muted) dark:bg-(color:--ks-color-fill-muted-dark)"
12
14
  PLAIN_FIGURE_DIVIDER = "@sm:[.ks-pages-column:has(.ks-metric-plain)+&:has(.ks-metric-plain)]:border-l-(length:--ks-border-width) " \
13
15
  "@sm:[.ks-pages-column:has(.ks-metric-plain)+&:has(.ks-metric-plain)]:border-(color:--ks-color-border) " \
14
16
  "dark:@sm:[.ks-pages-column:has(.ks-metric-plain)+&:has(.ks-metric-plain)]:border-(color:--ks-color-border-dark)"
15
17
 
16
18
  def drawn_version(version, values: {})
17
19
  key = [ "keystone_ui_pages", version.id, Operations.digest(values.to_h.stringify_keys) ]
20
+ @drawn_page_id = version.page_id
18
21
  Rails.cache.fetch(key) { drawn_document(version.document, values: values).to_str }.html_safe
19
22
  end
20
23
 
24
+ def pages_paragraph(text:)
25
+ return ui_paragraph(text: text) unless text.is_a?(Hash)
26
+
27
+ rich_paragraphs(text["html"].to_s)
28
+ end
29
+
21
30
  def drawn_document(document, editing: false, values: {})
22
31
  @drawn_values = values.to_h.stringify_keys
23
32
  safe_join(document["sections"].map { |section| drawn_section(section, editing) })
@@ -25,12 +34,31 @@ module KeystoneUi
25
34
 
26
35
  private
27
36
 
37
+ def rich_paragraphs(html)
38
+ stored = Nokogiri::HTML5.fragment(html)
39
+ stored.css("script, style").each(&:remove)
40
+ fragment = Nokogiri::HTML5.fragment(Rails::HTML5::SafeListSanitizer.new.sanitize(stored.to_html, tags: %w[p br a], attributes: %w[href]))
41
+ fragment.css("a:not([href])").each { |link| link.replace(link.children) }
42
+ fragment.css("p").each { |paragraph| paragraph["class"] = "ks-paragraph" }
43
+ fragment.css("a[href^='http://'], a[href^='https://']").each { |link| link["target"], link["rel"] = "_blank", "noopener noreferrer" }
44
+ fragment.to_html.html_safe
45
+ end
46
+
28
47
  def drawn_section(section, editing)
29
48
  return if hidden?(section) && !editing
30
49
 
31
50
  spacing = Keystone::Ui::SectionComponent::SPACING_CLASSES.fetch((section["spacing"] || Operations::DEFAULTS[:section]["spacing"]).to_sym)
32
51
  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)
52
+ shading = SHADED_CLASSES if section["background"] == "shaded"
53
+ tag.section(section_content(section, editing), class: [ "ks-pages-section flex flex-col gap-6 print:break-inside-avoid #{spacing}", shading ].compact.join(" "), data: data)
54
+ end
55
+
56
+ def section_content(section, editing)
57
+ rows = safe_join(section["rows"].map { |row| drawn_row(row, editing) })
58
+ width = section["width"]
59
+ return rows if width.nil? || width == "none"
60
+
61
+ tag.div(rows, class: "ks-page #{Keystone::Ui::PageComponent::MAX_WIDTH_CLASSES.fetch(width.to_sym)} w-full flex flex-col gap-6 #{SIZED_SECTION_PADDING}")
34
62
  end
35
63
 
36
64
  def drawn_row(row, editing)
@@ -52,10 +80,25 @@ module KeystoneUi
52
80
  end
53
81
 
54
82
  def edited_element(element)
83
+ return missing_element(element) unless KeystoneUi::Pages.catalog.find(element["key"])
84
+
55
85
  wrapped_element(element, data: { element_id: element["id"], hidden: hidden?(element) || nil }.compact)
56
86
  end
57
87
 
88
+ def reported_missing(element)
89
+ Rails.error.report(InvalidDocument.new("No element is registered as #{element["key"]}"), handled: true,
90
+ context: { page_id: @drawn_page_id, element_key: element["key"] })
91
+ nil
92
+ end
93
+
94
+ def missing_element(element)
95
+ tag.div("No longer offered: #{element["key"]}", class: "ks-pages-element ks-hint rounded-md border-2 border-dashed border-(--ks-color-border-strong) p-3",
96
+ data: { element_id: element["id"], missing_element: true })
97
+ end
98
+
58
99
  def drawn_element(element)
100
+ return reported_missing(element) unless KeystoneUi::Pages.catalog.find(element["key"])
101
+
59
102
  wrapped_element(element) unless hidden?(element)
60
103
  end
61
104
 
@@ -75,6 +118,7 @@ module KeystoneUi
75
118
  return [] if (field[:kind] == :list || (field[:kind] == :data && field[:default].is_a?(Array))) && value.blank?
76
119
  return nil if field[:kind] == :data && value.blank?
77
120
  return value if value.blank?
121
+ return ERB::Util.html_escape(value) if field[:as] == :escaped_text
78
122
  return value.to_sym if field[:as] == :key
79
123
  return value.to_s == "true" if field[:kind] == :yes_or_no
80
124
  return value.to_sym if field[:kind] == :one_of
@@ -91,7 +135,7 @@ module KeystoneUi
91
135
  end
92
136
 
93
137
  def filled(stored)
94
- return stored.presence unless stored.is_a?(Hash)
138
+ return stored.presence unless stored.is_a?(Hash) && stored.key?("value")
95
139
 
96
140
  (@drawn_values || {})[stored["value"]].presence || stored["fallback"].presence || ""
97
141
  end
@@ -23,6 +23,7 @@ module KeystoneUi
23
23
  samples: Values.samples_for(page),
24
24
  sample: chosen_sample(page),
25
25
  samples_url: manage_page_sample_path(page),
26
+ title_url: manage_page_title_path(page),
26
27
  access_exceptions: KeystoneUi::Pages.access_exceptions.invert.to_a,
27
28
  access_exception: page.access_exception,
28
29
  access_exception_url: manage_page_access_exception_path(page),
@@ -6,6 +6,7 @@ import Checkbox from "keystone_ui-react/src/Checkbox.jsx"
6
6
  import Button from "keystone_ui-react/src/Button.jsx"
7
7
  import { Label } from "keystone_ui-react/src/FieldText.jsx"
8
8
  import ListField from "./ListField"
9
+ import RichTextField from "./RichTextField"
9
10
 
10
11
  const FieldInput = ({ field, value, onChange }) => {
11
12
  const shown = value ?? field.default ?? ""
@@ -59,7 +60,9 @@ export default function ElementFields({ element, definition, alignments, valueNa
59
60
  {field.kind === "list" && !isBound(element.fields[field.key]) &&
60
61
  <ListField field={field} entries={element.fields[field.key] ?? field.default ?? []} onChange={onChange} />}
61
62
  {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
+ {field.kind === "rich_text" &&
64
+ <RichTextField key={JSON.stringify(element.fields[field.key] ?? null)} field={field} value={element.fields[field.key]} onChange={onChange} />}
65
+ {field.kind !== "list" && field.kind !== "data" && field.kind !== "rich_text" && <FieldInput key={JSON.stringify(element.fields[field.key] ?? null)} field={field}
63
66
  value={isBound(element.fields[field.key]) ? element.fields[field.key].fallback ?? "" : element.fields[field.key]}
64
67
  onChange={(key, text) => (isBound(element.fields[key]) ? onBind(key, element.fields[key].value, text) : onChange(key, text))} />}
65
68
  {isBound(element.fields[field.key]) &&
@@ -5,7 +5,7 @@ import Alert from "keystone_ui-react/src/Alert.jsx"
5
5
  import { Label } from "keystone_ui-react/src/FieldText.jsx"
6
6
 
7
7
  const LABELS = {
8
- section: { spacing: "Section spacing" },
8
+ section: { spacing: "Section spacing", background: "Section background", width: "Section width" },
9
9
  row: { gap: "Row gap" },
10
10
  column: { wide: "Width on wide screens", narrow: "Width on narrow screens", align: "Vertical alignment" }
11
11
  }
@@ -2,13 +2,14 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from "react"
2
2
  import { DragDropManager, Draggable, Droppable, KeyboardSensor, PointerSensor } from "@dnd-kit/dom"
3
3
  import { Sortable } from "@dnd-kit/dom/sortable"
4
4
  import Button from "keystone_ui-react/src/Button.jsx"
5
- import { addColumn, addElement, addRow, addSection, arrange, bindField, chooseAccessException, chooseSample, duplicateNode, moveNode, removeNode, setField, showWhen, stepHistory } from "./operations"
5
+ import { addColumn, addElement, addRow, addSection, arrange, bindField, chooseAccessException, chooseSample, duplicateNode, moveNode, removeNode, setField, setTitle, showWhen, stepHistory } from "./operations"
6
6
  import LayoutPanel from "./LayoutPanel"
7
7
  import DeviceWidth, { DEVICES } from "./DeviceWidth"
8
8
  import SelectionToolbar from "./SelectionToolbar"
9
9
  import ElementPalette from "./ElementPalette"
10
10
  import SelectionPath from "./SelectionPath"
11
11
  import SampleChoice from "./SampleChoice"
12
+ import PageTitle from "./PageTitle"
12
13
  import AccessExceptionChoice from "./AccessExceptionChoice"
13
14
  import ShowWhen from "./ShowWhen"
14
15
  import ThemeMode from "./ThemeMode"
@@ -29,7 +30,7 @@ const innermost = (target) => NODES.map(([selector, kind]) => [target.closest(se
29
30
 
30
31
  const PLACED_SENSORS = [PointerSensor.configure({ preventActivation: () => false }), KeyboardSensor]
31
32
 
32
- export default function PageEditor({ name, publish_url, unpublish_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, access_exceptions = [], access_exception, access_exception_url, history_url, live_url, back_url, undo_url, redo_url, undoable: couldUndo, redoable: couldRedo, token }) {
33
+ export default function PageEditor({ name, publish_url, unpublish_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, title_url, access_exceptions = [], access_exception, access_exception_url, history_url, live_url, back_url, undo_url, redo_url, undoable: couldUndo, redoable: couldRedo, token }) {
33
34
  const [canvas, setCanvas] = useState(drawn)
34
35
  const [undoable, setUndoable] = useState(couldUndo)
35
36
  const [redoable, setRedoable] = useState(couldRedo)
@@ -245,6 +246,7 @@ export default function PageEditor({ name, publish_url, unpublish_url, elements,
245
246
  <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)">
246
247
  <SelectionPath path={selectionPath} onChoose={choosePart} />
247
248
  {!node && !definition && <p className="ks-hint">Select something on the page to change it.</p>}
249
+ {!node && !definition && <PageTitle key={document.title ?? ""} title={document.title} onChange={(title) => setTitle(title_url, token, title).then(shown, refused)} />}
248
250
  {node && <LayoutPanel node={node} values={inUse(node)} arrangements={arrangements}
249
251
  onArrange={(id, setting, value) => arrange(nodes_url, token, id, setting, value).then(shown, refused)}
250
252
  refusal={refusal}
@@ -0,0 +1,16 @@
1
+ import React from "react"
2
+ import Input from "keystone_ui-react/src/Input.jsx"
3
+ import { Label } from "keystone_ui-react/src/FieldText.jsx"
4
+
5
+ export default function PageTitle({ title, onChange }) {
6
+ const shown = title ?? ""
7
+
8
+ return (
9
+ <div className="flex flex-col gap-1">
10
+ <Label htmlFor="ks-pages-page-title">Page title</Label>
11
+ <Input id="ks-pages-page-title" type="text" defaultValue={shown}
12
+ onBlur={(event) => event.target.value !== shown && onChange(event.target.value)} />
13
+ <p className="ks-hint">Shown in the browser tab once the page is published</p>
14
+ </div>
15
+ )
16
+ }
@@ -0,0 +1,100 @@
1
+ import React, { useEffect, useState } from "react"
2
+ import Input from "keystone_ui-react/src/Input.jsx"
3
+ import Button from "keystone_ui-react/src/Button.jsx"
4
+ import { Label } from "keystone_ui-react/src/FieldText.jsx"
5
+ import { LexicalComposer } from "@lexical/react/LexicalComposer"
6
+ import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"
7
+ import { ContentEditable } from "@lexical/react/LexicalContentEditable"
8
+ import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"
9
+ import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"
10
+ import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"
11
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
12
+ import { LinkNode, TOGGLE_LINK_COMMAND, $isLinkNode } from "@lexical/link"
13
+ import { $generateHtmlFromNodes, $generateNodesFromDOM } from "@lexical/html"
14
+ import { $getRoot, $getSelection, $isRangeSelection, $createParagraphNode, $createTextNode, TextNode } from "lexical"
15
+
16
+ const filled = (stored) => (editor) => {
17
+ const root = $getRoot()
18
+ if (stored && typeof stored === "object" && "html" in stored) {
19
+ const dom = new DOMParser().parseFromString(stored.html, "text/html")
20
+ root.append(...$generateNodesFromDOM(editor, dom))
21
+ } else {
22
+ root.append($createParagraphNode().append($createTextNode(stored ?? "")))
23
+ }
24
+ }
25
+
26
+ const selectedLink = () => {
27
+ const selection = $getSelection()
28
+ if (!$isRangeSelection(selection)) return null
29
+ const node = selection.anchor.getNode()
30
+ return $isLinkNode(node) ? node : $isLinkNode(node.getParent()) ? node.getParent() : null
31
+ }
32
+
33
+ const PlainWords = () => {
34
+ const [editor] = useLexicalComposerContext()
35
+ useEffect(() => editor.registerNodeTransform(TextNode, (node) => {
36
+ if (node.getFormat() !== 0) node.setFormat(0)
37
+ if (node.getStyle() !== "") node.setStyle("")
38
+ }), [editor])
39
+ return null
40
+ }
41
+
42
+ const LinkControls = () => {
43
+ const [editor] = useLexicalComposerContext()
44
+ const [address, setAddress] = useState(null)
45
+
46
+ const opened = () => editor.getEditorState().read(() => setAddress(selectedLink()?.getURL() ?? ""))
47
+ const saved = () => {
48
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, address || null)
49
+ setAddress(null)
50
+ }
51
+ const removed = () => {
52
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
53
+ setAddress(null)
54
+ }
55
+
56
+ if (address === null) return <Button type="button" variant="secondary" onClick={opened}>Link</Button>
57
+
58
+ return (
59
+ <div className="flex flex-col gap-2">
60
+ <Label htmlFor="ks-pages-link-address">Link address</Label>
61
+ <Input id="ks-pages-link-address" type="url" value={address} onChange={(event) => setAddress(event.target.value)} />
62
+ <div className="flex gap-2">
63
+ <Button type="button" onClick={saved}>Save link</Button>
64
+ <Button type="button" variant="secondary" onClick={removed}>Remove link</Button>
65
+ </div>
66
+ </div>
67
+ )
68
+ }
69
+
70
+ const SavedOnLeave = ({ fieldKey, onChange, children }) => {
71
+ const [editor] = useLexicalComposerContext()
72
+ const left = (event) => {
73
+ if (event.currentTarget.contains(event.relatedTarget)) return
74
+ editor.getEditorState().read(() => onChange(fieldKey, { html: $generateHtmlFromNodes(editor, null) }))
75
+ }
76
+ return <div data-rich-text-field={fieldKey} onBlur={left} className="flex flex-col gap-2">{children}</div>
77
+ }
78
+
79
+ export default function RichTextField({ field, value, onChange }) {
80
+ const config = {
81
+ namespace: `ks-pages-${field.key}`,
82
+ nodes: [LinkNode],
83
+ editorState: filled(value ?? field.default),
84
+ onError: (error) => { throw error }
85
+ }
86
+
87
+ return (
88
+ <LexicalComposer initialConfig={config}>
89
+ <SavedOnLeave fieldKey={field.key} onChange={onChange}>
90
+ <RichTextPlugin
91
+ contentEditable={<ContentEditable id={`ks-pages-field-${field.key}`} className="ks-input min-h-24" />}
92
+ ErrorBoundary={LexicalErrorBoundary} />
93
+ <HistoryPlugin />
94
+ <LinkPlugin />
95
+ <PlainWords />
96
+ <LinkControls />
97
+ </SavedOnLeave>
98
+ </LexicalComposer>
99
+ )
100
+ }
@@ -7,6 +7,8 @@ const sent = (url, token, method, body = {}) =>
7
7
 
8
8
  export const addElement = (url, token, key, columnId) => sent(url, token, "POST", { key, column_id: columnId })
9
9
 
10
+ export const setTitle = (url, token, title) => sent(url, token, "POST", { title })
11
+
10
12
  export const setField = (url, token, elementId, field, value) => sent(`${url}/${elementId}`, token, "PATCH", { field, value })
11
13
 
12
14
  export const addSection = (url, token) => sent(url, token, "POST", {})
@@ -8,6 +8,10 @@ module KeystoneUi
8
8
  enum :status, { live: "live", superseded: "superseded" }
9
9
 
10
10
  belongs_to :page, class_name: "KeystoneUi::Pages::Page", inverse_of: :versions
11
+
12
+ def title
13
+ document["title"]
14
+ end
11
15
  end
12
16
  end
13
17
  end
@@ -1,3 +1,4 @@
1
+ <% content_for(:title, @version.title) if @version.title %>
1
2
  <div data-live-page class="@container print:bg-white print:[print-color-adjust:exact]">
2
3
  <%= drawn_version(@version, values: @values.to_h) %>
3
4
  </div>
data/config/routes.rb CHANGED
@@ -20,6 +20,7 @@ KeystoneUi::Pages::Engine.routes.draw do
20
20
  end
21
21
  resource :document, only: %i[update]
22
22
  post :sample, to: "samples#update"
23
+ post :title, to: "titles#update"
23
24
  post :access_exception, to: "access_exceptions#update"
24
25
  post :undo, to: "edit_history#undo"
25
26
  post :redo, to: "edit_history#redo"
@@ -23,8 +23,8 @@ module KeystoneUi
23
23
  { key: :note, label: "Note", kind: :text } ])
24
24
  catalog.register(key: :label, name: "Label", component: :ui_caption, icon: :tag, group: "Text",
25
25
  fields: [ { key: :text, label: "Text", kind: :text, default: "Label" } ])
26
- catalog.register(key: :paragraph, name: "Paragraph", component: :ui_paragraph, icon: :text, group: "Text",
27
- fields: [ { key: :text, label: "Text", kind: :long_text, default: "Write the text of this paragraph here." } ])
26
+ catalog.register(key: :paragraph, name: "Paragraph", component: :pages_paragraph, icon: :text, group: "Text",
27
+ fields: [ { key: :text, label: "Text", kind: :rich_text, default: "Write the text of this paragraph here." } ])
28
28
  catalog.register(key: :hero, name: "Hero", component: :ui_hero, icon: :heading, group: "Text",
29
29
  fields: [ { key: :title, label: "Title", kind: :text, default: "Headline" },
30
30
  { key: :subtitle, label: "Subtitle", kind: :long_text },
@@ -66,7 +66,7 @@ module KeystoneUi
66
66
  { key: :subtitle, label: "Subtitle", kind: :text },
67
67
  { key: :features, label: "Features", kind: :list,
68
68
  entry: [ { key: :title, label: "Title", kind: :text }, { key: :description, label: "Description", kind: :long_text },
69
- { key: :icon, label: "Icon", kind: :text, hidden: true,
69
+ { key: :icon, label: "Icon", kind: :text, as: :escaped_text,
70
70
  default: '<svg viewBox="0 0 24 24" class="size-6 fill-none stroke-current stroke-2"><path d="M5 12l5 5L20 7"/></svg>' } ],
71
71
  default: [ { "title" => "Fast", "description" => "Pages load quickly." },
72
72
  { "title" => "Clear", "description" => "Every page reads plainly." },
@@ -14,13 +14,13 @@ module KeystoneUi
14
14
  module Operations
15
15
  WIDTHS = %w[1/1 3/4 2/3 1/2 1/3 1/4].freeze
16
16
  ARRANGEMENTS = {
17
- section: { "spacing" => %w[sm md lg] },
17
+ section: { "spacing" => %w[sm md lg], "background" => %w[plain shaded], "width" => %w[none sm md lg xl] },
18
18
  row: { "gap" => %w[sm md lg xl] },
19
19
  column: { "wide" => WIDTHS, "narrow" => WIDTHS, "align" => %w[top center bottom] },
20
20
  element: { "align" => %w[stretch start center end] }
21
21
  }.freeze
22
22
  DEFAULTS = {
23
- section: { "spacing" => "md" },
23
+ section: { "spacing" => "md", "background" => "plain", "width" => "none" },
24
24
  row: { "gap" => "md" },
25
25
  column: { "narrow" => "1/1", "align" => "top" },
26
26
  element: { "align" => "stretch" }
@@ -36,6 +36,10 @@ module KeystoneUi
36
36
  end
37
37
  end
38
38
 
39
+ def title(document, text)
40
+ copied(document).tap { |changed| changed["title"] = text.to_s.strip.presence }
41
+ end
42
+
39
43
  def add_section(document)
40
44
  copied(document).tap { |changed| changed["sections"] << Document.section }
41
45
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module KeystoneUi
4
4
  module Pages
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.1"
6
6
  end
7
7
  end
@@ -257,8 +257,11 @@ The catalog offers these Keystone UI components by default: `button`,
257
257
 
258
258
  The method must be reachable in the engine's views and in any host view that
259
259
  calls `keystone_ui_page`. A `helper_method` on the base controller covers
260
- both. Leaving out an element that pages already use makes those pages fail
261
- to draw, so search the pages' documents for its key before leaving it out.
260
+ both. A page that holds an element the catalog no longer offers still
261
+ draws its other elements for visitors, marks the missing one in the editor
262
+ as no longer offered, and reports the page's id and the element's key
263
+ through `Rails.error`, so search the pages' documents for its key before
264
+ leaving it out.
262
265
 
263
266
  ## Conventions
264
267
 
@@ -278,6 +281,29 @@ The catalog offers these Keystone UI components by default: `button`,
278
281
  an Archive button, and a published page also links to View live and, when
279
282
  Chrome or Chromium is found, to its PDF. Show archived lists archived pages
280
283
  instead, each with a Restore button.
284
+ - **A page's title** is set in the builder's settings panel when nothing is
285
+ selected, and reaches visitors when the page is published. The engine's
286
+ public address sets it with `content_for(:title)`, so a host layout that
287
+ shows `content_for(:title)` in its `<title>` gets it. A host drawing a page
288
+ in its own view reads `page.live_version&.title`, which is nil when the page
289
+ has none.
290
+ - **A paragraph's text is rich text.** Editors type it in the builder and
291
+ link selected words with the Link button, and pasted text keeps only its
292
+ words and links. It is stored as `{ "html" => "..." }` and cleaned each time
293
+ it is drawn, keeping paragraphs, line breaks and links with safe addresses.
294
+ A link to an `http` or `https` address opens in a new tab. A paragraph saved
295
+ earlier as a plain string still draws as escaped text.
296
+ - **A feature grid's icons** are typed per feature in the builder, usually as
297
+ an emoji. A typed icon is drawn as text, so markup or a script in it is shown
298
+ and never run. A feature left without an icon shows the check mark.
299
+ - **A section's background and width** are set in the builder's layout panel.
300
+ A shaded section fills the full width of the page with keystone_ui's
301
+ `--ks-color-fill-muted` colour, and its dark value in a dark theme. A section
302
+ with a width of `sm`, `md`, `lg` or `xl` holds its rows at keystone_ui's
303
+ page width of that name with its page side padding, padded above and below
304
+ from `--ks-spacing`. The page builder has no setting of its own for these:
305
+ a host changes them through its keystone_ui theme values. A section saved
306
+ without either draws as it did before.
281
307
  - **A page's settings** at `/pages/manage/pages/:id/edit` change its name, slug
282
308
  and place after it is created. A slug another page of the same owner uses, or
283
309
  a place the host does not name, is refused with a message and changes nothing.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: keystone_ui-pages
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tyler Schneider
@@ -90,6 +90,7 @@ files:
90
90
  - app/controllers/keystone_ui/pages/manage/rows_controller.rb
91
91
  - app/controllers/keystone_ui/pages/manage/samples_controller.rb
92
92
  - app/controllers/keystone_ui/pages/manage/sections_controller.rb
93
+ - app/controllers/keystone_ui/pages/manage/titles_controller.rb
93
94
  - app/controllers/keystone_ui/pages/manage/versions_controller.rb
94
95
  - app/controllers/keystone_ui/pages/pages_controller.rb
95
96
  - app/helpers/keystone_ui/pages/drawing_helper.rb
@@ -103,6 +104,8 @@ files:
103
104
  - app/javascript/keystone_ui_pages/ListField.jsx
104
105
  - app/javascript/keystone_ui_pages/LookChoice.jsx
105
106
  - app/javascript/keystone_ui_pages/PageEditor.jsx
107
+ - app/javascript/keystone_ui_pages/PageTitle.jsx
108
+ - app/javascript/keystone_ui_pages/RichTextField.jsx
106
109
  - app/javascript/keystone_ui_pages/SampleChoice.jsx
107
110
  - app/javascript/keystone_ui_pages/SelectionPath.jsx
108
111
  - app/javascript/keystone_ui_pages/SelectionToolbar.jsx