panda_cms 0.5.10 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -1
  3. data/Rakefile +0 -1
  4. data/app/assets/builds/panda_cms.css +2415 -1
  5. data/app/assets/config/panda_cms_manifest.js +2 -0
  6. data/app/assets/stylesheets/panda_cms/application.tailwind.css +3 -27
  7. data/app/builders/panda_cms/form_builder.rb +1 -1
  8. data/app/components/panda_cms/admin/button_component.rb +6 -3
  9. data/app/components/panda_cms/admin/flash_message_component.rb +1 -1
  10. data/app/components/panda_cms/admin/tag_component.rb +1 -1
  11. data/app/components/panda_cms/code_component.rb +60 -0
  12. data/app/components/panda_cms/page_menu_component.html.erb +6 -4
  13. data/app/components/panda_cms/page_menu_component.rb +21 -12
  14. data/app/components/panda_cms/rich_text_component.html.erb +6 -38
  15. data/app/components/panda_cms/rich_text_component.rb +24 -7
  16. data/app/components/panda_cms/text_component.rb +25 -22
  17. data/app/controllers/panda_cms/admin/dashboard_controller.rb +14 -6
  18. data/app/controllers/panda_cms/admin/menus_controller.rb +1 -54
  19. data/app/controllers/panda_cms/admin/pages_controller.rb +2 -1
  20. data/app/controllers/panda_cms/admin/sessions_controller.rb +13 -6
  21. data/app/controllers/panda_cms/application_controller.rb +1 -1
  22. data/app/controllers/panda_cms/pages_controller.rb +1 -1
  23. data/app/controllers/panda_cms/posts_controller.rb +1 -1
  24. data/app/helpers/panda_cms/application_helper.rb +2 -2
  25. data/app/javascript/panda_cms/@hotwired--stimulus.js +4 -0
  26. data/app/javascript/panda_cms/@hotwired--turbo.js +160 -0
  27. data/app/javascript/panda_cms/@rails--actioncable--src.js +4 -0
  28. data/app/javascript/panda_cms/application_panda_cms.js +4 -0
  29. data/app/javascript/panda_cms/controllers/dashboard_controller.js +7 -0
  30. data/app/javascript/panda_cms/controllers/index.js +42 -0
  31. data/app/javascript/panda_cms/controllers/slug_controller.js +48 -0
  32. data/app/javascript/panda_cms/panda_cms_editable.js +248 -0
  33. data/app/javascript/panda_cms/tailwindcss-stimulus-components.js +4 -0
  34. data/app/lib/panda_cms/demo_site_generator.rb +1 -1
  35. data/app/lib/panda_cms/slug.rb +1 -1
  36. data/app/models/panda_cms/block.rb +2 -2
  37. data/app/models/panda_cms/page.rb +9 -3
  38. data/app/models/panda_cms/post.rb +1 -1
  39. data/app/models/panda_cms/template.rb +4 -2
  40. data/app/models/panda_cms/user.rb +9 -1
  41. data/app/views/panda_cms/admin/dashboard/show.html.erb +11 -9
  42. data/app/views/panda_cms/admin/forms/new.html.erb +6 -7
  43. data/app/views/panda_cms/admin/menus/index.html.erb +0 -2
  44. data/app/views/panda_cms/admin/pages/edit.html.erb +18 -16
  45. data/app/views/panda_cms/admin/pages/new.html.erb +6 -7
  46. data/app/views/panda_cms/admin/posts/_form.html.erb +4 -4
  47. data/app/views/panda_cms/admin/sessions/new.html.erb +1 -2
  48. data/app/views/panda_cms/admin/shared/_sidebar.html.erb +12 -16
  49. data/app/views/panda_cms/shared/_header.html.erb +14 -14
  50. data/app/views/panda_cms/shared/_importmap.html.erb +22 -0
  51. data/config/importmap.rb +11 -10
  52. data/config/initializers/panda_cms.rb +57 -55
  53. data/config/routes.rb +9 -9
  54. data/config/tailwind.config.js +1 -0
  55. data/db/migrate/20240205223709_create_panda_cms_pages.rb +6 -4
  56. data/lib/generators/panda_cms/install_generator.rb +3 -0
  57. data/lib/panda_cms/engine.rb +27 -22
  58. data/lib/panda_cms/version.rb +1 -1
  59. data/lib/panda_cms.rb +58 -10
  60. data/lib/tasks/panda_cms.rake +41 -57
  61. data/public/panda-cms-assets/rich_text_editor.css +568 -0
  62. metadata +216 -278
  63. data/app/javascript/base.js +0 -37
  64. data/app/javascript/controllers/menu_controller.js +0 -19
  65. data/app/javascript/controllers/text_controller.js +0 -78
  66. data/app/javascript/controllers/text_field_update_controller.js +0 -51
  67. data/app/javascript/vendor/stimulus-components-rails-nested-form.js +0 -2
  68. data/app/javascript/vendor/tailwindcss-stimulus-components.js +0 -2
  69. data/app/views/panda_cms/admin/menus/_form.html.erb +0 -21
  70. data/app/views/panda_cms/admin/menus/_menu_item_fields.html.erb +0 -7
  71. data/app/views/panda_cms/admin/menus/edit.html.erb +0 -58
  72. data/app/views/panda_cms/admin/menus/new.html.erb +0 -5
  73. data/public/panda-cms-assets/javascripts/base.js +0 -37
  74. data/public/panda-cms-assets/javascripts/controllers/menu_controller.js +0 -19
  75. data/public/panda-cms-assets/javascripts/controllers/text_field_update_controller.js +0 -23
  76. data/public/panda-cms-assets/javascripts/embed/editable.js +0 -358
  77. data/public/panda-cms-assets/javascripts/embed/rich_text.css +0 -1294
  78. data/public/panda-cms-assets/javascripts/vendor/stimulus-components-rails-nested-form.js +0 -2
  79. data/public/panda-cms-assets/javascripts/vendor/stimulus-loading.js +0 -113
  80. data/public/panda-cms-assets/javascripts/vendor/tailwindcss-stimulus-components.js +0 -2
  81. /data/db/migrate/{20240804110225_add_status_to_panda_cms_pages.rb → 20240315125411_add_status_to_panda_cms_pages.rb} +0 -0
@@ -0,0 +1,48 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export class SlugController extends Controller {
4
+ static targets = [
5
+ "existing_root",
6
+ "input_select",
7
+ "input_text",
8
+ "output_text",
9
+ ];
10
+
11
+ connect() {
12
+ console.debug("[Panda CMS] Slug handler connected...");
13
+ }
14
+
15
+ generatePath() {
16
+ this.output_textTarget.value = "/" + this.createSlug(this.input_textTarget.value);
17
+ console.log("Have set the path to: " + this.output_textTarget.value);
18
+ }
19
+
20
+ setPrePath() {
21
+ this.parent_slugs = this.input_selectTarget.options[this.input_selectTarget.selectedIndex].text.match(/.*\((.*)\)$/)[1];
22
+ this.output_textTarget.previousSibling.innerHTML = (this.existing_rootTarget.value + this.parent_slugs).replace(/\/$/, "");
23
+ console.log("Have set the pre-path to: " + this.output_textTarget.previousSibling.innerHTML);
24
+ }
25
+
26
+ // TODO: Invoke a library or helper which can be shared with the backend
27
+ // and check for uniqueness at the same time
28
+ createSlug(input) {
29
+ var str = input
30
+ .toLowerCase()
31
+ .trim()
32
+ .replace(/[^\w\s-]/g, "-")
33
+ .replace(/&/g, "and")
34
+ .replace(/[\s_-]+/g, "-")
35
+ .trim();
36
+
37
+ return this.trimStartEnd(str, "-");
38
+ }
39
+
40
+ trimStartEnd(str, ch) {
41
+ var start = 0;
42
+ var end = str.length;
43
+
44
+ while (start < end && str[start] === ch) ++start;
45
+ while (end > start && str[end - 1] === ch) --end;
46
+ return start > 0 || end < str.length ? str.substring(start, end) : str;
47
+ }
48
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Represents a controller for managing editable content within an iframe.
3
+ * @class
4
+ */
5
+ class PandaCmsEditableController {
6
+ /**
7
+ * Represents the constructor for the Editable class.
8
+ * @param {HTMLIFrameElement} frame - The iFrame element to be used for editing.
9
+ */
10
+ constructor(pageId, frame) {
11
+ this.pageId = pageId;
12
+ this.frame = frame;
13
+ this.frame.style.display = "none";
14
+ this.csrfToken = document.querySelector('meta[name="csrf-token"]').content;
15
+
16
+ var pathNameArray = window.location.pathname.split("/");
17
+ this.adminPath = pathNameArray[1];
18
+
19
+ this.frame.addEventListener("load", () => {
20
+ this.frameDocument = this.frame.contentDocument || this.frame.contentWindow.document;
21
+ this.body = this.frameDocument.body;
22
+ this.head = this.frameDocument.head;
23
+ this.loadEvents();
24
+ });
25
+ }
26
+
27
+ /* iFrame & Main Methods */
28
+
29
+ /**
30
+ * Load events for the editable iFrame
31
+ */
32
+ loadEvents() {
33
+ console.debug("[Panda CMS] iFrame loaded...");
34
+
35
+ this.embedPlainTextEditors();
36
+ this.embedTrix();
37
+ }
38
+
39
+ setFrameVisible() {
40
+ console.debug("[Panda CMS] Setting iFrame to visible...");
41
+ this.frame.style.display = "";
42
+ }
43
+
44
+ addStylesheet(frameDocument, head, href) {
45
+ return new Promise(function (resolve, reject) {
46
+ let link = frameDocument.createElement("link");
47
+ link.rel = "stylesheet";
48
+ link.href = href;
49
+ link.media = "none";
50
+ head.append(link);
51
+
52
+ link.onload = () => {
53
+ if (link.media != "all") {
54
+ link.media = "all";
55
+ }
56
+ console.debug(`[Panda CMS] Stylesheet loaded: ${href}`);
57
+ resolve(link);
58
+ };
59
+ link.onerror = () =>
60
+ reject(new Error(`[Panda CMS] Stylesheet load error for ${href}`));
61
+ });
62
+ }
63
+
64
+ loadScript(frameDocument, head, src) {
65
+ return new Promise(function (resolve, reject) {
66
+ let script = frameDocument.createElement("script");
67
+ script.src = src;
68
+ head.append(script);
69
+
70
+ script.onload = () => {
71
+ console.debug(`[Panda CMS] Script loaded: ${src}`);
72
+ resolve(script);
73
+ };
74
+ script.onerror = () =>
75
+ reject(new Error(`[Panda CMS] Script load error for ${src}`));
76
+ });
77
+ }
78
+
79
+ /* Plain Text Editor (inc. Code) Methods */
80
+
81
+ stylePlainTextEditor(element, status) {
82
+ console.debug(
83
+ `[Panda CMS] Styling editor ${element.id} as ${status}...`
84
+ );
85
+
86
+ if (status == "initial") {
87
+ element.style.border = "1px dashed #ccc";
88
+ element.style.outline = "none";
89
+ element.style.cursor = "pointer";
90
+ element.style.transition = "background 500ms linear";
91
+ element.style.backgroundColor = "inherit";
92
+ } else if (status == "success") {
93
+ element.style.backgroundColor = "#66bd6a50";
94
+ } else if (status == "error") {
95
+ element.style.backgroundColor = "#dc354550";
96
+ }
97
+
98
+ if (element.getAttribute("data-editable-kind") == "html") {
99
+ element.style.whiteSpace = "pre-wrap";
100
+ element.style.fontFamily = "monospace";
101
+ }
102
+ }
103
+
104
+ embedPlainTextEditors() {
105
+ var elements = this.body.querySelectorAll('[data-editable-kind="plain_text"], [data-editable-kind="markdown"], [data-editable-kind="html"]');
106
+ if (elements.length == 0) {
107
+ return;
108
+ }
109
+
110
+ elements.forEach((element) => {
111
+ this.stylePlainTextEditor(element, "initial");
112
+ var currentElement = element;
113
+
114
+ // On save click, save this element
115
+ document.getElementById('saveEditableButton').addEventListener('click', () => {
116
+ this.bindPlainTextSaveHandler(currentElement);
117
+ });
118
+
119
+ console.debug("[Panda CMS] Attached button event handler to ${currentElement.id}");
120
+ });
121
+
122
+ console.debug(
123
+ "[Panda CMS] Dispatching event: pandaCmsPlainTextEditorLoaded"
124
+ );
125
+
126
+ // Let the parent know that the external resources have been loaded
127
+ this.frameDocument.dispatchEvent(new Event("pandaCmsPlainTextEditorLoaded"));
128
+ }
129
+
130
+ bindPlainTextSaveHandler(target) {
131
+ var blockContentId = target.getAttribute(
132
+ "data-editable-block-content-id"
133
+ );
134
+
135
+ if (target.getAttribute("data-editable-kind") == "html") { // Or markdown?
136
+ var content = target.innerText;
137
+ } else {
138
+ var content = target.innerHTML;
139
+ }
140
+
141
+ fetch(`/${this.adminPath}/pages/${this.pageId}/block_contents/${blockContentId}`, {
142
+ method: "PATCH",
143
+ headers: {
144
+ "Content-Type": "application/json",
145
+ "X-CSRF-Token": document
146
+ .querySelector('meta[name="csrf-token"]')
147
+ .getAttribute("content"),
148
+ },
149
+ body: JSON.stringify({ content: content }),
150
+ })
151
+ .then((response) => response.json())
152
+ .then((data) => {
153
+ this.stylePlainTextEditor(target, "success");
154
+ setTimeout(() => {
155
+ this.stylePlainTextEditor(target, "initial");
156
+ }, 1000);
157
+ })
158
+ .catch((error) => {
159
+ this.stylePlainTextEditor(target, "error");
160
+ setTimeout(() => {
161
+ this.stylePlainTextEditor(target, "initial");
162
+ }, 1000);
163
+ alert("Error:", error);
164
+ console.log(error);
165
+ });
166
+ }
167
+
168
+ /* Rich Text Editor Methods */
169
+
170
+ embedTrix() {
171
+ // Our stylesheet includes dist trix.css as of v2.1.7
172
+ this.addStylesheet(this.frameDocument, this.head, "/panda-cms-assets/rich_text_editor.css");
173
+ this.loadScript(this.frameDocument, this.head, "https://cdn.jsdelivr.net/npm/trix/dist/trix.js");
174
+
175
+ this.body.addEventListener("trix-before-initialize", () => {
176
+ // Change Trix.config if you need
177
+ console.debug("[Panda CMS] Trix before initialize");
178
+ // Trix.config.blockAttributes.heading = {
179
+ // tagName: "h2",
180
+ // terminal: true,
181
+ // breakOnReturn: true,
182
+ // group: false
183
+ // }
184
+
185
+ // Trix.config.blockAttributes.subHeading = {
186
+ // tagName: "h3",
187
+ // terminal: true,
188
+ // breakOnReturn: true,
189
+ // group: false
190
+ // }
191
+
192
+ // To make Trix styles appear we should define them in nb surely?
193
+ // Defining e.g. .panda-cms-content h2 in panda won't show it in panda, but
194
+ // as this is a page in nb
195
+ // We can define these styles through admin or a custom stylesheet eventually?
196
+
197
+ console.log(Trix.config.blockAttributes);
198
+ })
199
+
200
+ this.body.addEventListener("trix-initialize", (event) => {
201
+ const { toolbarElement } = event.target
202
+ const h1Button = toolbarElement.querySelector("[data-trix-attribute=heading1]")
203
+ h1Button.insertAdjacentHTML("afterend", `
204
+ <button type="button" class="trix-button" data-trix-attribute="heading2" title="Heading 2" tabindex="-1" data-trix-active="">H2</button>
205
+ `)
206
+ })
207
+
208
+ document.getElementById('saveEditableButton').addEventListener('click', () => {
209
+ console.debug("[Panda CMS] Handling click event on #saveEditableButton");
210
+ // Grab each input element that's a rich text editor and append a save handler to the button
211
+ var elements = this.frameDocument.querySelectorAll("input[data-editor-type='rich-text']");
212
+ elements.forEach((element) => {
213
+ var blockContentId = element.getAttribute("data-editor-block-content-id");
214
+ this.bindTrixSaveHandler(blockContentId, element.value);
215
+ });
216
+ });
217
+
218
+ // This prevents the flash of content before the iFrame is ready
219
+ console.debug("[Panda CMS] Setting iFrame to visible");
220
+ this.setFrameVisible();
221
+ }
222
+
223
+ bindTrixSaveHandler(blockContentId, content) {
224
+ console.debug(`[Panda CMS] Calling save handler for ${blockContentId}...`);
225
+ fetch(`/${this.adminPath}/pages/${this.pageId}/block_contents/${blockContentId}`, {
226
+ method: "PATCH",
227
+ headers: {
228
+ "Content-Type": "application/json",
229
+ "X-CSRF-Token": this.csrfToken,
230
+ },
231
+ body: JSON.stringify({ content: content }),
232
+ })
233
+ .then((response) => response.json())
234
+ .then((data) => {
235
+ console.debug("[Panda CMS] Save successful for block content ID:", blockContentId);
236
+ document.getElementById('saveEditableButton').classList.remove("bg-inactive");
237
+ document.getElementById('saveEditableButton').classList.add("bg-active");
238
+ setTimeout(() => {
239
+ document.getElementById('saveEditableButton').classList.remove("bg-active");
240
+ document.getElementById('saveEditableButton').classList.add("bg-inactive");
241
+ }, 1500);
242
+ })
243
+ .catch((error) => {
244
+ console.log(error);
245
+ alert("Error updating. Please contact the support team!", error);
246
+ });
247
+ }
248
+ }
@@ -0,0 +1,4 @@
1
+ // tailwindcss-stimulus-components@6.1.2 downloaded from https://ga.jspm.io/npm:tailwindcss-stimulus-components@6.1.2/dist/tailwindcss-stimulus-components.module.js
2
+
3
+ import{Controller as e}from"@hotwired/stimulus";var t=Object.defineProperty;var V=(e,s,a)=>s in e?t(e,s,{enumerable:!0,configurable:!0,writable:!0,value:a}):e[s]=a;var i=(e,t,s)=>V(e,typeof t!="symbol"?t+"":t,s);async function n(e,t,s={}){t?await T(e,s):await b(e,s)}async function T(e,t={}){let{transitionClasses:s,fromClasses:a,toClasses:o,toggleClass:r}=C("Enter",e,t);return v(e,{firstFrame(){e.classList.add(...s.split(" ")),e.classList.add(...a.split(" ")),e.classList.remove(...o.split(" ")),e.classList.remove(...r.split(" "))},secondFrame(){e.classList.remove(...a.split(" ")),e.classList.add(...o.split(" "))},ending(){e.classList.remove(...s.split(" "))}})}async function b(e,t={}){let{transitionClasses:s,fromClasses:a,toClasses:o,toggleClass:r}=C("Leave",e,t);return v(e,{firstFrame(){e.classList.add(...a.split(" ")),e.classList.remove(...o.split(" ")),e.classList.add(...s.split(" "))},secondFrame(){e.classList.remove(...a.split(" ")),e.classList.add(...o.split(" "))},ending(){e.classList.remove(...s.split(" ")),e.classList.add(...r.split(" "))}})}function C(e,t,s){return{transitionClasses:t.dataset[`transition${e}`]||s[e.toLowerCase()]||e.toLowerCase(),fromClasses:t.dataset[`transition${e}From`]||s[`${e.toLowerCase()}From`]||`${e.toLowerCase()}-from`,toClasses:t.dataset[`transition${e}To`]||s[`${e.toLowerCase()}To`]||`${e.toLowerCase()}-to`,toggleClass:t.dataset.toggleClass||s.toggleClass||s.toggle||"hidden"}}function L(e){e._stimulus_transition={timeout:null,interrupted:!1}}function I(e){e._stimulus_transition&&e._stimulus_transition.interrupt&&e._stimulus_transition.interrupt()}function v(e,t){e._stimulus_transition&&I(e);let s,a,o;return L(e),e._stimulus_transition.cleanup=()=>{a||t.firstFrame(),o||t.secondFrame(),t.ending(),e._stimulus_transition=null},e._stimulus_transition.interrupt=()=>{s=!0,e._stimulus_transition.timeout&&clearTimeout(e._stimulus_transition.timeout),e._stimulus_transition.cleanup()},new Promise((r=>{s||requestAnimationFrame((()=>{s||(t.firstFrame(),a=!0,requestAnimationFrame((()=>{s||(t.secondFrame(),o=!0,e._stimulus_transition&&(e._stimulus_transition.timeout=setTimeout((()=>{s||e._stimulus_transition.cleanup(),r()}),w(e))))})))}))}))}function w(e){let t=Number(getComputedStyle(e).transitionDuration.replace(/,.*/,"").replace("s",""))*1e3,s=Number(getComputedStyle(e).transitionDelay.replace(/,.*/,"").replace("s",""))*1e3;return t===0&&(t=Number(getComputedStyle(e).animationDuration.replace("s",""))*1e3),t+s}var s=class extends e{connect(){setTimeout((()=>{T(this.element)}),this.showDelayValue),this.hasDismissAfterValue&&setTimeout((()=>{this.close()}),this.dismissAfterValue)}close(){b(this.element).then((()=>{this.element.remove()}))}};i(s,"values",{dismissAfter:Number,showDelay:{type:Number,default:0}});var a=class extends e{connect(){this.timeout=null}save(){clearTimeout(this.timeout),this.timeout=setTimeout((()=>{this.statusTarget.textContent=this.submittingTextValue,this.formTarget.requestSubmit()}),this.submitDurationValue)}success(){this.setStatus(this.successTextValue)}error(){this.setStatus(this.errorTextValue)}setStatus(e){this.statusTarget.textContent=e,this.timeout=setTimeout((()=>{this.statusTarget.textContent=""}),this.statusDurationValue)}};i(a,"targets",["form","status"]),i(a,"values",{submitDuration:{type:Number,default:1e3},statusDuration:{type:Number,default:2e3},submittingText:{type:String,default:"Saving..."},successText:{type:String,default:"Saved!"},errorText:{type:String,default:"Unable to save."}});var o=class extends e{update(){this.preview=this.colorTarget.value}set preview(e){this.previewTarget.style[this.styleValue]=e;let t=this._getContrastYIQ(e);this.styleValue==="color"?this.previewTarget.style.backgroundColor=t:this.previewTarget.style.color=t}_getContrastYIQ(e){e=e.replace("#","");let t=128,s=parseInt(e.substr(0,2),16),a=parseInt(e.substr(2,2),16),o=parseInt(e.substr(4,2),16);return(s*299+a*587+o*114)/1e3>=t?"#000":"#fff"}};i(o,"targets",["preview","color"]),i(o,"values",{style:{type:String,default:"backgroundColor"}});var r=class extends e{connect(){this.boundBeforeCache=this.beforeCache.bind(this),document.addEventListener("turbo:before-cache",this.boundBeforeCache)}disconnect(){document.removeEventListener("turbo:before-cache",this.boundBeforeCache)}openValueChanged(){n(this.menuTarget,this.openValue,this.transitionOptions),this.openValue===!0&&this.hasMenuItemTarget&&this.menuItemTargets[0].focus()}show(){this.openValue=!0}close(){this.openValue=!1}hide(e){this.closeOnClickOutsideValue&&e.target.nodeType&&this.element.contains(e.target)===!1&&this.openValue&&(this.openValue=!1),this.closeOnEscapeValue&&e.key==="Escape"&&this.openValue&&(this.openValue=!1)}toggle(){this.openValue=!this.openValue}nextItem(e){e.preventDefault(),this.menuItemTargets[this.nextIndex].focus()}previousItem(e){e.preventDefault(),this.menuItemTargets[this.previousIndex].focus()}get currentItemIndex(){return this.menuItemTargets.indexOf(document.activeElement)}get nextIndex(){return Math.min(this.currentItemIndex+1,this.menuItemTargets.length-1)}get previousIndex(){return Math.max(this.currentItemIndex-1,0)}get transitionOptions(){return{enter:this.hasEnterClass?this.enterClass:"transition ease-out duration-100",enterFrom:this.hasEnterFromClass?this.enterFromClass:"transform opacity-0 scale-95",enterTo:this.hasEnterToClass?this.enterToClass:"transform opacity-100 scale-100",leave:this.hasLeaveClass?this.leaveClass:"transition ease-in duration-75",leaveFrom:this.hasLeaveFromClass?this.leaveFromClass:"transform opacity-100 scale-100",leaveTo:this.hasLeaveToClass?this.leaveToClass:"transform opacity-0 scale-95",toggleClass:this.hasToggleClass?this.toggleClass:"hidden"}}beforeCache(){this.openValue=!1,this.menuTarget.classList.add("hidden")}};i(r,"targets",["menu","button","menuItem"]),i(r,"values",{open:{type:Boolean,default:!1},closeOnEscape:{type:Boolean,default:!0},closeOnClickOutside:{type:Boolean,default:!0}}),i(r,"classes",["enter","enterFrom","enterTo","leave","leaveFrom","leaveTo","toggle"]);var l=class extends e{connect(){this.openValue&&this.open(),this.boundBeforeCache=this.beforeCache.bind(this),document.addEventListener("turbo:before-cache",this.boundBeforeCache)}disconnect(){document.removeEventListener("turbo:before-cache",this.boundBeforeCache)}open(){this.dialogTarget.showModal()}close(){this.dialogTarget.setAttribute("closing",""),Promise.all(this.dialogTarget.getAnimations().map((e=>e.finished))).then((()=>{this.dialogTarget.removeAttribute("closing"),this.dialogTarget.close()}))}backdropClose(e){e.target.nodeName=="DIALOG"&&this.close()}show(){this.dialogTarget.show()}hide(){this.close()}beforeCache(){this.close()}};i(l,"targets",["dialog"]),i(l,"values",{open:Boolean});var u=class extends e{openValueChanged(){n(this.contentTarget,this.openValue),this.shouldAutoDismiss&&this.scheduleDismissal()}show(e){this.shouldAutoDismiss&&this.scheduleDismissal(),this.openValue=!0}hide(){this.openValue=!1}toggle(){this.openValue=!this.openValue}get shouldAutoDismiss(){return this.openValue&&this.hasDismissAfterValue}scheduleDismissal(){this.hasDismissAfterValue&&(this.cancelDismissal(),this.timeoutId=setTimeout((()=>{this.hide(),this.timeoutId=void 0}),this.dismissAfterValue))}cancelDismissal(){typeof this.timeoutId=="number"&&(clearTimeout(this.timeoutId),this.timeoutId=void 0)}};i(u,"targets",["content"]),i(u,"values",{dismissAfter:Number,open:{type:Boolean,default:!1}});var h=class extends e{connect(){this.openValue&&this.open(),this.boundBeforeCache=this.beforeCache,document.addEventListener("turbo:before-cache",this.boundBeforeCache)}disconnect(){document.removeEventListener("turbo:before-cache",this.boundBeforeCache)}open(){this.dialogTarget.showModal()}close(){this.dialogTarget.setAttribute("closing",""),Promise.all(this.dialogTarget.getAnimations().map((e=>e.finished))).then((()=>{this.dialogTarget.removeAttribute("closing"),this.dialogTarget.close()}))}backdropClose(e){e.target.nodeName=="DIALOG"&&this.close()}show(){this.open()}hide(){this.close()}beforeCache(){this.close()}};i(h,"targets",["dialog"]),i(h,"values",{open:Boolean});var c=class extends e{initialize(){this.updateAnchorValue&&this.anchor&&(this.indexValue=this.tabTargets.findIndex((e=>e.id===this.anchor)))}connect(){this.showTab()}change(e){e.currentTarget.tagName==="SELECT"?this.indexValue=e.currentTarget.selectedIndex:e.currentTarget.dataset.index?this.indexValue=e.currentTarget.dataset.index:e.currentTarget.dataset.id?this.indexValue=this.tabTargets.findIndex((t=>t.id==e.currentTarget.dataset.id)):this.indexValue=this.tabTargets.indexOf(e.currentTarget)}nextTab(){this.indexValue=Math.min(this.indexValue+1,this.tabsCount-1)}previousTab(){this.indexValue=Math.max(this.indexValue-1,0)}firstTab(){this.indexValue=0}lastTab(){this.indexValue=this.tabsCount-1}indexValueChanged(){if(this.showTab(),this.dispatch("tab-change",{target:this.tabTargets[this.indexValue],detail:{activeIndex:this.indexValue}}),this.updateAnchorValue){let e=this.tabTargets[this.indexValue].id;if(this.scrollToAnchorValue)location.hash=e;else{let t=window.location.href.split("#")[0]+"#"+e;typeof Turbo<"u"?Turbo.navigator.history.replace(new URL(t)):history.replaceState({},document.title,t)}}}showTab(){this.panelTargets.forEach(((e,t)=>{let s=this.tabTargets[t];t===this.indexValue?(e.classList.remove("hidden"),s.ariaSelected="true",s.dataset.active=!0,this.hasInactiveTabClass&&s?.classList?.remove(...this.inactiveTabClasses),this.hasActiveTabClass&&s?.classList?.add(...this.activeTabClasses)):(e.classList.add("hidden"),s.ariaSelected=null,delete s.dataset.active,this.hasActiveTabClass&&s?.classList?.remove(...this.activeTabClasses),this.hasInactiveTabClass&&s?.classList?.add(...this.inactiveTabClasses))})),this.hasSelectTarget&&(this.selectTarget.selectedIndex=this.indexValue),this.scrollActiveTabIntoViewValue&&this.scrollToActiveTab()}scrollToActiveTab(){let e=this.element.querySelector("[aria-selected]");e&&e.scrollIntoView({inline:"center"})}get tabsCount(){return this.tabTargets.length}get anchor(){return document.URL.split("#").length>1?document.URL.split("#")[1]:null}};i(c,"classes",["activeTab","inactiveTab"]),i(c,"targets",["tab","panel","select"]),i(c,"values",{index:0,updateAnchor:Boolean,scrollToAnchor:Boolean,scrollActiveTabIntoView:Boolean});var d=class extends e{toggle(e){this.openValue=!this.openValue,this.animate()}toggleInput(e){this.openValue=e.target.checked,this.animate()}hide(){this.openValue=!1,this.animate()}show(){this.openValue=!0,this.animate()}animate(){this.toggleableTargets.forEach((e=>{n(e,this.openValue)}))}};i(d,"targets",["toggleable"]),i(d,"values",{open:{type:Boolean,default:!1}});export{s as Alert,a as Autosave,o as ColorPreview,r as Dropdown,l as Modal,u as Popover,h as Slideover,c as Tabs,d as Toggle,n as transition};
4
+
@@ -36,7 +36,7 @@ module PandaCms
36
36
  def create_pages
37
37
  @pages[:home] = PandaCms::Page.find_or_create_by!({path: "/", title: "Home", template: @templates[:homepage]})
38
38
  @pages[:about] = PandaCms::Page.find_or_create_by!({path: "/about", title: "About", template: @templates[:page], parent: @pages[:home]})
39
- @pages[:terms] = PandaCms::Page.find_or_create_by!({path: "/terms-and-conditions", title: "Terms & Conditions", template: @templates[:page], parent: @pages[:home]})
39
+ @pages[:terms] = PandaCms::Page.find_or_create_by!({path: "/terms-and-conditions", title: "Terms & Conditions", template: @templates[:page], parent: @pages[:home], status: "hidden"})
40
40
 
41
41
  PandaCms::Page.reset_column_information
42
42
  PandaCms::Page.rebuild!
@@ -5,7 +5,7 @@ module PandaCms
5
5
  #
6
6
  # @param string [String] The provided string to turn into a slug
7
7
  # @return string Generated slug
8
- # @see text_field_update_controller.js should also implement this logic
8
+ # @see slug_controller.js should also implement this logic
9
9
  def self.generate(string)
10
10
  # Trim whitespace and downcase the string
11
11
  string = string.to_s.strip.downcase
@@ -16,12 +16,12 @@ module PandaCms
16
16
  plain_text: "plain_text",
17
17
  rich_text: "rich_text",
18
18
  iframe: "iframe",
19
- list: "list"
19
+ list: "list",
20
+ code: "code"
20
21
  # image: "image",
21
22
  # video: "video",
22
23
  # audio: "audio",
23
24
  # file: "file",
24
- # code: "code",
25
25
  # iframe: "iframe",
26
26
  # quote: "quote",
27
27
  # list: "list"
@@ -18,15 +18,21 @@ module PandaCms
18
18
  has_many :menu_items, foreign_key: :panda_cms_page_id, class_name: "PandaCms::MenuItem", inverse_of: :page
19
19
  has_many :menus, through: :menu_items
20
20
  has_many :menus_of_parent, through: :parent, source: :menus
21
- has_one :page_menu, foreign_key: :panda_cms_menu_id, class_name: "PandaCms::Menu", inverse_of: :start_page
21
+ has_one :page_menu, foreign_key: :start_page_id, class_name: "PandaCms::Menu"
22
22
 
23
23
  validates :title, presence: true
24
+
24
25
  validates :path,
25
26
  presence: true,
26
27
  uniqueness: true,
27
28
  format: {with: /\A\/.*\z/, message: "must start with a forward slash"}
28
- validates :parent, presence: true, unless: -> { path == "/" }
29
- validates :panda_cms_template_id, presence: true
29
+
30
+ validates :parent,
31
+ presence: true,
32
+ unless: -> { path == "/" }
33
+
34
+ validates :panda_cms_template_id,
35
+ presence: true
30
36
 
31
37
  scope :ordered, -> { order(:lft) }
32
38
 
@@ -44,7 +44,7 @@ module PandaCms
44
44
  end
45
45
 
46
46
  def path
47
- "/" + PandaCms.posts[:prefix] + slug.to_s
47
+ "/" + PandaCms.config.posts[:prefix] + slug.to_s
48
48
  end
49
49
 
50
50
  def formatted_slug
@@ -24,9 +24,11 @@ module PandaCms
24
24
  validate :validate_template_file_exists
25
25
 
26
26
  # Scopes
27
- scope :ordered, -> { order(:sort_order) }
28
27
  scope :available, -> { where("max_uses IS NULL OR (pages_count < max_uses)") }
29
- scope :most_used, -> { order(pages_count: :desc).first }
28
+
29
+ def self.default
30
+ find_by(file_path: "layouts/page") || first
31
+ end
30
32
 
31
33
  # Generate missing blocks for all templates
32
34
  # @return [void]
@@ -2,7 +2,9 @@ module PandaCms
2
2
  class User < ApplicationRecord
3
3
  validates :firstname, presence: true
4
4
  validates :lastname, presence: true
5
- validates :email, presence: true, uniqueness: {case_sensitive: true}
5
+ validates :email, presence: true, uniqueness: true
6
+
7
+ before_save :downcase_email
6
8
 
7
9
  def is_admin?
8
10
  admin
@@ -15,5 +17,11 @@ module PandaCms
15
17
  def self.for_select_list(scope = :all, order = {firstname: :asc, lastname: :asc})
16
18
  PandaCms::User.send(scope).order(order).map { |u| [u.name, u.id] }
17
19
  end
20
+
21
+ private
22
+
23
+ def downcase_email
24
+ self.email = email.to_s.downcase
25
+ end
18
26
  end
19
27
  end
@@ -1,10 +1,12 @@
1
- <%= render PandaCms::Admin::ContainerComponent.new do |container| %>
2
- <% container.with_heading(text: "Dashboard", level: 1) do |heading| %>
3
- <% heading.with_button(action: :add, text: "Add Page", link: new_admin_page_path) %>
1
+ <div class="" data-controller="dashboard">
2
+ <%= render PandaCms::Admin::ContainerComponent.new do |container| %>
3
+ <% container.with_heading(text: "Dashboard", level: 1) do |heading| %>
4
+ <% heading.with_button(action: :add, text: "Add Page", link: new_admin_page_path) %>
5
+ <% end %>
6
+ <dl class="grid grid-cols-1 gap-5 mt-5 sm:grid-cols-3">
7
+ <%= render PandaCms::Admin::StatisticsComponent.new(metric: "Views Today", value: PandaCms::Visit.group_by_day(:visited_at, last: 1).count.values.first) %>
8
+ <%= render PandaCms::Admin::StatisticsComponent.new(metric: "Views Last Week", value: PandaCms::Visit.group_by_week(:visited_at, last: 1).count.values.first) %>
9
+ <%= render PandaCms::Admin::StatisticsComponent.new(metric: "Views Last Month", value: PandaCms::Visit.group_by_month(:visited_at, last: 1).count.values.first) %>
10
+ </dl>
4
11
  <% end %>
5
- <dl class="grid grid-cols-1 gap-5 mt-5 sm:grid-cols-3">
6
- <%= render PandaCms::Admin::StatisticsComponent.new(metric: "Views Today", value: PandaCms::Visit.group_by_day(:visited_at, last: 1).count.values.first) %>
7
- <%= render PandaCms::Admin::StatisticsComponent.new(metric: "Views Last Week", value: PandaCms::Visit.group_by_week(:visited_at, last: 1).count.values.first) %>
8
- <%= render PandaCms::Admin::StatisticsComponent.new(metric: "Views Last Month", value: PandaCms::Visit.group_by_month(:visited_at, last: 1).count.values.first) %>
9
- </dl>
10
- <% end %>
12
+ </div>
@@ -1,14 +1,13 @@
1
1
  <%= render PandaCms::Admin::ContainerComponent.new do |component| %>
2
2
  <% component.with_heading(text: "Add Page", level: 1) do |heading| %>
3
3
  <% end %>
4
-
5
4
  <%= panda_cms_form_with model: page, url: admin_pages_path, method: :post do |f| %>
6
- <% options = nested_set_options(PandaCms::Page, page) { |i| "#{"-" * i.level} #{i.title} (#{i.path})" } %>
7
- <div data-controller="text-field-update">
8
- <input type="hidden" value="<%= PandaCms::Current.root %>" data-text-field-update-target="existing_root">
9
- <%= f.select :parent_id, options, {}, { "data-text-field-update-target": "input_select", "data-action": "change->text-field-update#setPrePath" } %>
10
- <%= f.text_field :title, { data: { "text-field-update-target": "input_text", action: "focusout->text-field-update#generatePath" } } %>
11
- <%= f.text_field :path, { data: { prefix: PandaCms::Current.root, "text-field-update-target": "output_text" } } %>
5
+ <% options = nested_set_options(PandaCms::Page, page) { |i| "#{"-" * i.level} #{i.title} (#{i.path})" } %>
6
+ <div data-controller="slug">
7
+ <input type="hidden" value="<%= PandaCms::Current.root %>" data-slug-target="existing_root">
8
+ <%= f.select :parent_id, options, {}, { "data-slug-target": "input_select", "data-action": "change->slug#setPrePath" } %>
9
+ <%= f.text_field :title, { data: { "slug-target": "input_text", action: "focusout->slug#generatePath" } } %>
10
+ <%= f.text_field :path, { data: { prefix: PandaCms::Current.root, "slug-target": "output_text" } } %>
12
11
  <%= f.collection_select :panda_cms_template_id, PandaCms::Template.available, :id, :name %>
13
12
  <%= f.button %>
14
13
  </div>
@@ -1,8 +1,6 @@
1
1
  <%= render PandaCms::Admin::ContainerComponent.new do |component| %>
2
2
  <% component.with_heading(text: "Menus", level: 1) do |heading| %>
3
- <% heading.with_button(action: :add, text: "Add Menu", link: new_admin_menu_path) %>
4
3
  <% end %>
5
-
6
4
  <%= render PandaCms::Admin::TableComponent.new(term: "menu", rows: menus) do |table| %>
7
5
  <% table.column("Name") { |menu| link_to menu.name, edit_admin_menu_path(menu) } %>
8
6
  <% table.column("Kind") { |menu| render PandaCms::Admin::TagComponent.new(status: :active, text: menu.kind.titleize) } %>
@@ -1,26 +1,28 @@
1
1
  <%= render PandaCms::Admin::ContainerComponent.new do |component| %>
2
2
  <% component.with_heading(text: "#{page.title}", level: 1) %>
3
-
4
3
  <% component.with_slideover(title: "Page Details") do %>
5
4
  <%= panda_cms_form_with model: page, url: admin_page_path, method: :put do |f| %>
6
- <%= f.text_field :title, class: "block w-full rounded-md border-0 p-2 text-gray-900 ring-1 ring-inset ring-mid placeholder:text-gray-300 focus:ring-1 focus:ring-inset focus:ring-dark sm:text-sm sm:leading-6 hover:pointer" %>
7
-
8
- <%= f.text_field :template, value: template.name, readonly: true, class: "read-only:bg-gray-100 block w-full rounded-md border-0 p-2 text-gray-900 ring-1 ring-inset ring-mid placeholder:text-gray-300 focus:ring-1 focus:ring-inset focus:ring-dark sm:text-sm sm:leading-6 hover:pointer" %>
9
-
10
- <%= f.select :status, options_for_select([["Active", "active"], ["Draft", "draft"], ["Hidden", "hidden"], ["Archived", "archived"]], selected: page.status), {}, class: "block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-mid focus:ring-1 focus:ring-inset focus:ring-dark sm:text-sm sm:leading-6 hover:pointer" %>
11
-
12
- <%= f.submit "Save" %>
5
+ <%= f.text_field :title, class: "block w-full rounded-md border-0 p-2 text-gray-900 ring-1 ring-inset ring-mid placeholder:text-gray-300 focus:ring-1 focus:ring-inset focus:ring-dark sm:text-sm sm:leading-6 hover:pointer" %>
6
+ <%= f.text_field :template, value: template.name, readonly: true, class: "read-only:bg-gray-100 block w-full rounded-md border-0 p-2 text-gray-900 ring-1 ring-inset ring-mid placeholder:text-gray-300 focus:ring-1 focus:ring-inset focus:ring-dark sm:text-sm sm:leading-6 hover:pointer" %>
7
+ <%= f.select :status, options_for_select([["Active", "active"], ["Draft", "draft"], ["Hidden", "hidden"], ["Archived", "archived"]], selected: page.status), {}, class: "block w-full rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-mid focus:ring-1 focus:ring-inset focus:ring-dark sm:text-sm sm:leading-6 hover:pointer" %>
8
+ <%= f.submit "Save" %>
13
9
  <% end %>
14
10
  <% end %>
15
-
16
- <a class="block mb-2 -mt-4 text-sm text-black/60" target="_blank" href="<%= @page.path %>"><%= @page.path %> <i class="ml-2 fa-solid fa-arrow-up-right-from-square"></i></a>
17
-
11
+ <div class="grid grid-cols-2 mb-4 -mt-5">
12
+ <div>
13
+ <a class="inline-block mb-2 text-sm text-black/60" target="_blank" href="<%= @page.path %>"><%= @page.path %> <i class="ml-2 fa-solid fa-arrow-up-right-from-square"></i></a>
14
+ </div>
15
+ <div class="relative -mt-5">
16
+ <span class="absolute right-0"><%= render PandaCms::Admin::ButtonComponent.new(text: "Save Changes", action: :save_inactive, icon: "check", link: "#", size: :regular, id: "saveEditableButton") %></span>
17
+ </div>
18
+ </div>
18
19
  <iframe id="editablePageFrame" src="<%= page.path %>?embed_id=<%= page.id %>" class="p-0 m-0 w-full h-full border border-slate-200"></iframe>
19
-
20
- <script src="/panda-cms-assets/javascripts/embed/editable.js"></script>
21
- <script>
20
+ <% end %>
21
+ <% content_for :head do %>
22
+ <%#= javascript_include_tag "panda_cms_editable", "data-turbo-track": "reload", defer: true %>
23
+ <!-- <script>
22
24
  document.addEventListener("DOMContentLoaded", function() {
23
- const editable = new EditableController("<%= page.id %>", document.getElementById("editablePageFrame"));
25
+ const editable = new PandaCmsEditableController("<%= page.id %>", document.getElementById("editablePageFrame"));
24
26
  });
25
- </script>
27
+ </script> -->
26
28
  <% end %>
@@ -1,14 +1,13 @@
1
1
  <%= render PandaCms::Admin::ContainerComponent.new do |component| %>
2
2
  <% component.with_heading(text: "Add Page", level: 1) do |heading| %>
3
3
  <% end %>
4
-
5
4
  <%= panda_cms_form_with model: page, url: admin_pages_path, method: :post do |f| %>
6
- <% options = nested_set_options(PandaCms::Page, page) { |i| "#{"-" * i.level} #{i.title} (#{i.path})" } %>
7
- <div data-controller="text-field-update">
8
- <input type="hidden" value="<%= PandaCms::Current.root %>" data-text-field-update-target="existing_root">
9
- <%= f.select :parent_id, options, {}, { "data-text-field-update-target": "input_select", "data-action": "change->text-field-update#setPrePath" } %>
10
- <%= f.text_field :title, { data: { "text-field-update-target": "input_text", action: "focusout->text-field-update#generatePath" } } %>
11
- <%= f.text_field :path, { meta: t(".path.meta"), data: { prefix: PandaCms::Current.root, "text-field-update-target": "output_text" } } %>
5
+ <% options = nested_set_options(PandaCms::Page, page) { |i| "#{"-" * i.level} #{i.title} (#{i.path})" } %>
6
+ <div data-controller="slug">
7
+ <input type="hidden" value="<%= PandaCms::Current.root %>" data-slug-target="existing_root">
8
+ <%= f.select :parent_id, options, {}, { "data-slug-target": "input_select", "data-action": "change->slug#setPrePath" } %>
9
+ <%= f.text_field :title, { data: { "slug-target": "input_text", action: "focusout->slug#generatePath" } } %>
10
+ <%= f.text_field :path, { meta: t(".path.meta"), data: { prefix: PandaCms::Current.root, "slug-target": "output_text" } } %>
12
11
  <%= f.collection_select :panda_cms_template_id, PandaCms::Template.available, :id, :name %>
13
12
  <%= f.button %>
14
13
  </div>
@@ -1,8 +1,8 @@
1
1
  <%= panda_cms_form_with model: post, url: url do |f| %>
2
- <div data-controller="text-field-update">
3
- <input type="hidden" value="<%= PandaCms::Current.root %>" data-text-field-update-target="existing_root">
4
- <%= f.text_field :title, { required: true, data: { "text-field-update-target": "input_text", action: "focusout->text-field-update#generatePath" } } %>
5
- <%= f.text_field :slug, { required: true, data: { prefix: PandaCms::Current.root + "/#{PandaCms.posts[:prefix]}", "text-field-update-target": "output_text" } } %>
2
+ <div data-controller="slug">
3
+ <input type="hidden" value="<%= PandaCms::Current.root %>" data-slug-target="existing_root">
4
+ <%= f.text_field :title, { required: true, data: { "slug-target": "input_text", action: "focusout->slug#generatePath" } } %>
5
+ <%= f.text_field :slug, { required: true, data: { prefix: PandaCms::Current.root + "/#{PandaCms.config.posts[:prefix]}", "slug-target": "output_text" } } %>
6
6
  <%= f.select :user_id, PandaCms::User.for_select_list %>
7
7
  <%= f.datetime_field :published_at, { required: true } %>
8
8
  <%= f.select :status, PandaCms::Post.statuses.keys.map { |status| [status.humanize, status] } %>
@@ -3,10 +3,9 @@
3
3
  <img src="/panda-cms-assets/panda-nav.png" class="py-2 mx-auto w-auto h-32">
4
4
  <h2 class="mt-10 mb-6 text-2xl font-bold text-center text-white"><%= t("panda_cms.admin.sessions.new.title") %></h2>
5
5
  </div>
6
-
7
6
  <% @providers.each do |provider| %>
8
7
  <div class="mt-4 text-center sm:mx-auto sm:w-full sm:max-w-sm">
9
- <%= form_tag "#{PandaCms.admin_path}/auth/#{provider}", method: "post", data: {turbo: false} do %>
8
+ <%= form_tag "#{PandaCms.root_path}/auth/#{provider}", method: "post", data: {turbo: false} do %>
10
9
  <input type="hidden" name="redirect_uri" value="<%= admin_login_callback_url(provider: provider) %>">
11
10
  <button type="submit" id="button-sign-in-<%= provider %>" class="inline-flex gap-x-2 items-center py-2.5 px-3.5 mx-auto mb-4 bg-white rounded-md border min-w-56 border-neutral-400">
12
11
  <i class="fa-brands fa-<%= provider %> text-xl mr-1"></i>