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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: dabae0594160eb1ebafdd3501b5d521113fdcc8bde6f36eede4296314297255d
4
+ data.tar.gz: aace7d35cd049a4c3fcf140f21099d5d04d03020a1935402e8999fa82c368823
5
+ SHA512:
6
+ metadata.gz: 452af88132002902aef2edfa6858e772a4985ea069cc6b1726c4acd9f3f2594fb84bf2e35a3377d233c5cfe1411caa5c7fc86c795b52d6c648d3a9c80bf616fe
7
+ data.tar.gz: 1077d2f70a588105436e553b186f4cd3239a250ef36c2f10e15827973b8ea8375ca62b9b5c227d432f8642ef63a8ccdde832a6c89c482df6b99c79749811e78f
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2022 Katalyst Interactive
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # Katalyst::Content
2
+
3
+ Katalyst Content provides tools for creating and publishing content on Rails
4
+ applications.
5
+
6
+ ## Installation
7
+
8
+ Add this line to your application's Gemfile:
9
+
10
+ ```ruby
11
+ gem 'katalyst-content'
12
+ ```
13
+
14
+ And then execute:
15
+
16
+ $ bundle install
17
+
18
+ Or install it yourself as:
19
+
20
+ $ gem install katalyst-content
21
+
22
+ ## Usage
23
+
24
+ Content can be added to multiple models in your application. These examples
25
+ assume a `Page` model.
26
+
27
+ Assuming your model already exists, create a table for versions and add
28
+ published and draft version columns to your model. For example, if you have a
29
+ pages model:
30
+
31
+ ```ruby
32
+ class CreatePageVersions < ActiveRecord::Migration[7.0]
33
+ def change
34
+ create_table :page_versions do |t|
35
+ t.references :parent, foreign_key: { to_table: :pages }, null: false
36
+ t.json :nodes
37
+
38
+ t.timestamps
39
+ end
40
+
41
+ change_table :pages do |t|
42
+ t.references :published_version, foreign_key: { to_table: :page_versions }
43
+ t.references :draft_version, foreign_key: { to_table: :page_versions }
44
+ end
45
+ end
46
+ end
47
+ ```
48
+
49
+ Next, include the `Katalyst::Content` concerns into your model, and add a nested
50
+ model for storing content version information:
51
+
52
+ ```ruby
53
+ class Page < ApplicationRecord
54
+ include Katalyst::Content::Container
55
+
56
+ class Version < ApplicationRecord
57
+ include Katalyst::Content::Version
58
+ end
59
+ end
60
+ ```
61
+
62
+ You may also want to configure your factory to add container information to
63
+ items:
64
+
65
+ ```ruby
66
+ FactoryBot.define do
67
+ factory :page do
68
+ title { Faker::Beer.unique.name }
69
+ slug { title.parameterize }
70
+
71
+ after(:build) do |page, _context|
72
+ page.items.each { |item| item.container = page }
73
+ end
74
+
75
+ after(:create) do |page, _context|
76
+ page.items_attributes = page.items.map.with_index { |item, index| { id: item.id, index: index, depth: 0 } }
77
+ page.publish!
78
+ end
79
+ end
80
+ end
81
+ ```
82
+
83
+ ## Development
84
+
85
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake` to run the tests.
86
+
87
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
88
+
89
+ ## Contributing
90
+
91
+ Bug reports and pull requests are welcome on GitHub at https://github.com/katalyst/content.
92
+
93
+ ## License
94
+
95
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1 @@
1
+ //= link_tree ../javascripts
@@ -0,0 +1,113 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ import Item from "utils/content/editor/item";
4
+ import Container from "utils/content/editor/container";
5
+ import RulesEngine from "utils/content/editor/rules-engine";
6
+
7
+ export default class ContainerController extends Controller {
8
+ static targets = ["container"];
9
+ static values = {
10
+ maxDepth: Number,
11
+ };
12
+
13
+ connect() {
14
+ this.state = this.container.state;
15
+
16
+ this.reindex();
17
+ }
18
+
19
+ get container() {
20
+ return new Container(this.containerTarget);
21
+ }
22
+
23
+ reindex() {
24
+ this.container.reindex();
25
+ this.#update();
26
+ }
27
+
28
+ reset() {
29
+ this.container.reset();
30
+ }
31
+
32
+ remove(event) {
33
+ const item = getEventItem(event);
34
+
35
+ item.node.remove();
36
+
37
+ this.#update();
38
+ event.preventDefault();
39
+ }
40
+
41
+ nest(event) {
42
+ const item = getEventItem(event);
43
+
44
+ item.traverse((child) => {
45
+ child.depth += 1;
46
+ });
47
+
48
+ this.#update();
49
+ event.preventDefault();
50
+ }
51
+
52
+ deNest(event) {
53
+ const item = getEventItem(event);
54
+
55
+ item.traverse((child) => {
56
+ child.depth -= 1;
57
+ });
58
+
59
+ this.#update();
60
+ event.preventDefault();
61
+ }
62
+
63
+ collapse(event) {
64
+ const item = getEventItem(event);
65
+
66
+ item.collapse();
67
+
68
+ this.#update();
69
+ event.preventDefault();
70
+ }
71
+
72
+ expand(event) {
73
+ const item = getEventItem(event);
74
+
75
+ item.expand();
76
+
77
+ this.#update();
78
+ event.preventDefault();
79
+ }
80
+
81
+ /**
82
+ * Re-apply rules to items to enable/disable appropriate actions.
83
+ */
84
+ #update() {
85
+ // debounce requests to ensure that we only update once per tick
86
+ this.updateRequested = true;
87
+ setTimeout(() => {
88
+ if (!this.updateRequested) return;
89
+
90
+ this.updateRequested = false;
91
+ const engine = new RulesEngine(this.maxDepthValue);
92
+ this.container.items.forEach((item) => engine.update(item));
93
+
94
+ this.#notifyChange();
95
+ }, 0);
96
+ }
97
+
98
+ #notifyChange() {
99
+ this.dispatch("change", {
100
+ bubbles: true,
101
+ prefix: "content",
102
+ detail: { dirty: this.#isDirty() },
103
+ });
104
+ }
105
+
106
+ #isDirty() {
107
+ return this.container.state !== this.state;
108
+ }
109
+ }
110
+
111
+ function getEventItem(event) {
112
+ return new Item(event.target.closest("[data-content-item]"));
113
+ }
@@ -0,0 +1,45 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import Item from "utils/content/editor/item";
3
+
4
+ export default class ItemController extends Controller {
5
+ get item() {
6
+ return new Item(this.li);
7
+ }
8
+
9
+ get ol() {
10
+ return this.element.closest("ol");
11
+ }
12
+
13
+ get li() {
14
+ return this.element.closest("li");
15
+ }
16
+
17
+ connect() {
18
+ if (this.element.dataset.hasOwnProperty("delete")) {
19
+ this.remove();
20
+ }
21
+ // if index is not already set, re-index will set it
22
+ else if (!(this.item.index >= 0)) {
23
+ this.reindex();
24
+ }
25
+ // if item has been replaced via turbo, re-index will run the rules engine
26
+ // update our depth and index with values from the li's data attributes
27
+ else if (this.item.hasItemIdChanged()) {
28
+ this.item.updateAfterChange();
29
+ this.reindex();
30
+ }
31
+ }
32
+
33
+ remove() {
34
+ // capture ol
35
+ const ol = this.ol;
36
+ // remove self from dom
37
+ this.li.remove();
38
+ // reindex ol
39
+ this.reindex();
40
+ }
41
+
42
+ reindex() {
43
+ this.dispatch("reindex", { bubbles: true, prefix: "content" });
44
+ }
45
+ }
@@ -0,0 +1,105 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class ListController extends Controller {
4
+ dragstart(event) {
5
+ if (this.element !== event.target.parentElement) return;
6
+
7
+ const target = event.target;
8
+ event.dataTransfer.effectAllowed = "move";
9
+
10
+ // update element style after drag has begun
11
+ setTimeout(() => (target.dataset.dragging = ""));
12
+ }
13
+
14
+ dragover(event) {
15
+ const item = this.dragItem();
16
+ if (!item) return;
17
+
18
+ swap(this.dropTarget(event.target), item);
19
+
20
+ event.preventDefault();
21
+ return true;
22
+ }
23
+
24
+ dragenter(event) {
25
+ event.preventDefault();
26
+
27
+ if (event.dataTransfer.effectAllowed === "copy" && !this.dragItem()) {
28
+ const item = document.createElement("li");
29
+ item.dataset.dragging = "";
30
+ item.dataset.newItem = "";
31
+ this.element.prepend(item);
32
+ }
33
+ }
34
+
35
+ dragleave(event) {
36
+ const item = this.dragItem();
37
+ const related = this.dropTarget(event.relatedTarget);
38
+
39
+ // ignore if item is not set or we're moving into a valid drop target
40
+ if (!item || related) return;
41
+
42
+ // remove item if it's a new item
43
+ if (item.dataset.hasOwnProperty("newItem")) {
44
+ item.remove();
45
+ }
46
+ }
47
+
48
+ drop(event) {
49
+ const item = this.dragItem();
50
+
51
+ if (!item) return;
52
+
53
+ event.preventDefault();
54
+ delete item.dataset.dragging;
55
+ swap(this.dropTarget(event.target), item);
56
+
57
+ if (item.dataset.hasOwnProperty("newItem")) {
58
+ const template = document.createElement("template");
59
+ template.innerHTML = event.dataTransfer.getData("text/html");
60
+ const newItem = template.content.querySelector("li");
61
+
62
+ this.element.replaceChild(newItem, item);
63
+ setTimeout(() =>
64
+ newItem.querySelector("[role='button'][value='edit']").click()
65
+ );
66
+ }
67
+
68
+ this.reindex();
69
+ }
70
+
71
+ dragend() {
72
+ const item = this.dragItem();
73
+ if (!item) return;
74
+
75
+ delete item.dataset.dragging;
76
+ this.reset();
77
+ }
78
+
79
+ dragItem() {
80
+ return this.element.querySelector("[data-dragging]");
81
+ }
82
+
83
+ dropTarget(e) {
84
+ return e && e.closest("[data-controller='content--editor--list'] > *");
85
+ }
86
+
87
+ reindex() {
88
+ this.dispatch("reindex", { bubbles: true, prefix: "content" });
89
+ }
90
+
91
+ reset() {
92
+ this.dispatch("reset", { bubbles: true, prefix: "content" });
93
+ }
94
+ }
95
+
96
+ function swap(target, item) {
97
+ if (target && target !== item) {
98
+ const positionComparison = target.compareDocumentPosition(item);
99
+ if (positionComparison & Node.DOCUMENT_POSITION_FOLLOWING) {
100
+ target.insertAdjacentElement("beforebegin", item);
101
+ } else if (positionComparison & Node.DOCUMENT_POSITION_PRECEDING) {
102
+ target.insertAdjacentElement("afterend", item);
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,12 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class NewItemController extends Controller {
4
+ static targets = ["template"];
5
+
6
+ dragstart(event) {
7
+ if (this.element !== event.target) return;
8
+
9
+ event.dataTransfer.setData("text/html", this.templateTarget.innerHTML);
10
+ event.dataTransfer.effectAllowed = "copy";
11
+ }
12
+ }
@@ -0,0 +1,22 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class StatusBarController extends Controller {
4
+ connect() {
5
+ // cache the version's state in the controller on connect
6
+ this.versionState = this.element.dataset.state;
7
+ }
8
+
9
+ change(e) {
10
+ if (e.detail && e.detail.hasOwnProperty("dirty")) {
11
+ this.update(e.detail);
12
+ }
13
+ }
14
+
15
+ update({ dirty }) {
16
+ if (dirty) {
17
+ this.element.dataset.state = "dirty";
18
+ } else {
19
+ this.element.dataset.state = this.versionState;
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,52 @@
1
+ import Item from "utils/content/editor/item";
2
+
3
+ /**
4
+ * @param nodes {NodeList}
5
+ * @returns {Item[]}
6
+ */
7
+ function createItemList(nodes) {
8
+ return Array.from(nodes).map((node) => new Item(node));
9
+ }
10
+
11
+ export default class Container {
12
+ /**
13
+ * @param node {Element} content editor list
14
+ */
15
+ constructor(node) {
16
+ this.node = node;
17
+ }
18
+
19
+ /**
20
+ * @return {Item[]} an ordered list of all items in the container
21
+ */
22
+ get items() {
23
+ return createItemList(this.node.querySelectorAll("[data-content-index]"));
24
+ }
25
+
26
+ /**
27
+ * @return {String} a serialized description of the structure of the container
28
+ */
29
+ get state() {
30
+ const inputs = this.node.querySelectorAll("li input[type=hidden]");
31
+ return Array.from(inputs)
32
+ .map((e) => e.value)
33
+ .join("/");
34
+ }
35
+
36
+ /**
37
+ * Set the index of items based on their current position.
38
+ */
39
+ reindex() {
40
+ this.items.map((item, index) => (item.index = index));
41
+ }
42
+
43
+ /**
44
+ * Resets the order of items to their defined index.
45
+ * Useful after an aborted drag.
46
+ */
47
+ reset() {
48
+ this.items.sort(Item.comparator).forEach((item) => {
49
+ this.node.appendChild(item.node);
50
+ });
51
+ }
52
+ }