panda-cms 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +73 -0
  3. data/Rakefile +7 -0
  4. data/app/assets/builds/panda.cms.css +2808 -0
  5. data/app/assets/config/panda_cms_manifest.js +4 -0
  6. data/app/assets/stylesheets/panda/cms/application.tailwind.css +162 -0
  7. data/app/assets/stylesheets/panda/cms/editor.css +120 -0
  8. data/app/builders/panda/cms/form_builder.rb +234 -0
  9. data/app/components/panda/cms/admin/button_component.rb +70 -0
  10. data/app/components/panda/cms/admin/container_component.html.erb +13 -0
  11. data/app/components/panda/cms/admin/container_component.rb +13 -0
  12. data/app/components/panda/cms/admin/flash_message_component.html.erb +31 -0
  13. data/app/components/panda/cms/admin/flash_message_component.rb +47 -0
  14. data/app/components/panda/cms/admin/heading_component.rb +45 -0
  15. data/app/components/panda/cms/admin/panel_component.html.erb +7 -0
  16. data/app/components/panda/cms/admin/panel_component.rb +13 -0
  17. data/app/components/panda/cms/admin/slideover_component.html.erb +9 -0
  18. data/app/components/panda/cms/admin/slideover_component.rb +15 -0
  19. data/app/components/panda/cms/admin/statistics_component.html.erb +4 -0
  20. data/app/components/panda/cms/admin/statistics_component.rb +17 -0
  21. data/app/components/panda/cms/admin/tab_bar_component.html.erb +35 -0
  22. data/app/components/panda/cms/admin/tab_bar_component.rb +15 -0
  23. data/app/components/panda/cms/admin/table_component.html.erb +29 -0
  24. data/app/components/panda/cms/admin/table_component.rb +46 -0
  25. data/app/components/panda/cms/admin/tag_component.rb +35 -0
  26. data/app/components/panda/cms/admin/user_activity_component.html.erb +5 -0
  27. data/app/components/panda/cms/admin/user_activity_component.rb +33 -0
  28. data/app/components/panda/cms/admin/user_display_component.html.erb +17 -0
  29. data/app/components/panda/cms/admin/user_display_component.rb +21 -0
  30. data/app/components/panda/cms/code_component.rb +64 -0
  31. data/app/components/panda/cms/grid_component.html.erb +6 -0
  32. data/app/components/panda/cms/grid_component.rb +15 -0
  33. data/app/components/panda/cms/menu_component.html.erb +6 -0
  34. data/app/components/panda/cms/menu_component.rb +58 -0
  35. data/app/components/panda/cms/page_menu_component.html.erb +21 -0
  36. data/app/components/panda/cms/page_menu_component.rb +38 -0
  37. data/app/components/panda/cms/rich_text_component.html.erb +6 -0
  38. data/app/components/panda/cms/rich_text_component.rb +84 -0
  39. data/app/components/panda/cms/text_component.rb +72 -0
  40. data/app/constraints/panda/cms/admin_constraint.rb +18 -0
  41. data/app/controllers/panda/cms/admin/block_contents_controller.rb +52 -0
  42. data/app/controllers/panda/cms/admin/dashboard_controller.rb +20 -0
  43. data/app/controllers/panda/cms/admin/files_controller.rb +21 -0
  44. data/app/controllers/panda/cms/admin/forms_controller.rb +53 -0
  45. data/app/controllers/panda/cms/admin/menus_controller.rb +30 -0
  46. data/app/controllers/panda/cms/admin/pages_controller.rb +91 -0
  47. data/app/controllers/panda/cms/admin/posts_controller.rb +146 -0
  48. data/app/controllers/panda/cms/admin/sessions_controller.rb +94 -0
  49. data/app/controllers/panda/cms/admin/settings/bulk_editor_controller.rb +37 -0
  50. data/app/controllers/panda/cms/admin/settings_controller.rb +20 -0
  51. data/app/controllers/panda/cms/application_controller.rb +57 -0
  52. data/app/controllers/panda/cms/errors_controller.rb +33 -0
  53. data/app/controllers/panda/cms/form_submissions_controller.rb +23 -0
  54. data/app/controllers/panda/cms/pages_controller.rb +72 -0
  55. data/app/controllers/panda/cms/posts_controller.rb +13 -0
  56. data/app/helpers/panda/cms/admin/files_helper.rb +6 -0
  57. data/app/helpers/panda/cms/admin/pages_helper.rb +6 -0
  58. data/app/helpers/panda/cms/admin/posts_helper.rb +48 -0
  59. data/app/helpers/panda/cms/application_helper.rb +120 -0
  60. data/app/helpers/panda/cms/pages_helper.rb +6 -0
  61. data/app/helpers/panda/cms/theme_helper.rb +18 -0
  62. data/app/javascript/panda/cms/@editorjs--editorjs.js +2577 -0
  63. data/app/javascript/panda/cms/@hotwired--stimulus.js +4 -0
  64. data/app/javascript/panda/cms/@hotwired--turbo.js +160 -0
  65. data/app/javascript/panda/cms/@rails--actioncable--src.js +4 -0
  66. data/app/javascript/panda/cms/application_panda_cms.js +39 -0
  67. data/app/javascript/panda/cms/controllers/dashboard_controller.js +7 -0
  68. data/app/javascript/panda/cms/controllers/editor_form_controller.js +77 -0
  69. data/app/javascript/panda/cms/controllers/editor_iframe_controller.js +320 -0
  70. data/app/javascript/panda/cms/controllers/index.js +48 -0
  71. data/app/javascript/panda/cms/controllers/slug_controller.js +87 -0
  72. data/app/javascript/panda/cms/editor/css_extractor.js +80 -0
  73. data/app/javascript/panda/cms/editor/editor_js_config.js +177 -0
  74. data/app/javascript/panda/cms/editor/editor_js_initializer.js +285 -0
  75. data/app/javascript/panda/cms/editor/plain_text_editor.js +110 -0
  76. data/app/javascript/panda/cms/editor/resource_loader.js +115 -0
  77. data/app/javascript/panda/cms/tailwindcss-stimulus-components.js +4 -0
  78. data/app/jobs/panda/cms/application_job.rb +6 -0
  79. data/app/jobs/panda/cms/record_visit_job.rb +31 -0
  80. data/app/mailers/panda/cms/application_mailer.rb +8 -0
  81. data/app/mailers/panda/cms/form_mailer.rb +21 -0
  82. data/app/models/action_text/rich_text_version.rb +6 -0
  83. data/app/models/panda/cms/application_record.rb +7 -0
  84. data/app/models/panda/cms/block.rb +34 -0
  85. data/app/models/panda/cms/block_content.rb +18 -0
  86. data/app/models/panda/cms/block_content_version.rb +8 -0
  87. data/app/models/panda/cms/breadcrumb.rb +12 -0
  88. data/app/models/panda/cms/current.rb +17 -0
  89. data/app/models/panda/cms/form.rb +9 -0
  90. data/app/models/panda/cms/form_submission.rb +7 -0
  91. data/app/models/panda/cms/menu.rb +52 -0
  92. data/app/models/panda/cms/menu_item.rb +58 -0
  93. data/app/models/panda/cms/page.rb +96 -0
  94. data/app/models/panda/cms/page_version.rb +8 -0
  95. data/app/models/panda/cms/post.rb +60 -0
  96. data/app/models/panda/cms/post_version.rb +8 -0
  97. data/app/models/panda/cms/redirect.rb +11 -0
  98. data/app/models/panda/cms/template.rb +124 -0
  99. data/app/models/panda/cms/template_version.rb +8 -0
  100. data/app/models/panda/cms/user.rb +31 -0
  101. data/app/models/panda/cms/version.rb +8 -0
  102. data/app/models/panda/cms/visit.rb +9 -0
  103. data/app/services/panda/cms/html_to_editor_js_converter.rb +200 -0
  104. data/app/views/active_storage/blobs/blobs/_blob.html.erb +14 -0
  105. data/app/views/layouts/action_text/contents/_content.html.erb +3 -0
  106. data/app/views/layouts/panda/cms/application.html.erb +41 -0
  107. data/app/views/layouts/panda/cms/public.html.erb +3 -0
  108. data/app/views/panda/cms/admin/dashboard/show.html.erb +12 -0
  109. data/app/views/panda/cms/admin/files/index.html.erb +124 -0
  110. data/app/views/panda/cms/admin/files/show.html.erb +2 -0
  111. data/app/views/panda/cms/admin/forms/edit.html.erb +0 -0
  112. data/app/views/panda/cms/admin/forms/index.html.erb +13 -0
  113. data/app/views/panda/cms/admin/forms/new.html.erb +15 -0
  114. data/app/views/panda/cms/admin/forms/show.html.erb +35 -0
  115. data/app/views/panda/cms/admin/menus/index.html.erb +8 -0
  116. data/app/views/panda/cms/admin/pages/edit.html.erb +36 -0
  117. data/app/views/panda/cms/admin/pages/index.html.erb +22 -0
  118. data/app/views/panda/cms/admin/pages/new.html.erb +15 -0
  119. data/app/views/panda/cms/admin/pages/show.html.erb +1 -0
  120. data/app/views/panda/cms/admin/posts/_form.html.erb +29 -0
  121. data/app/views/panda/cms/admin/posts/edit.html.erb +6 -0
  122. data/app/views/panda/cms/admin/posts/index.html.erb +18 -0
  123. data/app/views/panda/cms/admin/posts/new.html.erb +6 -0
  124. data/app/views/panda/cms/admin/sessions/new.html.erb +17 -0
  125. data/app/views/panda/cms/admin/settings/bulk_editor/new.html.erb +68 -0
  126. data/app/views/panda/cms/admin/settings/index.html.erb +21 -0
  127. data/app/views/panda/cms/admin/settings/insta.html +4 -0
  128. data/app/views/panda/cms/admin/shared/_breadcrumbs.html.erb +28 -0
  129. data/app/views/panda/cms/admin/shared/_flash.html.erb +5 -0
  130. data/app/views/panda/cms/admin/shared/_sidebar.html.erb +41 -0
  131. data/app/views/panda/cms/form_mailer/notification_email.html.erb +11 -0
  132. data/app/views/panda/cms/shared/_editor.html.erb +0 -0
  133. data/app/views/panda/cms/shared/_favicons.html.erb +9 -0
  134. data/app/views/panda/cms/shared/_footer.html.erb +2 -0
  135. data/app/views/panda/cms/shared/_header.html.erb +15 -0
  136. data/app/views/panda/cms/shared/_importmap.html.erb +33 -0
  137. data/config/importmap.rb +13 -0
  138. data/config/initializers/inflections.rb +3 -0
  139. data/config/initializers/panda/cms/form_errors.rb +38 -0
  140. data/config/initializers/panda/cms/healthcheck_log_silencer.rb +11 -0
  141. data/config/initializers/panda/cms/paper_trail.rb +7 -0
  142. data/config/initializers/panda/cms.rb +10 -0
  143. data/config/initializers/zeitwork.rb +3 -0
  144. data/config/locales/en.yml +49 -0
  145. data/config/puma/test.rb +9 -0
  146. data/config/routes.rb +48 -0
  147. data/config/tailwind.config.js +37 -0
  148. data/db/migrate/20240205223709_create_panda_cms_pages.rb +9 -0
  149. data/db/migrate/20240219213327_create_panda_cms_page_versions.rb +14 -0
  150. data/db/migrate/20240303002805_create_panda_cms_templates.rb +11 -0
  151. data/db/migrate/20240303003434_create_panda_cms_template_versions.rb +14 -0
  152. data/db/migrate/20240303022441_create_panda_cms_blocks.rb +13 -0
  153. data/db/migrate/20240303024256_create_panda_cms_block_contents.rb +10 -0
  154. data/db/migrate/20240303024746_create_panda_cms_block_content_versions.rb +14 -0
  155. data/db/migrate/20240303233238_add_panda_cms_menu_table.rb +10 -0
  156. data/db/migrate/20240303234724_add_panda_cms_menu_item_table.rb +12 -0
  157. data/db/migrate/20240304134343_add_parent_id_to_panda_cms_pages.rb +5 -0
  158. data/db/migrate/20240305000000_convert_html_content_to_editor_js.rb +82 -0
  159. data/db/migrate/20240315125411_add_status_to_panda_cms_pages.rb +9 -0
  160. data/db/migrate/20240315125421_add_nested_sets_to_panda_cms_pages.rb +16 -0
  161. data/db/migrate/20240316212822_add_kind_to_panda_cms_menus.rb +6 -0
  162. data/db/migrate/20240316221425_add_start_page_to_panda_cms_menus.rb +5 -0
  163. data/db/migrate/20240316230706_add_nested_to_panda_cms_menu_items.rb +24 -0
  164. data/db/migrate/20240317010532_create_panda_cms_users.rb +12 -0
  165. data/db/migrate/20240317161534_add_max_uses_to_panda_cms_template.rb +7 -0
  166. data/db/migrate/20240317163053_reset_counter_cache_on_panda_cms_template.rb +5 -0
  167. data/db/migrate/20240317214827_create_panda_cms_redirects.rb +14 -0
  168. data/db/migrate/20240317230622_create_panda_cms_visits.rb +13 -0
  169. data/db/migrate/20240324205703_create_active_storage_tables.active_storage.rb +58 -0
  170. data/db/migrate/20240408084718_default_panda_cms_users_admin_to_false.rb +5 -0
  171. data/db/migrate/20240701225422_add_service_name_to_active_storage_blobs.active_storage.rb +22 -0
  172. data/db/migrate/20240701225423_create_active_storage_variant_records.active_storage.rb +28 -0
  173. data/db/migrate/20240701225424_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb +8 -0
  174. data/db/migrate/20240804235210_create_panda_cms_forms.rb +11 -0
  175. data/db/migrate/20240805013612_create_panda_cms_form_submissions.rb +9 -0
  176. data/db/migrate/20240805121123_create_panda_cms_posts.rb +27 -0
  177. data/db/migrate/20240805123104_create_panda_cms_post_versions.rb +14 -0
  178. data/db/migrate/20240806112735_fix_panda_cms_visits_column_names.rb +13 -0
  179. data/db/migrate/20240806204412_add_completion_path_to_panda_cms_forms.rb +5 -0
  180. data/db/migrate/20240820081917_change_form_submissions_to_submission_count.rb +5 -0
  181. data/db/migrate/20240904200605_create_action_text_tables.action_text.rb +24 -0
  182. data/db/migrate/20240923234535_add_depth_to_panda_cms_menus.rb +11 -0
  183. data/db/migrate/20241031205109_add_cached_content_to_panda_cms_block_contents.rb +5 -0
  184. data/db/migrate/20241119214548_convert_post_content_to_editor_js.rb +35 -0
  185. data/db/migrate/20241119214549_remove_action_text_from_posts.rb +9 -0
  186. data/db/migrate/20241120000419_remove_post_tag_references.rb +19 -0
  187. data/db/migrate/20241120110943_add_editor_js_to_posts.rb +27 -0
  188. data/db/migrate/20241120113859_add_cached_content_to_panda_cms_posts.rb +5 -0
  189. data/db/migrate/20241123234140_remove_post_tag_id_from_posts.rb +5 -0
  190. data/db/migrate/migrate +1 -0
  191. data/db/seeds.rb +5 -0
  192. data/lib/generators/panda/cms/install_generator.rb +29 -0
  193. data/lib/panda/cms/bulk_editor.rb +171 -0
  194. data/lib/panda/cms/demo_site_generator.rb +67 -0
  195. data/lib/panda/cms/editor_js/blocks/alert.rb +34 -0
  196. data/lib/panda/cms/editor_js/blocks/base.rb +33 -0
  197. data/lib/panda/cms/editor_js/blocks/header.rb +15 -0
  198. data/lib/panda/cms/editor_js/blocks/image.rb +36 -0
  199. data/lib/panda/cms/editor_js/blocks/list.rb +32 -0
  200. data/lib/panda/cms/editor_js/blocks/paragraph.rb +15 -0
  201. data/lib/panda/cms/editor_js/blocks/quote.rb +41 -0
  202. data/lib/panda/cms/editor_js/blocks/table.rb +50 -0
  203. data/lib/panda/cms/editor_js/renderer.rb +124 -0
  204. data/lib/panda/cms/editor_js.rb +16 -0
  205. data/lib/panda/cms/editor_js_content.rb +21 -0
  206. data/lib/panda/cms/engine.rb +257 -0
  207. data/lib/panda/cms/exceptions_app.rb +26 -0
  208. data/lib/panda/cms/railtie.rb +11 -0
  209. data/lib/panda/cms/slug.rb +24 -0
  210. data/lib/panda/cms.rb +0 -0
  211. data/lib/panda-cms/version.rb +5 -0
  212. data/lib/panda-cms.rb +81 -0
  213. data/lib/tasks/panda_cms.rake +54 -0
  214. data/lib/templates/erb/scaffold/_form.html.erb.tt +43 -0
  215. data/lib/templates/erb/scaffold/edit.html.erb.tt +8 -0
  216. data/lib/templates/erb/scaffold/index.html.erb.tt +14 -0
  217. data/lib/templates/erb/scaffold/new.html.erb.tt +7 -0
  218. data/lib/templates/erb/scaffold/partial.html.erb.tt +22 -0
  219. data/lib/templates/erb/scaffold/show.html.erb.tt +15 -0
  220. data/public/panda-cms-assets/favicons/android-chrome-192x192.png +0 -0
  221. data/public/panda-cms-assets/favicons/android-chrome-512x512.png +0 -0
  222. data/public/panda-cms-assets/favicons/apple-touch-icon.png +0 -0
  223. data/public/panda-cms-assets/favicons/browserconfig.xml +9 -0
  224. data/public/panda-cms-assets/favicons/favicon-16x16.png +0 -0
  225. data/public/panda-cms-assets/favicons/favicon-32x32.png +0 -0
  226. data/public/panda-cms-assets/favicons/favicon.ico +0 -0
  227. data/public/panda-cms-assets/favicons/mstile-150x150.png +0 -0
  228. data/public/panda-cms-assets/favicons/safari-pinned-tab.svg +61 -0
  229. data/public/panda-cms-assets/favicons/site.webmanifest +14 -0
  230. data/public/panda-cms-assets/panda-logo-screenprint.png +0 -0
  231. data/public/panda-cms-assets/panda-nav.png +0 -0
  232. data/public/panda-cms-assets/rich_text_editor.css +568 -0
  233. metadata +654 -0
@@ -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
+
@@ -0,0 +1,6 @@
1
+ module Panda
2
+ module CMS
3
+ class ApplicationJob < ActiveJob::Base
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,31 @@
1
+ module Panda
2
+ module CMS
3
+ class RecordVisitJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ def perform(
7
+ url: nil,
8
+ user_agent: nil,
9
+ referrer: nil,
10
+ ip_address: nil,
11
+ page_id: nil,
12
+ current_user_id: nil,
13
+ params: [],
14
+ visited_at: nil,
15
+ redirect_id: nil
16
+ )
17
+ Panda::CMS::Visit.create!(
18
+ url: url,
19
+ user_agent: user_agent,
20
+ referrer: referrer,
21
+ ip_address: ip_address,
22
+ page_id: page_id,
23
+ redirect_id: redirect_id,
24
+ user_id: current_user_id,
25
+ params: params,
26
+ visited_at: visited_at
27
+ )
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,8 @@
1
+ module Panda
2
+ module CMS
3
+ class ApplicationMailer < ActionMailer::Base
4
+ default from: "noreply@pandacms.io"
5
+ layout "mailer"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,21 @@
1
+ module Panda
2
+ module CMS
3
+ class FormMailer < Panda::CMS::ApplicationMailer
4
+ def notification_email(form:, form_submission:)
5
+ # TODO: Handle fields named just "name", and "email" better
6
+ @submission_data = form_submission.data
7
+ @sender_name = @submission_data["first_name"].to_s + " " + @submission_data["last_name"].to_s
8
+ @sender_email = @submission_data["email"].to_s
9
+
10
+ mail(
11
+ subject: "#{form.name}: #{form_submission.created_at.strftime("%d %b %Y %H:%M")}",
12
+ to: email_address_with_name("james@otaina.co.uk", "James Inman"),
13
+ from: email_address_with_name("noreply@pandacms.io", "Panda CMS"),
14
+ reply_to: email_address_with_name(@sender_email, @sender_name),
15
+ track_opens: "true",
16
+ message_stream: "outbound"
17
+ )
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,6 @@
1
+ module ActionText
2
+ class RichTextVersion < ::Panda::CMS::Version
3
+ self.table_name = :action_text_rich_text_versions
4
+ self.sequence_name = :action_text_rich_text_versions_id_seq
5
+ end
6
+ end
@@ -0,0 +1,7 @@
1
+ module Panda
2
+ module CMS
3
+ class ApplicationRecord < ActiveRecord::Base
4
+ self.abstract_class = true
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,34 @@
1
+ module Panda
2
+ module CMS
3
+ class Block < ApplicationRecord
4
+ self.table_name = "panda_cms_blocks"
5
+
6
+ belongs_to :template, foreign_key: :panda_cms_template_id, class_name: "Panda::CMS::Template", inverse_of: :blocks, optional: true
7
+ has_many :block_contents, foreign_key: :panda_cms_block_id, class_name: "Panda::CMS::BlockContent", inverse_of: :block
8
+
9
+ validates :kind, presence: true
10
+ validates :name, presence: true
11
+ validates :key, presence: true, uniqueness: {scope: :panda_cms_template_id, case_sensitive: false}
12
+
13
+ # Validation for presence on template intentionally skipped to allow global elements
14
+
15
+ # NB: Commented out values are not yet implemented
16
+ enum :kind, {
17
+ plain_text: "plain_text",
18
+ rich_text: "rich_text",
19
+ iframe: "iframe",
20
+ list: "list",
21
+ code: "code"
22
+ # image: "image",
23
+ # video: "video",
24
+ # audio: "audio",
25
+ # file: "file",
26
+ # iframe: "iframe",
27
+ # quote: "quote",
28
+ # list: "list"
29
+ # table: "table",
30
+ # form: "form"
31
+ }
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,18 @@
1
+ module Panda
2
+ module CMS
3
+ class BlockContent < ApplicationRecord
4
+ include EditorJsContent
5
+
6
+ self.table_name = "panda_cms_block_contents"
7
+
8
+ has_paper_trail versions: {
9
+ class_name: "Panda::CMS::BlockContentVersion"
10
+ }
11
+
12
+ belongs_to :page, foreign_key: :panda_cms_page_id, class_name: "Panda::CMS::Page", inverse_of: :block_contents, optional: true, touch: true
13
+ belongs_to :block, foreign_key: :panda_cms_block_id, class_name: "Panda::CMS::Block", inverse_of: :block_contents, optional: false
14
+
15
+ validates :block, presence: true, uniqueness: {scope: :page}
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,8 @@
1
+ module Panda
2
+ module CMS
3
+ class BlockContentVersion < Version
4
+ self.table_name = :panda_cms_block_content_versions
5
+ self.sequence_name = :panda_cms_block_content_versions_id_seq
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,12 @@
1
+ module Panda
2
+ module CMS
3
+ class Breadcrumb
4
+ attr_reader :name, :path
5
+
6
+ def initialize(name, path)
7
+ @name = name
8
+ @path = path
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,17 @@
1
+ module Panda
2
+ module CMS
3
+ class Current < ActiveSupport::CurrentAttributes
4
+ attribute :root, :page
5
+ attribute :user
6
+ attribute :request_id, :user_agent, :ip_address
7
+
8
+ # resets { Time.zone = nil }
9
+
10
+ # def user=(user)
11
+ # super
12
+ # self.account = user.account
13
+ # Time.zone = user.time_zone
14
+ # end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,9 @@
1
+ module Panda
2
+ module CMS
3
+ class Form < ApplicationRecord
4
+ self.table_name = "panda_cms_forms"
5
+
6
+ has_many :form_submissions, class_name: "Panda::CMS::FormSubmission"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,7 @@
1
+ module Panda
2
+ module CMS
3
+ class FormSubmission < ApplicationRecord
4
+ self.table_name = "panda_cms_form_submissions"
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,52 @@
1
+ module Panda
2
+ module CMS
3
+ class Menu < ApplicationRecord
4
+ self.table_name = "panda_cms_menus"
5
+
6
+ after_save :generate_auto_menu_items, if: -> { kind == "auto" }
7
+
8
+ has_many :menu_items, -> { order(lft: :asc) }, foreign_key: :panda_cms_menu_id, class_name: "Panda::CMS::MenuItem", inverse_of: :menu
9
+ belongs_to :start_page, class_name: "Panda::CMS::Page", foreign_key: "start_page_id", inverse_of: :page_menu, optional: true
10
+
11
+ accepts_nested_attributes_for :menu_items, reject_if: :all_blank, allow_destroy: true
12
+
13
+ validates :name, presence: true, uniqueness: {case_sensitive: false}
14
+ validates :kind, presence: true, inclusion: {in: ["static", "auto"]}
15
+ validate :validate_start_page
16
+
17
+ def generate_auto_menu_items
18
+ return false if kind != "auto"
19
+
20
+ # NB: Transactions are not distributed across database connections
21
+ transaction do
22
+ menu_items.destroy_all
23
+ menu_item_root = menu_items.create(text: start_page.title, panda_cms_page_id: start_page.id)
24
+ generate_menu_items(parent_menu_item: menu_item_root, parent_page: start_page)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def generate_menu_items(parent_menu_item:, parent_page:)
31
+ parent_page.children.where(status: [:active]).each do |page|
32
+ menu_item = menu_items.create(text: page.title, panda_cms_page_id: page.id, parent: parent_menu_item)
33
+ if page.children
34
+ generate_menu_items(parent_menu_item: menu_item, parent_page: page)
35
+ end
36
+ end
37
+ end
38
+
39
+ #
40
+ # Validate that the start page is set if the menu is of kind auto
41
+ #
42
+ # @return nil
43
+ # @visibility private
44
+ #
45
+ def validate_start_page
46
+ if kind == "auto" && start_page.nil?
47
+ errors.add(:start_page, "can't be blank")
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,58 @@
1
+ require "awesome_nested_set"
2
+
3
+ module Panda
4
+ module CMS
5
+ class MenuItem < ApplicationRecord
6
+ acts_as_nested_set scope: [:panda_cms_menu_id], counter_cache: :children_count
7
+
8
+ self.implicit_order_column = "lft"
9
+ self.table_name = "panda_cms_menu_items"
10
+
11
+ belongs_to :menu, foreign_key: :panda_cms_menu_id, class_name: "Panda::CMS::Menu", inverse_of: :menu_items, touch: true
12
+ belongs_to :page, foreign_key: :panda_cms_page_id, class_name: "Panda::CMS::Page", inverse_of: :menu_items, optional: true
13
+
14
+ validates :text, presence: true, uniqueness: {scope: :panda_cms_menu_id, case_sensitive: false}
15
+ validates :page, presence: true, unless: -> { external_url.present? }
16
+ validates :external_url, presence: true, unless: -> { page.present? }
17
+
18
+ validate :validate_is_actual_link
19
+
20
+ #
21
+ # Returns the resolved link for the menu item.
22
+ #
23
+ # If the menu item is associated with a page, it returns the path of the page.
24
+ # If the menu item is associated with an external URL, it returns the external URL.
25
+ #
26
+ # @return [String] Resolved link
27
+ # @visibility public
28
+ def resolved_link
29
+ if page
30
+ page.path
31
+ elsif external_url
32
+ external_url
33
+ else
34
+ ""
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ #
41
+ # Validate that the link is an actual link or a page
42
+ #
43
+ # @return nil
44
+ # @visibility private
45
+ def validate_is_actual_link
46
+ if page.nil? && external_url.nil?
47
+ errors.add(:page, "must be a valid page or external link, neither are set")
48
+ errors.add(:external_url, "must be a valid page or external link, neither are set")
49
+ end
50
+
51
+ if !page.nil? && !external_url.nil?
52
+ errors.add(:page, "must be a valid page or external link, both are set")
53
+ errors.add(:external_url, "must be a valid page or external link, both are set")
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,96 @@
1
+ require "awesome_nested_set"
2
+
3
+ module Panda
4
+ module CMS
5
+ class Page < ApplicationRecord
6
+ acts_as_nested_set counter_cache: :children_count
7
+ self.table_name = "panda_cms_pages"
8
+ self.implicit_order_column = "lft"
9
+
10
+ has_paper_trail versions: {
11
+ class_name: "Panda::CMS::PageVersion"
12
+ }
13
+
14
+ after_save :after_save
15
+
16
+ belongs_to :template, foreign_key: :panda_cms_template_id, class_name: "Panda::CMS::Template", inverse_of: :pages, optional: false, counter_cache: :pages_count
17
+ has_many :blocks, through: :template
18
+ has_many :block_contents, foreign_key: :panda_cms_page_id, class_name: "Panda::CMS::BlockContent", inverse_of: :page
19
+ has_many :menu_items, foreign_key: :panda_cms_page_id, class_name: "Panda::CMS::MenuItem", inverse_of: :page
20
+ has_many :menus, through: :menu_items
21
+ has_many :menus_of_parent, through: :parent, source: :menus
22
+ has_one :page_menu, foreign_key: :start_page_id, class_name: "Panda::CMS::Menu"
23
+
24
+ validates :title, presence: true
25
+
26
+ validates :path,
27
+ presence: true,
28
+ uniqueness: true,
29
+ format: {with: /\A\/.*\z/, message: "must start with a forward slash"}
30
+
31
+ validates :parent,
32
+ presence: true,
33
+ unless: -> { path == "/" }
34
+
35
+ validates :panda_cms_template_id,
36
+ presence: true
37
+
38
+ scope :ordered, -> { order(:lft) }
39
+
40
+ enum :status, {
41
+ active: "active",
42
+ draft: "draft",
43
+ hidden: "hidden",
44
+ archived: "archived"
45
+ }
46
+
47
+ #
48
+ # Update any menus which include this page or its parent as a menu item
49
+ #
50
+ # @return nil
51
+ # @visibility public
52
+ #
53
+ def update_auto_menus
54
+ menus.find_each(&:generate_auto_menu_items)
55
+ menus_of_parent.find_each(&:generate_auto_menu_items)
56
+ end
57
+
58
+ private
59
+
60
+ #
61
+ # After save callbacks
62
+ #
63
+ # @return nil
64
+ # @visibility private
65
+ #
66
+ def after_save
67
+ generate_content_blocks
68
+ update_existing_menu_items
69
+ update_auto_menus
70
+ end
71
+
72
+ def generate_content_blocks
73
+ template_block_ids = template.blocks.ids
74
+ page_existing_block_ids = block_contents.map { |bc| bc.block.id }
75
+ required_block_ids = template_block_ids - page_existing_block_ids
76
+
77
+ if required_block_ids.count > 0
78
+ required_block_ids.each do |block_id|
79
+ Panda::CMS::BlockContent.find_or_create_by!(page: self, panda_cms_block_id: block_id, content: "")
80
+ end
81
+ end
82
+ end
83
+
84
+ #
85
+ # Update text of existing menu items if the title differs
86
+ #
87
+ # @return nil
88
+ # @todo Only run this if the page title has changed
89
+ # @visibility private
90
+ #
91
+ def update_existing_menu_items
92
+ menu_items.where.not(text: title).update_all(text: title)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,8 @@
1
+ module Panda
2
+ module CMS
3
+ class PageVersion < Version
4
+ self.table_name = :panda_cms_page_versions
5
+ self.sequence_name = :panda_cms_page_versions_id_seq
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,60 @@
1
+ require "awesome_nested_set"
2
+
3
+ module Panda
4
+ module CMS
5
+ class Post < ApplicationRecord
6
+ include ::Panda::CMS::EditorJsContent
7
+
8
+ self.table_name = "panda_cms_posts"
9
+
10
+ has_paper_trail versions: {
11
+ class_name: "Panda::CMS::PostVersion"
12
+ }
13
+
14
+ belongs_to :user, class_name: "Panda::CMS::User"
15
+
16
+ validates :title, presence: true
17
+ validates :slug,
18
+ presence: true,
19
+ uniqueness: true,
20
+ format: {
21
+ with: /\A\/[a-z0-9-]+\z/,
22
+ message: "must start with a forward slash and contain only lowercase letters, numbers, and hyphens"
23
+ }
24
+
25
+ scope :ordered, -> { order(published_at: :desc) }
26
+ scope :with_user, -> { includes(:user) }
27
+
28
+ enum :status, {
29
+ active: "active",
30
+ draft: "draft",
31
+ hidden: "hidden",
32
+ archived: "archived"
33
+ }
34
+
35
+ def to_param
36
+ slug.delete_prefix("/")
37
+ end
38
+
39
+ def admin_param
40
+ id
41
+ end
42
+
43
+ def excerpt(length = 100, squish: true)
44
+ return "" if content.blank?
45
+
46
+ text = if content.is_a?(Hash) && content["blocks"]
47
+ content["blocks"]
48
+ .select { |block| block["type"] == "paragraph" }
49
+ .map { |block| block["data"]["text"] }
50
+ .join(" ")
51
+ else
52
+ content.to_s
53
+ end
54
+
55
+ text = text.squish if squish
56
+ text.truncate(length).html_safe
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,8 @@
1
+ module Panda
2
+ module CMS
3
+ class PostVersion < Version
4
+ self.table_name = :panda_cms_post_versions
5
+ self.sequence_name = :panda_cms_post_versions_id_seq
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,11 @@
1
+ module Panda
2
+ module CMS
3
+ class Redirect < ApplicationRecord
4
+ belongs_to :origin_page, class_name: "Panda::CMS::Page", foreign_key: :origin_panda_cms_page_id
5
+ belongs_to :destination_page, class_name: "Panda::CMS::Page", foreign_key: :destination_panda_cms_page_id
6
+
7
+ validates :status_code, presence: true
8
+ validates :visits, presence: true
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,124 @@
1
+ module Panda
2
+ module CMS
3
+ # Represents a template in the Panda CMS application.
4
+ class Template < ApplicationRecord
5
+ self.table_name = "panda_cms_templates"
6
+
7
+ # Enables versioning for the Template model using the `has_paper_trail` gem.
8
+ has_paper_trail versions: {
9
+ class_name: "Panda::CMS::TemplateVersion"
10
+ }
11
+
12
+ # Associations
13
+ has_many :pages, class_name: "Panda::CMS::Page", dependent: :restrict_with_error, inverse_of: :template, foreign_key: :panda_cms_template_id
14
+ has_many :blocks, class_name: "Panda::CMS::Block", dependent: :restrict_with_error, inverse_of: :template, foreign_key: :panda_cms_template_id
15
+ has_many :block_contents, through: :blocks
16
+
17
+ # Validations
18
+ validates :name, presence: true, uniqueness: true
19
+
20
+ validates :file_path,
21
+ presence: true,
22
+ uniqueness: true,
23
+ format: {with: /\Alayouts\/.*\z/, message: "must be a valid layout file path"}
24
+
25
+ validate :validate_template_file_exists
26
+
27
+ # Scopes
28
+ scope :available, -> {
29
+ where("max_uses IS NULL OR (pages_count IS NOT NULL AND pages_count < max_uses)")
30
+ }
31
+
32
+ def self.default
33
+ find_by(file_path: "layouts/page")
34
+ end
35
+
36
+ # Generate missing blocks for all templates
37
+ # @return [void]
38
+ def self.generate_missing_blocks
39
+ # Loop through all templates in app/views/layouts/*.html.erb
40
+ Dir.glob("app/views/layouts/*.html.erb").each do |file|
41
+ # TODO: Delete all blocks which aren't in use by a template?
42
+
43
+ File.open(file).each_line do |line|
44
+ # Matches:
45
+ # Panda::CMS::RichTextComponent.new(key: :value)
46
+ # Panda::CMS::RichTextComponent.new key: :value, key: value
47
+ line.match(/Panda::CMS::([a-zA-Z]+)Component\.new[ \(]+([^\)]+)[\)]*/) do |match|
48
+ # Extract the hash values
49
+ template_path = file.gsub("app/views/", "").gsub(".html.erb", "")
50
+ template_name = template_path.gsub("layouts/", "").titleize
51
+
52
+ # Create the template if it doesn't exist
53
+ template = Panda::CMS::Template.find_or_create_by!(file_path: template_path) do |template|
54
+ template.name = template_name
55
+ end
56
+
57
+ next if match[1] == "PageMenu" # Skip PageMenu blocks
58
+ next if match[1] == "Menu" # Skip Menu blocks
59
+
60
+ # Previously used match[1].underscore but this supports more complex database
61
+ # operations, and is more secure as it'll force the usage of a class
62
+ block_kind = "Panda::CMS::#{match[1]}Component".constantize::KIND
63
+
64
+ match[2].split(",").map do |keyvar|
65
+ key, value = keyvar.split(":", 2)
66
+ next if key != "key"
67
+
68
+ block_name = value.to_s.strip.tr(":", "")
69
+ # Create the block if it doesn't exist
70
+ # TODO: +/- the output if it's created or removed
71
+ begin
72
+ block = Panda::CMS::Block.find_or_create_by!(template: template, kind: block_kind, key: block_name) do |block|
73
+ block.name = block_name.titleize
74
+ end
75
+ rescue ActiveRecord::RecordInvalid => e
76
+ raise "Error creating block '#{block_name}' on template '#{template_name}': #{e.message}"
77
+ end
78
+
79
+ # For the given block, create the block_content for each page using the template
80
+ template.pages.each do |page|
81
+ Panda::CMS::BlockContent.find_or_create_by!(block: block, page: page) do |block_content|
82
+ block_content.content = {}
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ # Custom validation method to check if the file_path is a valid layout file path
94
+ # NB: Currently only supports .html.erb templates, may want to expand in future?
95
+ # @return [void]
96
+ def validate_template_file_exists
97
+ # Remove any directory traversal attempts from the file_path
98
+ safe_file_path = file_path.to_s.gsub("../", "")
99
+ # Check if the file_path is an ERB template that exists in app/views
100
+ template_path = Rails.root.join("app", "views", "#{safe_file_path}.html.erb")
101
+ # NB: file? checks for files and excludes directories (unlike exist?)
102
+ errors.add(:file_path, "must be an existing layout file path") unless File.file?(template_path)
103
+ end
104
+
105
+ # Import templates from the filesystem into the database
106
+ # @return [void]
107
+ def self.load_from_filesystem
108
+ Rails.root.glob("app/views/layouts/**/*.html.erb").each do |file|
109
+ # Extract the file path from the Rails root
110
+ file_path = file.to_s.sub("#{Rails.root}/app/views/", "").sub(".html.erb", "")
111
+
112
+ next if file_path == "layouts/application" || file_path == "layouts/mailer"
113
+
114
+ # Find or create the template based on the file path
115
+ find_or_create_by(file_path: file_path) do |t|
116
+ t.name = file_path.sub("layouts/", "").titleize
117
+ end
118
+ end
119
+ end
120
+
121
+ private_class_method :load_from_filesystem
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,8 @@
1
+ module Panda
2
+ module CMS
3
+ class TemplateVersion < Version
4
+ self.table_name = :panda_cms_template_versions
5
+ self.sequence_name = :panda_cms_template_versions_id_seq
6
+ end
7
+ end
8
+ end