katalyst-content 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +95 -0
  4. data/app/assets/config/katalyst-content.js +1 -0
  5. data/app/assets/javascripts/controllers/content/editor/container_controller.js +113 -0
  6. data/app/assets/javascripts/controllers/content/editor/item_controller.js +45 -0
  7. data/app/assets/javascripts/controllers/content/editor/list_controller.js +105 -0
  8. data/app/assets/javascripts/controllers/content/editor/new_item_controller.js +12 -0
  9. data/app/assets/javascripts/controllers/content/editor/status_bar_controller.js +22 -0
  10. data/app/assets/javascripts/utils/content/editor/container.js +52 -0
  11. data/app/assets/javascripts/utils/content/editor/item.js +245 -0
  12. data/app/assets/javascripts/utils/content/editor/rules-engine.js +177 -0
  13. data/app/assets/stylesheets/katalyst/content/_index.scss +31 -0
  14. data/app/assets/stylesheets/katalyst/content/editor/_icon.scss +17 -0
  15. data/app/assets/stylesheets/katalyst/content/editor/_index.scss +145 -0
  16. data/app/assets/stylesheets/katalyst/content/editor/_item-actions.scss +93 -0
  17. data/app/assets/stylesheets/katalyst/content/editor/_item-rules.scss +19 -0
  18. data/app/assets/stylesheets/katalyst/content/editor/_new-items.scss +39 -0
  19. data/app/assets/stylesheets/katalyst/content/editor/_status-bar.scss +87 -0
  20. data/app/controllers/katalyst/content/application_controller.rb +8 -0
  21. data/app/controllers/katalyst/content/items_controller.rb +70 -0
  22. data/app/helpers/katalyst/content/application_helper.rb +8 -0
  23. data/app/helpers/katalyst/content/editor/base.rb +44 -0
  24. data/app/helpers/katalyst/content/editor/container.rb +41 -0
  25. data/app/helpers/katalyst/content/editor/item.rb +67 -0
  26. data/app/helpers/katalyst/content/editor/list.rb +41 -0
  27. data/app/helpers/katalyst/content/editor/new_item.rb +53 -0
  28. data/app/helpers/katalyst/content/editor/status_bar.rb +57 -0
  29. data/app/helpers/katalyst/content/editor_helper.rb +42 -0
  30. data/app/models/concerns/katalyst/content/container.rb +100 -0
  31. data/app/models/concerns/katalyst/content/garbage_collection.rb +31 -0
  32. data/app/models/concerns/katalyst/content/has_tree.rb +63 -0
  33. data/app/models/concerns/katalyst/content/version.rb +33 -0
  34. data/app/models/katalyst/content/content.rb +21 -0
  35. data/app/models/katalyst/content/item.rb +36 -0
  36. data/app/models/katalyst/content/node.rb +21 -0
  37. data/app/models/katalyst/content/types/nodes_type.rb +42 -0
  38. data/app/views/active_storage/blobs/_blob.html.erb +14 -0
  39. data/app/views/katalyst/content/contents/_content.html+form.erb +39 -0
  40. data/app/views/katalyst/content/contents/_content.html.erb +5 -0
  41. data/app/views/katalyst/content/editor/_item.html.erb +11 -0
  42. data/app/views/katalyst/content/editor/_list_item.html.erb +14 -0
  43. data/app/views/katalyst/content/editor/_new_item.html.erb +3 -0
  44. data/app/views/katalyst/content/editor/_new_items.html.erb +5 -0
  45. data/app/views/katalyst/content/items/_item.html+form.erb +34 -0
  46. data/app/views/katalyst/content/items/_item.html.erb +3 -0
  47. data/app/views/katalyst/content/items/edit.html.erb +4 -0
  48. data/app/views/katalyst/content/items/new.html.erb +4 -0
  49. data/app/views/katalyst/content/items/update.turbo_stream.erb +7 -0
  50. data/app/views/layouts/action_text/contents/_content.html.erb +3 -0
  51. data/config/importmap.rb +8 -0
  52. data/config/locales/en.yml +12 -0
  53. data/config/routes.rb +3 -0
  54. data/db/migrate/20220913003839_create_katalyst_content_items.rb +17 -0
  55. data/lib/katalyst/content/config.rb +18 -0
  56. data/lib/katalyst/content/engine.rb +36 -0
  57. data/lib/katalyst/content/version.rb +7 -0
  58. data/lib/katalyst/content.rb +19 -0
  59. data/lib/tasks/yarn.rake +18 -0
  60. data/spec/factories/katalyst/content/items.rb +16 -0
  61. metadata +103 -0
@@ -0,0 +1,93 @@
1
+ @use "icon" as *;
2
+
3
+ [data-controller="content--editor--item"] {
4
+ [data-tree-accordion-controls] {
5
+ min-width: 2.5rem;
6
+ min-height: 2.5rem;
7
+ display: grid;
8
+ }
9
+
10
+ [data-tree-controls] {
11
+ display: flex;
12
+ align-items: center;
13
+ }
14
+
15
+ [data-tree-accordion-controls],
16
+ [data-tree-controls] {
17
+ [role="button"] {
18
+ @extend %icon-block;
19
+ &::before {
20
+ @extend %icon;
21
+ }
22
+ }
23
+ }
24
+
25
+ [role="img"][value="link"],
26
+ [role="img"][value="title"] {
27
+ width: 1.5rem;
28
+ height: 1.5rem;
29
+ display: grid;
30
+ place-items: center;
31
+ border-radius: 2px;
32
+ background: var(--icon-active-color);
33
+ margin-right: 0.5rem;
34
+ flex-shrink: 0;
35
+
36
+ [data-invisible="true"] & {
37
+ background: var(--icon-passive-color);
38
+ }
39
+
40
+ &::before {
41
+ @extend %icon;
42
+ color: white;
43
+ font-size: 1.125rem;
44
+ line-height: 1.125rem;
45
+ text-align: center;
46
+ }
47
+
48
+ &[value="link"] {
49
+ &::before {
50
+ content: "#";
51
+ }
52
+ }
53
+
54
+ &[value="title"] {
55
+ &::before {
56
+ content: "T";
57
+ }
58
+ }
59
+ }
60
+
61
+ [role="img"][value="invisible"] {
62
+ @extend %icon-block;
63
+
64
+ &::before {
65
+ @extend %icon;
66
+ background-image: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg clip-path='url(%23clip0_58_2189)'%3E%3Cpath d='M22.1699 0.329991C21.7304 -0.109509 21.0179 -0.109509 20.5784 0.329991L15.8399 5.06849C14.6219 4.69949 13.3334 4.50149 11.9984 4.50149C6.76494 4.50149 2.22744 7.54949 -0.00156052 12.0015C0.962939 13.926 2.35794 15.588 4.05294 16.8555L0.326939 20.5815C-0.112561 21.021 -0.112561 21.7335 0.326939 22.173C0.545939 22.392 0.833939 22.503 1.12194 22.503C1.40994 22.503 1.69794 22.3935 1.91694 22.173L22.1669 1.92299C22.6064 1.48349 22.6064 0.770991 22.1669 0.331491L22.1699 0.329991ZM9.74994 7.49999C10.7399 7.49999 11.5799 8.13899 11.8814 9.02849L9.02844 11.8815C8.14044 11.58 7.49994 10.74 7.49994 9.74999C7.49994 8.50799 8.50794 7.49999 9.74994 7.49999ZM2.58144 12C3.47844 10.581 4.67394 9.37649 6.08394 8.47799C6.17544 8.41949 6.26844 8.36249 6.36144 8.30699C6.12744 8.94749 5.99994 9.63899 5.99994 10.3605C5.99994 11.6475 6.40494 12.8385 7.09494 13.815L5.66694 15.243C4.43844 14.379 3.38844 13.2765 2.58144 12V12Z' fill='%2358607A'/%3E%3Cpath d='M18.0001 10.3589C18.0001 9.72295 17.9011 9.10945 17.7166 8.53345L10.1746 16.0754C10.7506 16.2599 11.3641 16.3589 12.0001 16.3589C15.3136 16.3589 18.0001 13.6724 18.0001 10.3589V10.3589Z' fill='%2358607A'/%3E%3Cpath d='M19.4536 6.79651L17.8276 8.42251C17.8576 8.44051 17.8876 8.45851 17.9161 8.47801C19.3261 9.37801 20.5216 10.5825 21.4186 12C20.5216 13.419 19.3261 14.6235 17.9161 15.522C16.1446 16.6515 14.0986 17.25 12.0001 17.25C11.0941 17.25 10.1971 17.139 9.3286 16.9215L7.5271 18.723C8.9266 19.2255 10.4326 19.5 12.0001 19.5C17.2336 19.5 21.7711 16.452 24.0001 12C22.9456 9.89251 21.3721 8.10001 19.4536 6.79651V6.79651Z' fill='%2358607A'/%3E%3C/g%3E%3Cdefs%3E%3CclipPath id='clip0_58_2189'%3E%3Crect width='24' height='24' fill='white'/%3E%3C/clipPath%3E%3C/defs%3E%3C/svg%3E");
67
+ }
68
+ }
69
+
70
+ [role="button"][value="collapse"]::before {
71
+ background-image: url("data:image/svg+xml,%3Csvg width='20' height='20' viewBox='0 0 20 20' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg clip-path='url(%23clip0_50_1454)'%3E%3Cpath d='M15 8.33333L13.825 7.15833L10 10.975L6.175 7.15833L5 8.33333L10 13.3333L15 8.33333Z' fill='%2358607A'/%3E%3C/g%3E%3Cdefs%3E%3CclipPath id='clip0_50_1454'%3E%3Crect width='20' height='20' fill='white' transform='translate(20) rotate(90)'/%3E%3C/clipPath%3E%3C/defs%3E%3C/svg%3E");
72
+ }
73
+
74
+ [role="button"][value="expand"]::before {
75
+ background-image: url("data:image/svg+xml,%3Csvg width='20' height='20' viewBox='0 0 20 20' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg clip-path='url(%23clip0_50_1454)'%3E%3Cpath d='M8.3332 5L7.1582 6.175L10.9749 10L7.1582 13.825L8.3332 15L13.3332 10L8.3332 5Z' fill='%2358607A'/%3E%3C/g%3E%3Cdefs%3E%3CclipPath id='clip0_50_1454'%3E%3Crect width='20' height='20' fill='white'/%3E%3C/clipPath%3E%3C/defs%3E%3C/svg%3E");
76
+ }
77
+
78
+ [role="button"][value="de-nest"]::before {
79
+ background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 20 20' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg clip-path='url(%23clip0_50_1454)'%3E%3Cpath d='M11.6668 15L12.8418 13.825L9.02513 10L12.8418 6.175L11.6668 5L6.6668 10L11.6668 15Z' fill='%2358607A'/%3E%3C/g%3E%3Cdefs%3E%3CclipPath id='clip0_50_1454'%3E%3Crect width='20' height='20' fill='white' transform='translate(20 20) rotate(180)'/%3E%3C/clipPath%3E%3C/defs%3E%3C/svg%3E");
80
+ }
81
+
82
+ [role="button"][value="nest"]::before {
83
+ background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 20 20' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg clip-path='url(%23clip0_50_1454)'%3E%3Cpath d='M8.3332 5L7.1582 6.175L10.9749 10L7.1582 13.825L8.3332 15L13.3332 10L8.3332 5Z' fill='%2358607A'/%3E%3C/g%3E%3Cdefs%3E%3CclipPath id='clip0_50_1454'%3E%3Crect width='20' height='20' fill='white'/%3E%3C/clipPath%3E%3C/defs%3E%3C/svg%3E");
84
+ }
85
+
86
+ [role="button"][value="edit"]::before {
87
+ background-image: url("data:image/svg+xml,%3Csvg width='18' height='18' viewBox='0 0 18 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg clip-path='url(%23clip0_50_1406)'%3E%3Cpath d='M2.25 12.9375V15.75H5.0625L13.3575 7.45504L10.545 4.64254L2.25 12.9375ZM15.5325 5.28004C15.825 4.98754 15.825 4.51504 15.5325 4.22254L13.7775 2.46754C13.485 2.17504 13.0125 2.17504 12.72 2.46754L11.3475 3.84004L14.16 6.65254L15.5325 5.28004V5.28004Z' fill='%2358607A'/%3E%3C/g%3E%3Cdefs%3E%3CclipPath id='clip0_50_1406'%3E%3Crect width='18' height='18' fill='white'/%3E%3C/clipPath%3E%3C/defs%3E%3C/svg%3E");
88
+ }
89
+
90
+ [role="button"][value="remove"]::before {
91
+ background-image: url("data:image/svg+xml,%3Csvg width='18' height='18' viewBox='0 0 18 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg clip-path='url(%23clip0_50_1409)'%3E%3Cpath d='M4.5 14.25C4.5 15.075 5.175 15.75 6 15.75H12C12.825 15.75 13.5 15.075 13.5 14.25V5.25H4.5V14.25ZM14.25 3H11.625L10.875 2.25H7.125L6.375 3H3.75V4.5H14.25V3Z' fill='%2358607A'/%3E%3C/g%3E%3Cdefs%3E%3CclipPath id='clip0_50_1409'%3E%3Crect width='18' height='18' fill='white'/%3E%3C/clipPath%3E%3C/defs%3E%3C/svg%3E");
92
+ }
93
+ }
@@ -0,0 +1,19 @@
1
+ [data-controller="content--editor--list"] {
2
+ // Lower opacity for buttons you can't use
3
+ [data-deny-de-nest] [role="button"][value="de-nest"],
4
+ [data-deny-nest] [role="button"][value="nest"],
5
+ [data-deny-remove] [role="button"][value="remove"],
6
+ [data-deny-drag] [role="button"][value="drag"],
7
+ [data-deny-edit] [role="button"][value="edit"] {
8
+ opacity: 0.2;
9
+ pointer-events: none;
10
+ }
11
+
12
+ // Only show 1 of the collapse / expand button
13
+ [data-deny-collapse] [role="button"][value="collapse"],
14
+ [data-deny-expand] [role="button"][value="expand"],
15
+ [data-invisible="false"] [role="img"][value="invisible"] {
16
+ display: none !important;
17
+ pointer-events: none;
18
+ }
19
+ }
@@ -0,0 +1,39 @@
1
+ @use "icon" as *;
2
+
3
+ .content--editor--new-items {
4
+ display: grid;
5
+ grid-template-columns: repeat(3, 1fr);
6
+ gap: 0.5rem;
7
+
8
+ [role="listitem"] {
9
+ display: flex;
10
+ flex-direction: column;
11
+ justify-content: center;
12
+ align-items: center;
13
+ transform: translate3d(0, 0, 0);
14
+ cursor: grab;
15
+ background: white;
16
+ box-shadow: rgb(0 0 0 / 25%) 0 1px 1px, rgb(0 0 0 / 31%) 0 0 2px;
17
+ min-height: 5rem;
18
+ padding: 1.5rem;
19
+
20
+ &:hover {
21
+ box-shadow: rgb(0 0 0 / 25%) 0 1px 2px, rgb(0 0 0 / 31%) 0 0 5px;
22
+ }
23
+
24
+ &::before {
25
+ @extend %icon;
26
+ width: 2.5rem;
27
+ height: 2.5rem;
28
+ position: unset;
29
+ }
30
+
31
+ &[data-item-type="link"]:before {
32
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 48 48' xmlns='http://www.w3.org/2000/svg'%3E%3Cg clip-path='url(%23clip0_67_1273)'%3E%3Cpath d='M16 22H32V26H16V22ZM40.2 24H44C44 18.48 39.52 14 34 14H26V17.8H34C37.42 17.8 40.2 20.58 40.2 24ZM7.8 24C7.8 20.58 10.58 17.8 14 17.8H22V14H14C8.48 14 4 18.48 4 24C4 29.52 8.48 34 14 34H22V30.2H14C10.58 30.2 7.8 27.42 7.8 24ZM38 24H34V30H28V34H34V40H38V34H44V30H38V24Z' /%3E%3C/g%3E%3Cdefs%3E%3CclipPath id='clip0_67_1273'%3E%3Crect width='48' height='48' /%3E%3C/clipPath%3E%3C/defs%3E%3C/svg%3E");
33
+ }
34
+
35
+ &[data-item-type="button"]:before {
36
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 48 48' xmlns='http://www.w3.org/2000/svg'%3E%3Cg clip-path='url(%23clip0_67_1276)'%3E%3Cpath d='M44 18V30C44 32.2 42.2 34 40 34H38V30H40V18H8V30H20V34H8C5.8 34 4 32.2 4 30V18C4 15.8 5.8 14 8 14H40C42.2 14 44 15.8 44 18ZM29 38L31.18 33.18L36 31L31.18 28.82L29 24L26.82 28.82L22 31L26.82 33.18L29 38ZM34 28L35.24 25.24L38 24L35.24 22.76L34 20L32.76 22.76L30 24L32.76 25.24L34 28ZM29 38L31.18 33.18L36 31L31.18 28.82L29 24L26.82 28.82L22 31L26.82 33.18L29 38ZM34 28L35.24 25.24L38 24L35.24 22.76L34 20L32.76 22.76L30 24L32.76 25.24L34 28Z' /%3E%3C/g%3E%3Cdefs%3E%3CclipPath id='clip0_67_1276'%3E%3Crect width='48' height='48' /%3E%3C/clipPath%3E%3C/defs%3E%3C/svg%3E");
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,87 @@
1
+ [data-controller="content--editor--status-bar"] {
2
+ min-height: 3rem;
3
+ line-height: 3rem;
4
+ padding: 0 1.25rem 0 1.5rem;
5
+ background: var(--background);
6
+ color: var(--color);
7
+ border: 1px solid var(--border);
8
+ margin-bottom: 1rem;
9
+
10
+ display: grid;
11
+ grid-template-columns: 1fr auto;
12
+ grid-template-areas: "status actions";
13
+ align-items: baseline;
14
+ grid-column-gap: 2rem;
15
+
16
+ .status-text {
17
+ display: none;
18
+ grid-area: status;
19
+ font-weight: bold;
20
+ }
21
+
22
+ &[data-state="published"] .status-text[data-published],
23
+ &[data-state="draft"] .status-text[data-draft],
24
+ &[data-state="dirty"] .status-text[data-dirty] {
25
+ display: unset;
26
+ }
27
+
28
+ menu {
29
+ display: inline;
30
+ grid-area: actions;
31
+ margin: 0;
32
+ padding: 0;
33
+ }
34
+
35
+ menu > li {
36
+ display: inline;
37
+ }
38
+
39
+ .button {
40
+ color: inherit;
41
+ line-height: 1rem;
42
+ margin-left: 0.5rem;
43
+ }
44
+
45
+ .button--primary {
46
+ background: var(--color);
47
+ border: 1px solid var(--border);
48
+ color: white;
49
+
50
+ &[disabled] {
51
+ background: var(--color);
52
+ opacity: 0.8;
53
+ }
54
+ }
55
+
56
+ .button--secondary {
57
+ background: var(--background);
58
+ border: 1px solid var(--border);
59
+ color: var(--color);
60
+
61
+ &[disabled] {
62
+ background: var(--background);
63
+ }
64
+ }
65
+
66
+ &[data-state="published"] {
67
+ [value="publish"],
68
+ [value="save"],
69
+ [value="revert"],
70
+ [value="discard"] {
71
+ display: none;
72
+ }
73
+ }
74
+
75
+ &[data-state="draft"] {
76
+ [value="save"],
77
+ [value="discard"] {
78
+ display: none;
79
+ }
80
+ }
81
+
82
+ &[data-state="dirty"] {
83
+ [value="revert"] {
84
+ display: none;
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Content
5
+ class ApplicationController < ActionController::Base
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Content
5
+ class ItemsController < ApplicationController
6
+ before_action :set_container, only: %i[new create]
7
+ before_action :set_item, except: %i[new create]
8
+ before_action :set_editor_variant
9
+
10
+ helper EditorHelper
11
+
12
+ def new
13
+ render locals: { item: @container.items.build(item_params) }
14
+ end
15
+
16
+ def create
17
+ item = @container.items.build(item_params)
18
+ if item.save
19
+ render :update, locals: { item: item, previous: @container.items.build(type: item.type) }
20
+ else
21
+ render :new, status: :unprocessable_entity, locals: { item: item }
22
+ end
23
+ end
24
+
25
+ def edit
26
+ render locals: { item: @item }
27
+ end
28
+
29
+ def update
30
+ @item.attributes = item_params
31
+
32
+ if @item.valid?
33
+ previous = @item
34
+ @item = @item.dup.tap(&:save!)
35
+ render locals: { item: @item, previous: previous }
36
+ else
37
+ render :edit, status: :unprocessable_entity, locals: { item: @item }
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def item_params_type
44
+ type = params.require(:item).fetch(:type, "")
45
+ if Katalyst::Content.config.items.include?(type)
46
+ type.safe_constantize
47
+ else
48
+ Item
49
+ end
50
+ end
51
+
52
+ def item_params
53
+ params.require(:item).permit(item_params_type.permitted_params)
54
+ end
55
+
56
+ def set_container
57
+ @container = Item.new(item_params).container
58
+ raise ActiveRecord::RecordNotFound unless @container
59
+ end
60
+
61
+ def set_item
62
+ @item = Item.find(params[:id])
63
+ end
64
+
65
+ def set_editor_variant
66
+ request.variant << :form
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Content
5
+ module ApplicationHelper
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Content
5
+ module Editor
6
+ class Base
7
+ CONTAINER_CONTROLLER = "content--editor--container"
8
+ LIST_CONTROLLER = "content--editor--list"
9
+ ITEM_CONTROLLER = "content--editor--item"
10
+ STATUS_BAR_CONTROLLER = "content--editor--status-bar"
11
+ NEW_ITEM_CONTROLLER = "content--editor--new-item"
12
+
13
+ TURBO_FRAME = "content--editor--item-frame"
14
+
15
+ attr_accessor :template, :container
16
+
17
+ delegate_missing_to :template
18
+
19
+ def initialize(template, container)
20
+ self.template = template
21
+ self.container = container
22
+ end
23
+
24
+ def container_form_id
25
+ dom_id(container, :items)
26
+ end
27
+
28
+ def attributes_scope
29
+ "#{container.model_name.param_key}[items_attributes][]"
30
+ end
31
+
32
+ private
33
+
34
+ def add_option(options, key, *path)
35
+ if path.length > 1
36
+ add_option(options[key] ||= {}, *path)
37
+ else
38
+ options[key] = [options[key], *path].compact.join(" ")
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Content
5
+ module Editor
6
+ class Container < Base
7
+ ACTIONS = <<~ACTIONS.gsub(/\s+/, " ").freeze
8
+ submit->#{CONTAINER_CONTROLLER}#reindex
9
+ content:reindex->#{CONTAINER_CONTROLLER}#reindex
10
+ content:reset->#{CONTAINER_CONTROLLER}#reset
11
+ ACTIONS
12
+
13
+ def build(options)
14
+ form_with(model: container, **default_options(id: container_form_id, **options)) do |form|
15
+ concat hidden_input
16
+ concat(capture { yield form })
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ # Hidden input ensures that if the container is empty then the controller
23
+ # receives an empty array.
24
+ def hidden_input
25
+ tag.input(type: "hidden", name: "#{attributes_scope}[id]")
26
+ end
27
+
28
+ def default_options(options)
29
+ add_option(options, :data, :controller, CONTAINER_CONTROLLER)
30
+ add_option(options, :data, :action, ACTIONS)
31
+
32
+ # depth = options.delete(:depth) || container.depth
33
+ #
34
+ # add_option(options, :data, :"#{CONTAINER_CONTROLLER}-max-depth-value", depth) if depth
35
+
36
+ options
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Content
5
+ module Editor
6
+ class Item < Base
7
+ attr_accessor :item
8
+
9
+ def build(item, **options, &block)
10
+ self.item = item
11
+ tag.div **default_options(id: dom_id(item), **options) do
12
+ concat(capture { yield self }) if block
13
+ concat fields(item)
14
+ end
15
+ end
16
+
17
+ def accordion_actions
18
+ tag.div role: "toolbar", data: { tree_accordion_controls: "" } do
19
+ concat tag.span(role: "button", value: "collapse",
20
+ data: { action: "click->#{CONTAINER_CONTROLLER}#collapse", title: "Collapse tree" })
21
+ concat tag.span(role: "button", value: "expand",
22
+ data: { action: "click->#{CONTAINER_CONTROLLER}#expand", title: "Expand tree" })
23
+ end
24
+ end
25
+
26
+ def item_actions
27
+ tag.div role: "toolbar", data: { tree_controls: "" } do
28
+ concat tag.span(role: "button", value: "de-nest",
29
+ data: { action: "click->#{CONTAINER_CONTROLLER}#deNest", title: "Outdent" })
30
+ concat tag.span(role: "button", value: "nest",
31
+ data: { action: "click->#{CONTAINER_CONTROLLER}#nest", title: "Indent" })
32
+ concat link_to("", edit_item_link,
33
+ role: "button", title: "Edit", value: "edit",
34
+ data: { turbo_frame: TURBO_FRAME })
35
+ concat tag.span(role: "button", value: "remove",
36
+ data: { action: "click->#{CONTAINER_CONTROLLER}#remove", title: "Remove" })
37
+ end
38
+ end
39
+
40
+ def edit_item_link
41
+ if item.persisted?
42
+ katalyst_content.edit_item_path(item)
43
+ else
44
+ katalyst_content.new_item_path(item: item.attributes.slice("type", "container_id",
45
+ "container_type").compact)
46
+ end
47
+ end
48
+
49
+ def fields(item)
50
+ template.fields(attributes_scope, model: item, index: nil, skip_default_ids: true) do |f|
51
+ concat f.hidden_field(:id)
52
+ concat f.hidden_field(:depth)
53
+ concat f.hidden_field(:index)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def default_options(options)
60
+ add_option(options, :data, :controller, ITEM_CONTROLLER)
61
+
62
+ options
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Content
5
+ module Editor
6
+ class List < Base
7
+ ACTIONS = <<~ACTIONS.gsub(/\s+/, " ").freeze
8
+ dragstart->#{LIST_CONTROLLER}#dragstart
9
+ dragover->#{LIST_CONTROLLER}#dragover
10
+ dragenter->#{LIST_CONTROLLER}#dragenter
11
+ dragleave->#{LIST_CONTROLLER}#dragleave
12
+ drop->#{LIST_CONTROLLER}#drop
13
+ dragend->#{LIST_CONTROLLER}#dragend
14
+ ACTIONS
15
+
16
+ def build(options, &_block)
17
+ tag.ol **default_options(id: container_form_id, **options) do
18
+ yield self
19
+ end
20
+ end
21
+
22
+ def items(*items)
23
+ render partial: "katalyst/content/editor/item",
24
+ layout: "katalyst/content/editor/list_item",
25
+ collection: items,
26
+ as: :item
27
+ end
28
+
29
+ private
30
+
31
+ def default_options(options)
32
+ add_option(options, :data, :controller, LIST_CONTROLLER)
33
+ add_option(options, :data, :action, ACTIONS)
34
+ add_option(options, :data, :"#{CONTAINER_CONTROLLER}_target", "container")
35
+
36
+ options
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Content
5
+ module Editor
6
+ class NewItem < Base
7
+ ACTIONS = <<~ACTIONS.gsub(/\s+/, " ").freeze
8
+ dragstart->#{NEW_ITEM_CONTROLLER}#dragstart
9
+ ACTIONS
10
+
11
+ def build(item, **options, &block)
12
+ capture do
13
+ concat(tag.div(**default_options(options)) do
14
+ concat capture(&block)
15
+ concat item_template(item)
16
+ end)
17
+ concat turbo_replace_placeholder(item)
18
+ end
19
+ end
20
+
21
+ # Remove items that are incomplete when rendering new items, this
22
+ # causes incomplete items to be removed from the list when the user
23
+ # cancels adding a new item by pressing 'discard' in the new item form.
24
+ def turbo_replace_placeholder(item)
25
+ turbo_stream.replace dom_id(item) do
26
+ content_editor_item(item: item, data: { delete: "" })
27
+ end
28
+ end
29
+
30
+ # Template is stored inside the new item dom, and copied into drag
31
+ # events when the user initiates drag so that it can be copied into the
32
+ # editor list on drop.
33
+ def item_template(item)
34
+ tag.template data: { "#{NEW_ITEM_CONTROLLER}-target" => "template" } do
35
+ content_editor_items(item: item)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def default_options(options)
42
+ add_option(options, :draggable, true)
43
+ add_option(options, :role, "listitem")
44
+ add_option(options, :data, :turbo_frame, TURBO_FRAME)
45
+ add_option(options, :data, :controller, NEW_ITEM_CONTROLLER)
46
+ add_option(options, :data, :action, ACTIONS)
47
+
48
+ options
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Content
5
+ module Editor
6
+ class StatusBar < Base
7
+ ACTIONS = <<~ACTIONS.gsub(/\s+/, " ").freeze
8
+ content:change@document->#{STATUS_BAR_CONTROLLER}#change
9
+ ACTIONS
10
+
11
+ def build(**options)
12
+ tag.div **default_options(**options) do
13
+ concat status(:published, last_update: l(container.updated_at, format: :short))
14
+ concat status(:draft)
15
+ concat status(:dirty)
16
+ concat actions
17
+ end
18
+ end
19
+
20
+ def status(state, **options)
21
+ tag.span(t("views.katalyst.content.editor.#{state}_html", **options),
22
+ class: "status-text",
23
+ data: { state => "" })
24
+ end
25
+
26
+ def actions
27
+ tag.menu do
28
+ concat action(:discard, class: "button button--text")
29
+ concat action(:revert, class: "button button--text") if container.state == :draft
30
+ concat action(:save, class: "button button--secondary")
31
+ concat action(:publish, class: "button button--primary")
32
+ end
33
+ end
34
+
35
+ def action(action, **options)
36
+ tag.li do
37
+ button_tag(t("views.katalyst.content.editor.#{action}"),
38
+ name: "commit",
39
+ value: action,
40
+ form: container_form_id,
41
+ **options)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def default_options(**options)
48
+ add_option(options, :data, :controller, STATUS_BAR_CONTROLLER)
49
+ add_option(options, :data, :action, ACTIONS)
50
+ add_option(options, :data, :state, container.state)
51
+
52
+ options
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Content
5
+ module EditorHelper
6
+ def content_editor_new_items(container:)
7
+ Katalyst::Content.config.items.map do |item_class|
8
+ item_class = item_class.is_a?(String) ? item_class.safe_constantize : item_class
9
+ item_class.new(container: container)
10
+ end
11
+ end
12
+
13
+ def content_editor_container(container:, **options, &block)
14
+ Editor::Container.new(self, container).build(options, &block)
15
+ end
16
+
17
+ def content_editor_list(container:, items: container.draft_items, **options)
18
+ Editor::List.new(self, container).build(options) do |list|
19
+ list.items(*items) if items.present?
20
+ end
21
+ end
22
+
23
+ # Generate items without their list wrapper, similar to form_with/fields
24
+ def content_editor_items(item:, container: item.container)
25
+ Editor::List.new(self, container).items(item)
26
+ end
27
+
28
+ # Gene
29
+ def content_editor_new_item(item:, container: item.container, **options, &block)
30
+ Editor::NewItem.new(self, container).build(item, **options, &block)
31
+ end
32
+
33
+ def content_editor_item(item:, container: item.container, **options, &block)
34
+ Editor::Item.new(self, container).build(item, **options, &block)
35
+ end
36
+
37
+ def content_editor_status_bar(container:, **options)
38
+ Editor::StatusBar.new(self, container).build(**options)
39
+ end
40
+ end
41
+ end
42
+ end