postnhost 0.0.0 → 0.1.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 (497) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/{LICENSE.txt → LICENSE} +6 -6
  4. data/README.md +341 -15
  5. data/Rakefile +38 -6
  6. data/app/assets/builds/postnhost/application.css +2 -0
  7. data/app/assets/builds/postnhost/application.js +50190 -0
  8. data/app/assets/config/postnhost_manifest.js +2 -0
  9. data/app/assets/images/postnhost/demo.webp +0 -0
  10. data/app/assets/images/postnhost/icons/alert-circle.svg +3 -0
  11. data/app/assets/images/postnhost/icons/arrow-left-long.svg +3 -0
  12. data/app/assets/images/postnhost/icons/arrow-left.svg +3 -0
  13. data/app/assets/images/postnhost/icons/arrow-right-end-on-rectangle.svg +3 -0
  14. data/app/assets/images/postnhost/icons/arrow-right-long.svg +3 -0
  15. data/app/assets/images/postnhost/icons/arrow-right.svg +3 -0
  16. data/app/assets/images/postnhost/icons/bars.svg +3 -0
  17. data/app/assets/images/postnhost/icons/bluesky.svg +3 -0
  18. data/app/assets/images/postnhost/icons/check-circle.svg +3 -0
  19. data/app/assets/images/postnhost/icons/chevron-down-small.svg +3 -0
  20. data/app/assets/images/postnhost/icons/chevron-down.svg +3 -0
  21. data/app/assets/images/postnhost/icons/chevron-left.svg +3 -0
  22. data/app/assets/images/postnhost/icons/chevron-right.svg +3 -0
  23. data/app/assets/images/postnhost/icons/clock.svg +3 -0
  24. data/app/assets/images/postnhost/icons/close.svg +3 -0
  25. data/app/assets/images/postnhost/icons/code-bracket.svg +3 -0
  26. data/app/assets/images/postnhost/icons/document-text.svg +3 -0
  27. data/app/assets/images/postnhost/icons/drag-handle.svg +8 -0
  28. data/app/assets/images/postnhost/icons/edit.svg +3 -0
  29. data/app/assets/images/postnhost/icons/external-link.svg +3 -0
  30. data/app/assets/images/postnhost/icons/eye.svg +4 -0
  31. data/app/assets/images/postnhost/icons/facebook.svg +3 -0
  32. data/app/assets/images/postnhost/icons/folder.svg +3 -0
  33. data/app/assets/images/postnhost/icons/image.svg +3 -0
  34. data/app/assets/images/postnhost/icons/info-circle.svg +3 -0
  35. data/app/assets/images/postnhost/icons/instagram.svg +3 -0
  36. data/app/assets/images/postnhost/icons/language.svg +3 -0
  37. data/app/assets/images/postnhost/icons/layers.svg +3 -0
  38. data/app/assets/images/postnhost/icons/lightning.svg +3 -0
  39. data/app/assets/images/postnhost/icons/linkedin.svg +3 -0
  40. data/app/assets/images/postnhost/icons/logout.svg +3 -0
  41. data/app/assets/images/postnhost/icons/magnifying-glass.svg +3 -0
  42. data/app/assets/images/postnhost/icons/mastodon.svg +3 -0
  43. data/app/assets/images/postnhost/icons/menu.svg +3 -0
  44. data/app/assets/images/postnhost/icons/plus.svg +3 -0
  45. data/app/assets/images/postnhost/icons/schedule.svg +3 -0
  46. data/app/assets/images/postnhost/icons/settings.svg +4 -0
  47. data/app/assets/images/postnhost/icons/sparkles.svg +3 -0
  48. data/app/assets/images/postnhost/icons/spinner.svg +4 -0
  49. data/app/assets/images/postnhost/icons/threads.svg +3 -0
  50. data/app/assets/images/postnhost/icons/tiktok.svg +3 -0
  51. data/app/assets/images/postnhost/icons/tiptap/align-center.svg +3 -0
  52. data/app/assets/images/postnhost/icons/tiptap/align-left.svg +3 -0
  53. data/app/assets/images/postnhost/icons/tiptap/align-right.svg +3 -0
  54. data/app/assets/images/postnhost/icons/tiptap/apply-link.svg +3 -0
  55. data/app/assets/images/postnhost/icons/tiptap/blockquote.svg +3 -0
  56. data/app/assets/images/postnhost/icons/tiptap/bold.svg +3 -0
  57. data/app/assets/images/postnhost/icons/tiptap/bullet-list.svg +3 -0
  58. data/app/assets/images/postnhost/icons/tiptap/code-block.svg +3 -0
  59. data/app/assets/images/postnhost/icons/tiptap/code.svg +3 -0
  60. data/app/assets/images/postnhost/icons/tiptap/heading-2.svg +3 -0
  61. data/app/assets/images/postnhost/icons/tiptap/heading-3.svg +3 -0
  62. data/app/assets/images/postnhost/icons/tiptap/image-add.svg +3 -0
  63. data/app/assets/images/postnhost/icons/tiptap/italic.svg +3 -0
  64. data/app/assets/images/postnhost/icons/tiptap/link.svg +3 -0
  65. data/app/assets/images/postnhost/icons/tiptap/ordered-list.svg +3 -0
  66. data/app/assets/images/postnhost/icons/tiptap/redo.svg +3 -0
  67. data/app/assets/images/postnhost/icons/tiptap/remove-link.svg +3 -0
  68. data/app/assets/images/postnhost/icons/tiptap/strike.svg +3 -0
  69. data/app/assets/images/postnhost/icons/tiptap/table.svg +3 -0
  70. data/app/assets/images/postnhost/icons/tiptap/underline.svg +3 -0
  71. data/app/assets/images/postnhost/icons/tiptap/undo.svg +3 -0
  72. data/app/assets/images/postnhost/icons/tiptap/youtube.svg +4 -0
  73. data/app/assets/images/postnhost/icons/translate.svg +3 -0
  74. data/app/assets/images/postnhost/icons/trash.svg +3 -0
  75. data/app/assets/images/postnhost/icons/user.svg +4 -0
  76. data/app/assets/images/postnhost/icons/users.svg +3 -0
  77. data/app/assets/images/postnhost/icons/warning.svg +3 -0
  78. data/app/assets/images/postnhost/icons/x.svg +3 -0
  79. data/app/assets/images/postnhost/icons/youtube.svg +3 -0
  80. data/app/assets/images/postnhost/logo.webp +0 -0
  81. data/app/assets/images/postnhost/og-image.webp +0 -0
  82. data/app/assets/images/postnhost/sample-docs/08-how-to-control-suggested-articles-and-top-picks-01-discovery-controls.webp +0 -0
  83. data/app/assets/images/postnhost/sample-docs/09-all-about-authors-in-postnhost-01-author-profile.webp +0 -0
  84. data/app/assets/images/postnhost/sample-docs/09-all-about-authors-in-postnhost-02-multiple-authors.webp +0 -0
  85. data/app/assets/images/postnhost/sample-docs/11-how-to-schedule-an-article-for-publication-01-timezone.webp +0 -0
  86. data/app/assets/images/postnhost/sample-docs/11-how-to-schedule-an-article-for-publication-02-scheduled-article.webp +0 -0
  87. data/app/assets/images/postnhost/sample-docs/12-how-article-version-history-and-restores-work-01-version-history.webp +0 -0
  88. data/app/assets/images/postnhost/sample-docs/13-how-to-add-and-manage-languages-01-language-management.webp +0 -0
  89. data/app/assets/images/postnhost/sample-docs/14-how-to-translate-articles-pages-and-categories-01-translation-options.webp +0 -0
  90. data/app/assets/images/postnhost/sample-docs/14-how-to-translate-articles-pages-and-categories-02-ai-language-selection.webp +0 -0
  91. data/app/assets/images/postnhost/sample-docs/16-how-to-add-navigation-links-01-navigation-builder.webp +0 -0
  92. data/app/assets/images/postnhost/sample-docs/16-how-to-add-navigation-links-02-external-link-settings.webp +0 -0
  93. data/app/assets/images/postnhost/sample-docs/17-how-to-add-a-logo-and-default-og-image-01-assets-settings.webp +0 -0
  94. data/app/assets/images/postnhost/sample-docs/19-how-to-customize-public-text-for-each-language-01-i18n-overrides.webp +0 -0
  95. data/app/assets/images/postnhost/sample-docs/20-how-to-configure-structured-data-and-sitemap-seo-01-site-schema.webp +0 -0
  96. data/app/assets/images/postnhost/sample-docs/20-how-to-configure-structured-data-and-sitemap-seo-02-author-schema-defaults.webp +0 -0
  97. data/app/assets/images/postnhost/sample-docs/20-how-to-configure-structured-data-and-sitemap-seo-03-author-schema-advanced-fields.webp +0 -0
  98. data/app/assets/images/postnhost/sample-docs/covers/08-suggested-articles-and-top-picks.webp +0 -0
  99. data/app/assets/images/postnhost/sample-docs/covers/09-authors.webp +0 -0
  100. data/app/assets/images/postnhost/sample-docs/covers/11-scheduling.webp +0 -0
  101. data/app/assets/images/postnhost/sample-docs/covers/12-version-history.webp +0 -0
  102. data/app/assets/images/postnhost/sample-docs/covers/13-languages.webp +0 -0
  103. data/app/assets/images/postnhost/sample-docs/covers/14-translations.webp +0 -0
  104. data/app/assets/images/postnhost/sample-docs/covers/16-navigation-links.webp +0 -0
  105. data/app/assets/images/postnhost/sample-docs/covers/17-logo-and-default-og-image.webp +0 -0
  106. data/app/assets/images/postnhost/sample-docs/covers/19-public-text-for-each-language.webp +0 -0
  107. data/app/assets/images/postnhost/sample-docs/covers/20-structured-data-and-sitemap-seo.webp +0 -0
  108. data/app/assets/stylesheets/application.tailwind.css +343 -0
  109. data/app/assets/stylesheets/postnhost/application.css +15 -0
  110. data/app/assets/xsl/sitemap.xsl +64 -0
  111. data/app/controllers/concerns/postnhost/authentication.rb +59 -0
  112. data/app/controllers/concerns/postnhost/canonical_first_page_redirect.rb +14 -0
  113. data/app/controllers/concerns/postnhost/language_switcher.rb +42 -0
  114. data/app/controllers/concerns/postnhost/onboarding/access_control.rb +57 -0
  115. data/app/controllers/concerns/postnhost/onboarding/step_assignment.rb +55 -0
  116. data/app/controllers/concerns/postnhost/public_footer_data.rb +103 -0
  117. data/app/controllers/concerns/postnhost/public_http_caching.rb +35 -0
  118. data/app/controllers/concerns/postnhost/public_page_size.rb +17 -0
  119. data/app/controllers/concerns/postnhost/public_template_resolvable.rb +44 -0
  120. data/app/controllers/concerns/postnhost/settings/i18n_manageable.rb +61 -0
  121. data/app/controllers/concerns/postnhost/settings/navigation_manageable.rb +118 -0
  122. data/app/controllers/concerns/postnhost/settings/schema_manageable.rb +140 -0
  123. data/app/controllers/concerns/postnhost/users/schema_manageable.rb +119 -0
  124. data/app/controllers/postnhost/application_controller.rb +37 -0
  125. data/app/controllers/postnhost/article_variants_controller.rb +138 -0
  126. data/app/controllers/postnhost/articles/cover_images_controller.rb +35 -0
  127. data/app/controllers/postnhost/articles/images_controller.rb +28 -0
  128. data/app/controllers/postnhost/articles/translations_controller.rb +26 -0
  129. data/app/controllers/postnhost/articles_controller.rb +130 -0
  130. data/app/controllers/postnhost/categories/translations_controller.rb +24 -0
  131. data/app/controllers/postnhost/categories_controller.rb +59 -0
  132. data/app/controllers/postnhost/category_variants_controller.rb +69 -0
  133. data/app/controllers/postnhost/languages_controller.rb +66 -0
  134. data/app/controllers/postnhost/onboarding_controller.rb +70 -0
  135. data/app/controllers/postnhost/page_variants_controller.rb +136 -0
  136. data/app/controllers/postnhost/pages/images_controller.rb +28 -0
  137. data/app/controllers/postnhost/pages/translations_controller.rb +26 -0
  138. data/app/controllers/postnhost/pages_controller.rb +95 -0
  139. data/app/controllers/postnhost/public/articles_controller.rb +135 -0
  140. data/app/controllers/postnhost/public/authors_controller.rb +57 -0
  141. data/app/controllers/postnhost/public/categories_controller.rb +61 -0
  142. data/app/controllers/postnhost/public/static_pages_controller.rb +77 -0
  143. data/app/controllers/postnhost/public_controller.rb +32 -0
  144. data/app/controllers/postnhost/sessions_controller.rb +37 -0
  145. data/app/controllers/postnhost/settings/site_scripts_controller.rb +61 -0
  146. data/app/controllers/postnhost/settings_controller.rb +63 -0
  147. data/app/controllers/postnhost/sitemap_controller.rb +17 -0
  148. data/app/controllers/postnhost/templates_controller.rb +26 -0
  149. data/app/controllers/postnhost/users_controller.rb +112 -0
  150. data/app/helpers/postnhost/application_helper.rb +247 -0
  151. data/app/helpers/postnhost/article_helper.rb +41 -0
  152. data/app/helpers/postnhost/category_helper.rb +52 -0
  153. data/app/helpers/postnhost/common_css_helper.rb +97 -0
  154. data/app/helpers/postnhost/navigation_helper.rb +148 -0
  155. data/app/helpers/postnhost/pagy_helper.rb +103 -0
  156. data/app/helpers/postnhost/public_pages_helper.rb +287 -0
  157. data/app/helpers/postnhost/schema_helper.rb +102 -0
  158. data/app/helpers/postnhost/seo_helper.rb +428 -0
  159. data/app/helpers/postnhost/settings_helper.rb +79 -0
  160. data/app/javascript/application.js +3 -0
  161. data/app/javascript/controllers/ai_translation_controller.js +42 -0
  162. data/app/javascript/controllers/application.js +9 -0
  163. data/app/javascript/controllers/author_manager_controller.js +96 -0
  164. data/app/javascript/controllers/auto_save_controller.js +244 -0
  165. data/app/javascript/controllers/bulk_selection_controller.js +74 -0
  166. data/app/javascript/controllers/burger_controller.js +20 -0
  167. data/app/javascript/controllers/category_manager_controller.js +99 -0
  168. data/app/javascript/controllers/collapsible_sidebar_controller.js +87 -0
  169. data/app/javascript/controllers/cover_image_upload_controller.js +164 -0
  170. data/app/javascript/controllers/dropdown_controller.js +38 -0
  171. data/app/javascript/controllers/editor_mobile_sidebars_controller.js +36 -0
  172. data/app/javascript/controllers/file_auto_submit_controller.js +23 -0
  173. data/app/javascript/controllers/flash_message_controller.js +40 -0
  174. data/app/javascript/controllers/index.js +71 -0
  175. data/app/javascript/controllers/navigation_builder_controller.js +463 -0
  176. data/app/javascript/controllers/public_mobile_nav_controller.js +26 -0
  177. data/app/javascript/controllers/publish_guard_controller.js +47 -0
  178. data/app/javascript/controllers/reading_progress_controller.js +38 -0
  179. data/app/javascript/controllers/schedule_manager_controller.js +87 -0
  180. data/app/javascript/controllers/suggestion_manager_controller.js +99 -0
  181. data/app/javascript/controllers/template_preview_controller.js +34 -0
  182. data/app/javascript/controllers/tiptap_editor_controller.js +812 -0
  183. data/app/javascript/controllers/title_field_controller.js +17 -0
  184. data/app/javascript/helpers/auto_save_state.js +47 -0
  185. data/app/javascript/helpers/code_highlighting.js +48 -0
  186. data/app/javascript/helpers/dom_helpers.js +109 -0
  187. data/app/javascript/helpers/style_helpers.js +168 -0
  188. data/app/jobs/postnhost/application_job.rb +9 -0
  189. data/app/jobs/postnhost/scheduled_article_job.rb +13 -0
  190. data/app/jobs/postnhost/translation/article_variant_job.rb +17 -0
  191. data/app/jobs/postnhost/translation/category_variant_job.rb +17 -0
  192. data/app/jobs/postnhost/translation/page_variant_job.rb +17 -0
  193. data/app/models/concerns/postnhost/article/image_uploadable.rb +11 -0
  194. data/app/models/concerns/postnhost/excerpt_optimizable.rb +57 -0
  195. data/app/models/concerns/postnhost/inline_image_uploadable.rb +40 -0
  196. data/app/models/concerns/postnhost/page/image_uploadable.rb +11 -0
  197. data/app/models/concerns/postnhost/primary_language_guard.rb +18 -0
  198. data/app/models/concerns/postnhost/public_revision_touch.rb +15 -0
  199. data/app/models/concerns/postnhost/schema_localizable.rb +28 -0
  200. data/app/models/postnhost/application_record.rb +5 -0
  201. data/app/models/postnhost/article.rb +142 -0
  202. data/app/models/postnhost/article_author.rb +20 -0
  203. data/app/models/postnhost/article_category.rb +8 -0
  204. data/app/models/postnhost/article_suggestion.rb +19 -0
  205. data/app/models/postnhost/article_variant.rb +28 -0
  206. data/app/models/postnhost/category.rb +34 -0
  207. data/app/models/postnhost/category_variant.rb +24 -0
  208. data/app/models/postnhost/language.rb +38 -0
  209. data/app/models/postnhost/navigation.rb +76 -0
  210. data/app/models/postnhost/navigation_item.rb +100 -0
  211. data/app/models/postnhost/page.rb +63 -0
  212. data/app/models/postnhost/page_variant.rb +26 -0
  213. data/app/models/postnhost/public_site_revision.rb +24 -0
  214. data/app/models/postnhost/published_cover_image.rb +28 -0
  215. data/app/models/postnhost/setting.rb +116 -0
  216. data/app/models/postnhost/site_script.rb +11 -0
  217. data/app/models/postnhost/snapshot/article.rb +142 -0
  218. data/app/models/postnhost/snapshot/article_author.rb +11 -0
  219. data/app/models/postnhost/snapshot/article_category.rb +10 -0
  220. data/app/models/postnhost/snapshot/article_suggestion.rb +11 -0
  221. data/app/models/postnhost/snapshot/article_variant.rb +60 -0
  222. data/app/models/postnhost/snapshot/page.rb +33 -0
  223. data/app/models/postnhost/snapshot/page_variant.rb +41 -0
  224. data/app/models/postnhost/template.rb +20 -0
  225. data/app/models/postnhost/user.rb +63 -0
  226. data/app/models/postnhost/version.rb +7 -0
  227. data/app/services/postnhost/base_service.rb +17 -0
  228. data/app/services/postnhost/constraints/public_locale_constraint.rb +9 -0
  229. data/app/services/postnhost/constraints/public_route_constraint.rb +19 -0
  230. data/app/services/postnhost/onboarding/step_processor.rb +27 -0
  231. data/app/services/postnhost/onboarding/steps/base.rb +52 -0
  232. data/app/services/postnhost/onboarding/steps/choose_languages.rb +66 -0
  233. data/app/services/postnhost/onboarding/steps/complete_post_install_checklist.rb +12 -0
  234. data/app/services/postnhost/onboarding/steps/create_admin_account.rb +28 -0
  235. data/app/services/postnhost/onboarding/steps/finish_setup.rb +27 -0
  236. data/app/services/postnhost/onboarding/steps/set_site_metadata.rb +70 -0
  237. data/app/services/postnhost/public_navigation_targets.rb +67 -0
  238. data/app/services/postnhost/public_request_context.rb +101 -0
  239. data/app/services/postnhost/publishing/article_variants/publish.rb +26 -0
  240. data/app/services/postnhost/publishing/article_variants/publish_many.rb +23 -0
  241. data/app/services/postnhost/publishing/article_variants/snapshot_writer.rb +44 -0
  242. data/app/services/postnhost/publishing/article_variants/unpublish.rb +27 -0
  243. data/app/services/postnhost/publishing/article_variants/unpublish_many.rb +23 -0
  244. data/app/services/postnhost/publishing/articles/publish.rb +25 -0
  245. data/app/services/postnhost/publishing/articles/publish_scheduled.rb +42 -0
  246. data/app/services/postnhost/publishing/articles/snapshot_writer.rb +84 -0
  247. data/app/services/postnhost/publishing/articles/unpublish.rb +26 -0
  248. data/app/services/postnhost/publishing/bulk_transition.rb +63 -0
  249. data/app/services/postnhost/publishing/error.rb +12 -0
  250. data/app/services/postnhost/publishing/page_variants/publish.rb +26 -0
  251. data/app/services/postnhost/publishing/page_variants/publish_many.rb +23 -0
  252. data/app/services/postnhost/publishing/page_variants/snapshot_writer.rb +41 -0
  253. data/app/services/postnhost/publishing/page_variants/unpublish.rb +27 -0
  254. data/app/services/postnhost/publishing/page_variants/unpublish_many.rb +23 -0
  255. data/app/services/postnhost/publishing/pages/publish.rb +25 -0
  256. data/app/services/postnhost/publishing/pages/snapshot_writer.rb +38 -0
  257. data/app/services/postnhost/publishing/pages/unpublish.rb +26 -0
  258. data/app/services/postnhost/publishing/revision.rb +37 -0
  259. data/app/services/postnhost/publishing/route_validator.rb +41 -0
  260. data/app/services/postnhost/publishing/versions/restore_draft.rb +64 -0
  261. data/app/services/postnhost/service_result.rb +7 -0
  262. data/app/services/postnhost/sitemap_builder.rb +395 -0
  263. data/app/services/postnhost/translation/article_variant_translator.rb +102 -0
  264. data/app/services/postnhost/translation/category_variant_translator.rb +92 -0
  265. data/app/services/postnhost/translation/content_translator.rb +119 -0
  266. data/app/services/postnhost/translation/openai_llm_client.rb +30 -0
  267. data/app/services/postnhost/translation/page_variant_translator.rb +55 -0
  268. data/app/uploaders/base_uploader.rb +26 -0
  269. data/app/uploaders/cover_image_uploader.rb +31 -0
  270. data/app/uploaders/postnhost/avatar_uploader.rb +17 -0
  271. data/app/uploaders/postnhost/inline_article_image_uploader.rb +38 -0
  272. data/app/uploaders/postnhost/inline_page_image_uploader.rb +38 -0
  273. data/app/uploaders/postnhost/setting_asset_uploader.rb +18 -0
  274. data/app/views/layouts/postnhost/_favicon.html.erb +6 -0
  275. data/app/views/layouts/postnhost/_footer.html.erb +67 -0
  276. data/app/views/layouts/postnhost/_header.html.erb +89 -0
  277. data/app/views/layouts/postnhost/_sidebar.html.erb +72 -0
  278. data/app/views/layouts/postnhost/application.html.erb +23 -0
  279. data/app/views/layouts/postnhost/articles.html.erb +34 -0
  280. data/app/views/layouts/postnhost/product.html.erb +36 -0
  281. data/app/views/layouts/postnhost/public/templates/default.html.erb +110 -0
  282. data/app/views/layouts/postnhost/public/templates/swiss-editorial.html.erb +213 -0
  283. data/app/views/layouts/postnhost/public/templates/workspace-journal.html.erb +231 -0
  284. data/app/views/layouts/postnhost/public_header/_dashboard_sign_in.html.erb +14 -0
  285. data/app/views/layouts/postnhost/public_header/_language_switcher.html.erb +43 -0
  286. data/app/views/layouts/postnhost/public_header/_logo.html.erb +9 -0
  287. data/app/views/postnhost/article_variants/_variants_list.html.erb +82 -0
  288. data/app/views/postnhost/article_variants/edit.html.erb +48 -0
  289. data/app/views/postnhost/article_variants/index.html.erb +128 -0
  290. data/app/views/postnhost/article_variants/publish.turbo_stream.erb +3 -0
  291. data/app/views/postnhost/article_variants/unpublish.turbo_stream.erb +3 -0
  292. data/app/views/postnhost/article_variants/update.turbo_stream.erb +3 -0
  293. data/app/views/postnhost/articles/_author_manager.html.erb +38 -0
  294. data/app/views/postnhost/articles/_category_manager.html.erb +42 -0
  295. data/app/views/postnhost/articles/_cover_image_upload.html.erb +82 -0
  296. data/app/views/postnhost/articles/_suggestion_manager.html.erb +43 -0
  297. data/app/views/postnhost/articles/_version_snapshot.html.erb +10 -0
  298. data/app/views/postnhost/articles/edit.html.erb +51 -0
  299. data/app/views/postnhost/articles/index.html.erb +132 -0
  300. data/app/views/postnhost/articles/publish.turbo_stream.erb +3 -0
  301. data/app/views/postnhost/articles/unpublish.turbo_stream.erb +3 -0
  302. data/app/views/postnhost/articles/update.turbo_stream.erb +3 -0
  303. data/app/views/postnhost/articles/version_preview.html.erb +17 -0
  304. data/app/views/postnhost/articles/versions.html.erb +68 -0
  305. data/app/views/postnhost/categories/edit.html.erb +77 -0
  306. data/app/views/postnhost/categories/index.html.erb +80 -0
  307. data/app/views/postnhost/categories/new.html.erb +50 -0
  308. data/app/views/postnhost/categories/show.html.erb +99 -0
  309. data/app/views/postnhost/category_variants/_category_variants_list.html.erb +67 -0
  310. data/app/views/postnhost/category_variants/_form.html.erb +29 -0
  311. data/app/views/postnhost/category_variants/edit.html.erb +21 -0
  312. data/app/views/postnhost/category_variants/index.html.erb +82 -0
  313. data/app/views/postnhost/category_variants/new.html.erb +21 -0
  314. data/app/views/postnhost/languages/edit.html.erb +45 -0
  315. data/app/views/postnhost/languages/index.html.erb +86 -0
  316. data/app/views/postnhost/languages/new.html.erb +45 -0
  317. data/app/views/postnhost/languages/show.html.erb +156 -0
  318. data/app/views/postnhost/onboarding/_post_install_checklist.html.erb +40 -0
  319. data/app/views/postnhost/onboarding/_step_1.html.erb +42 -0
  320. data/app/views/postnhost/onboarding/_step_2.html.erb +38 -0
  321. data/app/views/postnhost/onboarding/_step_3.html.erb +62 -0
  322. data/app/views/postnhost/onboarding/_step_4.html.erb +26 -0
  323. data/app/views/postnhost/onboarding/show.html.erb +51 -0
  324. data/app/views/postnhost/page_variants/_variants_list.html.erb +82 -0
  325. data/app/views/postnhost/page_variants/edit.html.erb +50 -0
  326. data/app/views/postnhost/page_variants/index.html.erb +128 -0
  327. data/app/views/postnhost/page_variants/publish.turbo_stream.erb +3 -0
  328. data/app/views/postnhost/page_variants/unpublish.turbo_stream.erb +3 -0
  329. data/app/views/postnhost/page_variants/update.turbo_stream.erb +3 -0
  330. data/app/views/postnhost/pages/edit.html.erb +50 -0
  331. data/app/views/postnhost/pages/index.html.erb +110 -0
  332. data/app/views/postnhost/pages/publish.turbo_stream.erb +3 -0
  333. data/app/views/postnhost/pages/unpublish.turbo_stream.erb +3 -0
  334. data/app/views/postnhost/public/articles/content_for/_index.html.erb +30 -0
  335. data/app/views/postnhost/public/articles/content_for/_preview.html.erb +9 -0
  336. data/app/views/postnhost/public/articles/content_for/_show.html.erb +24 -0
  337. data/app/views/postnhost/public/authors/content_for/_show.html.erb +24 -0
  338. data/app/views/postnhost/public/categories/content_for/_index.html.erb +29 -0
  339. data/app/views/postnhost/public/static_pages/content_for/_show.html.erb +19 -0
  340. data/app/views/postnhost/public/templates/default/articles/_article_card.html.erb +37 -0
  341. data/app/views/postnhost/public/templates/default/articles/_search_form.html.erb +12 -0
  342. data/app/views/postnhost/public/templates/default/articles/_suggestions.html.erb +44 -0
  343. data/app/views/postnhost/public/templates/default/articles/_top_picks.html.erb +42 -0
  344. data/app/views/postnhost/public/templates/default/articles/index.html.erb +53 -0
  345. data/app/views/postnhost/public/templates/default/articles/preview.html.erb +102 -0
  346. data/app/views/postnhost/public/templates/default/articles/show.html.erb +111 -0
  347. data/app/views/postnhost/public/templates/default/authors/_article_card.html.erb +28 -0
  348. data/app/views/postnhost/public/templates/default/authors/_profile_block.html.erb +29 -0
  349. data/app/views/postnhost/public/templates/default/authors/show.html.erb +36 -0
  350. data/app/views/postnhost/public/templates/default/categories/_article_card.html.erb +34 -0
  351. data/app/views/postnhost/public/templates/default/categories/index.html.erb +48 -0
  352. data/app/views/postnhost/public/templates/default/shared/_article_authors.html.erb +24 -0
  353. data/app/views/postnhost/public/templates/default/shared/_author_avatar.html.erb +9 -0
  354. data/app/views/postnhost/public/templates/default/shared/_hero_cover_image.html.erb +11 -0
  355. data/app/views/postnhost/public/templates/default/shared/_pagination.html.erb +35 -0
  356. data/app/views/postnhost/public/templates/default/shared/_thumbnail_cover_image.html.erb +9 -0
  357. data/app/views/postnhost/public/templates/default/static_pages/show.html.erb +22 -0
  358. data/app/views/postnhost/public/templates/swiss-editorial/articles/_article_card.html.erb +28 -0
  359. data/app/views/postnhost/public/templates/swiss-editorial/articles/_search_form.html.erb +19 -0
  360. data/app/views/postnhost/public/templates/swiss-editorial/articles/_suggestions.html.erb +42 -0
  361. data/app/views/postnhost/public/templates/swiss-editorial/articles/_top_picks.html.erb +38 -0
  362. data/app/views/postnhost/public/templates/swiss-editorial/articles/index.html.erb +133 -0
  363. data/app/views/postnhost/public/templates/swiss-editorial/articles/preview.html.erb +42 -0
  364. data/app/views/postnhost/public/templates/swiss-editorial/articles/show.html.erb +100 -0
  365. data/app/views/postnhost/public/templates/swiss-editorial/authors/_article_card.html.erb +27 -0
  366. data/app/views/postnhost/public/templates/swiss-editorial/authors/_profile_block.html.erb +32 -0
  367. data/app/views/postnhost/public/templates/swiss-editorial/authors/show.html.erb +31 -0
  368. data/app/views/postnhost/public/templates/swiss-editorial/categories/_article_card.html.erb +28 -0
  369. data/app/views/postnhost/public/templates/swiss-editorial/categories/index.html.erb +88 -0
  370. data/app/views/postnhost/public/templates/swiss-editorial/shared/_article_authors.html.erb +26 -0
  371. data/app/views/postnhost/public/templates/swiss-editorial/shared/_author_avatar.html.erb +9 -0
  372. data/app/views/postnhost/public/templates/swiss-editorial/shared/_hero_cover_image.html.erb +11 -0
  373. data/app/views/postnhost/public/templates/swiss-editorial/shared/_pagination.html.erb +35 -0
  374. data/app/views/postnhost/public/templates/swiss-editorial/shared/_thumbnail_cover_image.html.erb +9 -0
  375. data/app/views/postnhost/public/templates/swiss-editorial/static_pages/show.html.erb +17 -0
  376. data/app/views/postnhost/public/templates/workspace-journal/articles/_article_card.html.erb +44 -0
  377. data/app/views/postnhost/public/templates/workspace-journal/articles/_search_form.html.erb +19 -0
  378. data/app/views/postnhost/public/templates/workspace-journal/articles/_suggestions.html.erb +49 -0
  379. data/app/views/postnhost/public/templates/workspace-journal/articles/_top_picks.html.erb +34 -0
  380. data/app/views/postnhost/public/templates/workspace-journal/articles/index.html.erb +69 -0
  381. data/app/views/postnhost/public/templates/workspace-journal/articles/preview.html.erb +113 -0
  382. data/app/views/postnhost/public/templates/workspace-journal/articles/show.html.erb +120 -0
  383. data/app/views/postnhost/public/templates/workspace-journal/authors/_article_card.html.erb +1 -0
  384. data/app/views/postnhost/public/templates/workspace-journal/authors/_profile_block.html.erb +32 -0
  385. data/app/views/postnhost/public/templates/workspace-journal/authors/show.html.erb +33 -0
  386. data/app/views/postnhost/public/templates/workspace-journal/categories/_article_card.html.erb +1 -0
  387. data/app/views/postnhost/public/templates/workspace-journal/categories/index.html.erb +58 -0
  388. data/app/views/postnhost/public/templates/workspace-journal/shared/_article_authors.html.erb +27 -0
  389. data/app/views/postnhost/public/templates/workspace-journal/shared/_article_card.html.erb +44 -0
  390. data/app/views/postnhost/public/templates/workspace-journal/shared/_author_avatar.html.erb +9 -0
  391. data/app/views/postnhost/public/templates/workspace-journal/shared/_category_navigation.html.erb +10 -0
  392. data/app/views/postnhost/public/templates/workspace-journal/shared/_hero_cover_image.html.erb +11 -0
  393. data/app/views/postnhost/public/templates/workspace-journal/shared/_pagination.html.erb +35 -0
  394. data/app/views/postnhost/public/templates/workspace-journal/shared/_thumbnail_cover_image.html.erb +9 -0
  395. data/app/views/postnhost/public/templates/workspace-journal/static_pages/show.html.erb +17 -0
  396. data/app/views/postnhost/sessions/new.html.erb +59 -0
  397. data/app/views/postnhost/settings/edit.html.erb +68 -0
  398. data/app/views/postnhost/settings/sections/_assets.html.erb +40 -0
  399. data/app/views/postnhost/settings/sections/_feature_flags.html.erb +43 -0
  400. data/app/views/postnhost/settings/sections/_i18n.html.erb +57 -0
  401. data/app/views/postnhost/settings/sections/_navigation.html.erb +98 -0
  402. data/app/views/postnhost/settings/sections/_schema.html.erb +253 -0
  403. data/app/views/postnhost/settings/sections/_scripts.html.erb +79 -0
  404. data/app/views/postnhost/settings/sections/_site.html.erb +41 -0
  405. data/app/views/postnhost/settings/sections/_timezone.html.erb +19 -0
  406. data/app/views/postnhost/shared/_ai_translation_modal.html.erb +85 -0
  407. data/app/views/postnhost/shared/_flash_messages.html.erb +27 -0
  408. data/app/views/postnhost/shared/_form_errors.html.erb +22 -0
  409. data/app/views/postnhost/shared/_seo_meta_tags.html.erb +70 -0
  410. data/app/views/postnhost/shared/_structured_data.html.erb +5 -0
  411. data/app/views/postnhost/shared/articles/_action_buttons.html.erb +87 -0
  412. data/app/views/postnhost/shared/articles/_cover_image_preview.html.erb +30 -0
  413. data/app/views/postnhost/shared/articles/_editor_column.html.erb +15 -0
  414. data/app/views/postnhost/shared/articles/_editor_column_preview.html.erb +33 -0
  415. data/app/views/postnhost/shared/articles/_error_messages.html.erb +23 -0
  416. data/app/views/postnhost/shared/articles/_meta_sidebar.html.erb +322 -0
  417. data/app/views/postnhost/shared/articles/_meta_sidebar_preview.html.erb +29 -0
  418. data/app/views/postnhost/shared/articles/_post_actions.html.erb +105 -0
  419. data/app/views/postnhost/shared/articles/_schedule_actions.html.erb +51 -0
  420. data/app/views/postnhost/shared/articles/_tiptap_editor.html.erb +225 -0
  421. data/app/views/postnhost/shared/articles/_title_field.html.erb +9 -0
  422. data/app/views/postnhost/shared/articles/_version_action_buttons.html.erb +35 -0
  423. data/app/views/postnhost/shared/elements/_button.html.erb +20 -0
  424. data/app/views/postnhost/shared/elements/_button_link.html.erb +24 -0
  425. data/app/views/postnhost/shared/elements/_empty_state.html.erb +22 -0
  426. data/app/views/postnhost/shared/elements/_error_messages.html.erb +28 -0
  427. data/app/views/postnhost/shared/elements/_form_card.html.erb +10 -0
  428. data/app/views/postnhost/shared/elements/_form_page_header.html.erb +21 -0
  429. data/app/views/postnhost/shared/elements/_icon_button.html.erb +17 -0
  430. data/app/views/postnhost/shared/elements/_page_header.html.erb +40 -0
  431. data/app/views/postnhost/shared/elements/_table_container.html.erb +23 -0
  432. data/app/views/postnhost/shared/pages/_action_buttons.html.erb +84 -0
  433. data/app/views/postnhost/shared/pages/_meta_sidebar.html.erb +132 -0
  434. data/app/views/postnhost/shared/pages/_post_actions.html.erb +69 -0
  435. data/app/views/postnhost/templates/edit.html.erb +83 -0
  436. data/app/views/postnhost/users/_avatar.html.erb +9 -0
  437. data/app/views/postnhost/users/_form.html.erb +160 -0
  438. data/app/views/postnhost/users/edit.html.erb +26 -0
  439. data/app/views/postnhost/users/index.html.erb +91 -0
  440. data/app/views/postnhost/users/new.html.erb +9 -0
  441. data/app/views/postnhost/users/schema_edit.html.erb +175 -0
  442. data/config/locales/de.yml +55 -0
  443. data/config/locales/en.yml +88 -0
  444. data/config/locales/es.yml +58 -0
  445. data/config/locales/fr.yml +55 -0
  446. data/config/locales/ja.yml +55 -0
  447. data/config/locales/ko.yml +55 -0
  448. data/config/locales/pl.yml +55 -0
  449. data/config/locales/pt.yml +55 -0
  450. data/config/locales/ru.yml +55 -0
  451. data/config/routes.rb +112 -0
  452. data/db/migrate/20250822213820_create_users.rb +29 -0
  453. data/db/migrate/20250822214046_create_categories.rb +15 -0
  454. data/db/migrate/20250822214106_create_languages.rb +16 -0
  455. data/db/migrate/20250822214148_create_articles.rb +34 -0
  456. data/db/migrate/20250822214217_create_article_variants.rb +24 -0
  457. data/db/migrate/20250929091137_create_article_categories.rb +12 -0
  458. data/db/migrate/20251013204200_create_versions.rb +40 -0
  459. data/db/migrate/20260106074532_create_article_suggestions.rb +13 -0
  460. data/db/migrate/20260108110506_create_category_variants.rb +15 -0
  461. data/db/migrate/20260308130000_create_settings.rb +23 -0
  462. data/db/migrate/20260323113000_create_pages.rb +19 -0
  463. data/db/migrate/20260414100000_create_article_authors.rb +14 -0
  464. data/db/migrate/20260512100000_create_templates.rb +9 -0
  465. data/db/migrate/20260520110001_create_navigations.rb +9 -0
  466. data/db/migrate/20260520110002_create_navigation_items.rb +22 -0
  467. data/db/migrate/20260528000002_create_page_variants.rb +20 -0
  468. data/db/migrate/20260709090000_create_snapshots.rb +154 -0
  469. data/db/migrate/20260726130000_create_site_scripts.rb +11 -0
  470. data/lib/generators/postnhost/install/install_generator.rb +56 -0
  471. data/lib/generators/postnhost/install/templates/carrierwave.rb +17 -0
  472. data/lib/generators/postnhost/install/templates/postnhost.rb +36 -0
  473. data/lib/generators/postnhost/locale/locale_generator.rb +36 -0
  474. data/lib/generators/postnhost/static_pages/static_pages_generator.rb +47 -0
  475. data/lib/generators/postnhost/tailwindcss/install_generator.rb +97 -0
  476. data/lib/generators/postnhost/tailwindcss/templates/host.tailwind.css +7 -0
  477. data/lib/generators/postnhost/tailwindcss/templates/tailwind.config.js +8 -0
  478. data/lib/generators/postnhost/user/user_generator.rb +36 -0
  479. data/lib/generators/postnhost/views/views_generator.rb +142 -0
  480. data/lib/postnhost/configuration.rb +64 -0
  481. data/lib/postnhost/engine.rb +57 -0
  482. data/lib/postnhost/sample_data.rb +865 -0
  483. data/lib/postnhost/settings/i18n_overrides.rb +137 -0
  484. data/lib/postnhost/settings/i18n_patch.rb +18 -0
  485. data/lib/postnhost/version.rb +1 -3
  486. data/lib/postnhost.rb +26 -3
  487. data/lib/tasks/postnhost_snapshots.rake +77 -0
  488. data/lib/tasks/postnhost_tasks.rake +96 -0
  489. metadata +662 -21
  490. data/.github/workflows/main.yml +0 -27
  491. data/.gitignore +0 -11
  492. data/.rspec +0 -3
  493. data/.rubocop.yml +0 -8
  494. data/Gemfile +0 -13
  495. data/bin/console +0 -11
  496. data/bin/setup +0 -8
  497. data/postnhost.gemspec +0 -31
@@ -0,0 +1,17 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ preventNewLine(event) {
5
+ if (event.key === "Enter") {
6
+ event.preventDefault()
7
+ this.#focusEditor()
8
+ }
9
+ }
10
+
11
+ #focusEditor() {
12
+ const editor = document.querySelector(".ProseMirror")
13
+ if (editor) {
14
+ editor.focus()
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,47 @@
1
+ export default class AutoSaveState {
2
+ constructor() {
3
+ this.changeGeneration = 0
4
+ this.savedGeneration = 0
5
+ this.savingGeneration = null
6
+ }
7
+
8
+ get hasUnsavedChanges() {
9
+ return this.changeGeneration > this.savedGeneration
10
+ }
11
+
12
+ get isSaving() {
13
+ return this.savingGeneration !== null
14
+ }
15
+
16
+ markChanged() {
17
+ this.changeGeneration += 1
18
+ }
19
+
20
+ beginSave() {
21
+ if (this.isSaving || !this.hasUnsavedChanges) return null
22
+
23
+ this.savingGeneration = this.changeGeneration
24
+ return this.savingGeneration
25
+ }
26
+
27
+ completeSave(generation) {
28
+ this.#assertActiveSave(generation)
29
+ this.savedGeneration = Math.max(this.savedGeneration, generation)
30
+ this.savingGeneration = null
31
+ }
32
+
33
+ failSave(generation) {
34
+ this.#assertActiveSave(generation)
35
+ this.savingGeneration = null
36
+ }
37
+
38
+ hasChangesAfter(generation) {
39
+ return this.changeGeneration > generation
40
+ }
41
+
42
+ #assertActiveSave(generation) {
43
+ if (generation !== this.savingGeneration) {
44
+ throw new Error(`Expected active save generation ${this.savingGeneration}, received ${generation}`)
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,48 @@
1
+ import { createLowlight } from 'lowlight'
2
+ import bash from 'highlight.js/lib/languages/bash'
3
+ import c from 'highlight.js/lib/languages/c'
4
+ import cpp from 'highlight.js/lib/languages/cpp'
5
+ import csharp from 'highlight.js/lib/languages/csharp'
6
+ import css from 'highlight.js/lib/languages/css'
7
+ import diff from 'highlight.js/lib/languages/diff'
8
+ import go from 'highlight.js/lib/languages/go'
9
+ import java from 'highlight.js/lib/languages/java'
10
+ import javascript from 'highlight.js/lib/languages/javascript'
11
+ import json from 'highlight.js/lib/languages/json'
12
+ import kotlin from 'highlight.js/lib/languages/kotlin'
13
+ import markdown from 'highlight.js/lib/languages/markdown'
14
+ import php from 'highlight.js/lib/languages/php'
15
+ import plaintext from 'highlight.js/lib/languages/plaintext'
16
+ import python from 'highlight.js/lib/languages/python'
17
+ import ruby from 'highlight.js/lib/languages/ruby'
18
+ import rust from 'highlight.js/lib/languages/rust'
19
+ import sql from 'highlight.js/lib/languages/sql'
20
+ import swift from 'highlight.js/lib/languages/swift'
21
+ import typescript from 'highlight.js/lib/languages/typescript'
22
+ import xml from 'highlight.js/lib/languages/xml'
23
+ import yaml from 'highlight.js/lib/languages/yaml'
24
+
25
+ export const codeLowlight = createLowlight({
26
+ bash,
27
+ c,
28
+ cpp,
29
+ csharp,
30
+ css,
31
+ diff,
32
+ go,
33
+ java,
34
+ javascript,
35
+ json,
36
+ kotlin,
37
+ markdown,
38
+ php,
39
+ plaintext,
40
+ python,
41
+ ruby,
42
+ rust,
43
+ sql,
44
+ swift,
45
+ typescript,
46
+ xml,
47
+ yaml,
48
+ })
@@ -0,0 +1,109 @@
1
+ // DOM manipulation utilities
2
+ // Follows KISS principle: simple, reusable DOM operations
3
+
4
+ /**
5
+ * Show an element by removing 'hidden' class
6
+ * @param {HTMLElement} element - Element to show
7
+ */
8
+ export function showElement(element) {
9
+ if (!element) return
10
+ element.classList.remove('hidden')
11
+ }
12
+
13
+ /**
14
+ * Hide an element by adding 'hidden' class
15
+ * @param {HTMLElement} element - Element to hide
16
+ */
17
+ export function hideElement(element) {
18
+ if (!element) return
19
+ element.classList.add('hidden')
20
+ }
21
+
22
+ /**
23
+ * Toggle element visibility
24
+ * @param {HTMLElement} element - Element to toggle
25
+ */
26
+ export function toggleElement(element) {
27
+ if (!element) return
28
+ element.classList.toggle('hidden')
29
+ }
30
+
31
+ /**
32
+ * Create a toast notification
33
+ * @param {string} message - Message to display
34
+ * @param {string} type - Type of toast: 'success', 'error', 'info', 'warning'
35
+ * @param {number} duration - Duration in milliseconds (default: 5000)
36
+ * @returns {HTMLElement} The created toast element
37
+ */
38
+ export function createToast(message, type = 'error', duration = 5000) {
39
+ const toastConfig = {
40
+ success: {
41
+ bg: 'bg-green-50',
42
+ border: 'border-green-200',
43
+ textColor: 'text-green-800',
44
+ iconColor: 'text-green-600',
45
+ icon: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>'
46
+ },
47
+ error: {
48
+ bg: 'bg-red-50',
49
+ border: 'border-red-200',
50
+ textColor: 'text-red-800',
51
+ iconColor: 'text-red-600',
52
+ icon: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>'
53
+ },
54
+ info: {
55
+ bg: 'bg-blue-50',
56
+ border: 'border-blue-200',
57
+ textColor: 'text-blue-800',
58
+ iconColor: 'text-blue-600',
59
+ icon: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>'
60
+ },
61
+ warning: {
62
+ bg: 'bg-yellow-50',
63
+ border: 'border-yellow-200',
64
+ textColor: 'text-yellow-800',
65
+ iconColor: 'text-yellow-600',
66
+ icon: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>'
67
+ }
68
+ }
69
+
70
+
71
+ const config = toastConfig[type] || toastConfig.error
72
+
73
+ const toast = document.createElement('div')
74
+ toast.className = `fixed top-4 right-4 ${config.bg} border ${config.border} rounded-xl px-4 py-3 shadow-lg z-50`
75
+ toast.innerHTML = `
76
+ <div class="flex items-center">
77
+ <svg class="w-5 h-5 ${config.iconColor} mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
78
+ ${config.icon}
79
+ </svg>
80
+ <span class="text-sm ${config.textColor}">${message}</span>
81
+ </div>
82
+ `
83
+
84
+ document.body.appendChild(toast)
85
+
86
+ setTimeout(() => {
87
+ toast.remove()
88
+ }, duration)
89
+
90
+ return toast
91
+ }
92
+
93
+ /**
94
+ * Safely remove an element from the DOM
95
+ * @param {HTMLElement} element - Element to remove
96
+ */
97
+ export function removeElement(element) {
98
+ if (!element || !element.parentNode) return
99
+ element.remove()
100
+ }
101
+
102
+ /**
103
+ * Check if element exists in DOM
104
+ * @param {HTMLElement} element - Element to check
105
+ * @returns {boolean}
106
+ */
107
+ export function elementExists(element) {
108
+ return element && element.parentNode !== null
109
+ }
@@ -0,0 +1,168 @@
1
+ // Centralized Tailwind CSS class definitions for consistent styling across the app
2
+ // Following DRY principle: define once, use everywhere
3
+
4
+ // Button State Classes
5
+ export const BUTTON_STATES = {
6
+ primary: ['bg-gray-900', 'text-white', 'border-gray-900', 'hover:bg-gray-800', 'cursor-pointer'],
7
+ secondary: ['bg-white', 'border-gray-300', 'text-gray-700', 'hover:bg-gray-50', 'cursor-pointer'],
8
+ disabled: ['bg-gray-300', 'text-gray-500', 'cursor-not-allowed'],
9
+ saved: ['bg-white', 'border-gray-300', 'text-gray-700', 'hover:bg-gray-50', 'opacity-50', 'cursor-not-allowed'],
10
+ saving: ['opacity-75', 'cursor-not-allowed'],
11
+ uploading: ['bg-purple-100', 'text-purple-800', 'border-purple-200', 'cursor-not-allowed'],
12
+ error: ['bg-red-600', 'text-white', 'border-red-600', 'hover:bg-red-700']
13
+ }
14
+
15
+ // Editor Toolbar States
16
+ export const EDITOR_STATES = {
17
+ active: ['bg-gray-900', 'text-white'],
18
+ inactive: ['text-gray-500', 'hover:text-gray-900', 'hover:bg-gray-100'],
19
+ disabled: ['opacity-50', 'cursor-not-allowed'],
20
+ dragOver: ['border-blue-500', 'border-2', 'border-dashed', 'bg-blue-50']
21
+ }
22
+
23
+ // Animation Classes
24
+ export const ANIMATION_CLASSES = {
25
+ fadeIn: ['transition-all', 'duration-300', 'ease-out', 'scale-100', 'opacity-100'],
26
+ fadeOut: ['transform', 'scale-95', 'opacity-0'],
27
+ initial: ['transform', 'scale-95', 'opacity-0']
28
+ }
29
+
30
+ // Drag & Drop States
31
+ export const DRAG_STATES = {
32
+ over: ['border-gray-400', 'bg-gray-50'],
33
+ leave: []
34
+ }
35
+
36
+ // Utility Functions
37
+
38
+ /**
39
+ * Apply multiple CSS classes to an element
40
+ * @param {HTMLElement} element - Target element
41
+ * @param {string[]} classes - Array of class names to add
42
+ */
43
+ export function applyClasses(element, classes) {
44
+ if (!element || !Array.isArray(classes)) return
45
+ element.classList.add(...classes)
46
+ }
47
+
48
+ /**
49
+ * Remove multiple CSS classes from an element
50
+ * @param {HTMLElement} element - Target element
51
+ * @param {string[]} classes - Array of class names to remove
52
+ */
53
+ export function removeClasses(element, classes) {
54
+ if (!element || !Array.isArray(classes)) return
55
+ element.classList.remove(...classes)
56
+ }
57
+
58
+ /**
59
+ * Toggle classes: remove old set, add new set
60
+ * @param {HTMLElement} element - Target element
61
+ * @param {string[]} addClasses - Classes to add
62
+ * @param {string[]} removeClasses - Classes to remove
63
+ */
64
+ export function toggleClasses(element, addClasses, removeClasses) {
65
+ if (!element) return
66
+
67
+ if (Array.isArray(removeClasses) && removeClasses.length > 0) {
68
+ element.classList.remove(...removeClasses)
69
+ }
70
+
71
+ if (Array.isArray(addClasses) && addClasses.length > 0) {
72
+ element.classList.add(...addClasses)
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Set button to primary state
78
+ * @param {HTMLElement} button - Button element
79
+ */
80
+ export function setPrimaryButton(button) {
81
+ if (!button) return
82
+ toggleClasses(button, BUTTON_STATES.primary, [
83
+ ...BUTTON_STATES.secondary,
84
+ ...BUTTON_STATES.disabled,
85
+ ...BUTTON_STATES.saved,
86
+ ...BUTTON_STATES.saving,
87
+ ...BUTTON_STATES.uploading,
88
+ ...BUTTON_STATES.error
89
+ ])
90
+ button.disabled = false
91
+ }
92
+
93
+ /**
94
+ * Set button to disabled state
95
+ * @param {HTMLElement} button - Button element
96
+ */
97
+ export function setDisabledButton(button) {
98
+ if (!button) return
99
+ toggleClasses(button, BUTTON_STATES.disabled, [
100
+ ...BUTTON_STATES.primary,
101
+ ...BUTTON_STATES.secondary,
102
+ ...BUTTON_STATES.saved,
103
+ ...BUTTON_STATES.saving,
104
+ ...BUTTON_STATES.uploading,
105
+ ...BUTTON_STATES.error
106
+ ])
107
+ button.disabled = true
108
+ }
109
+
110
+ /**
111
+ * Set button to error state
112
+ * @param {HTMLElement} button - Button element
113
+ */
114
+ export function setErrorButton(button) {
115
+ if (!button) return
116
+ toggleClasses(button, BUTTON_STATES.error, [
117
+ ...BUTTON_STATES.primary,
118
+ ...BUTTON_STATES.secondary,
119
+ ...BUTTON_STATES.saved,
120
+ ...BUTTON_STATES.saving,
121
+ ...BUTTON_STATES.uploading,
122
+ ...BUTTON_STATES.disabled
123
+ ])
124
+ button.disabled = false
125
+ }
126
+
127
+ /**
128
+ * Set button to saved state
129
+ * @param {HTMLElement} button - Button element
130
+ */
131
+ export function setSavedButton(button) {
132
+ if (!button) return
133
+ toggleClasses(button, BUTTON_STATES.saved, [
134
+ ...BUTTON_STATES.primary,
135
+ ...BUTTON_STATES.saving,
136
+ ...BUTTON_STATES.uploading,
137
+ ...BUTTON_STATES.error
138
+ ])
139
+ button.disabled = true
140
+ }
141
+
142
+ /**
143
+ * Set button to saving state
144
+ * @param {HTMLElement} button - Button element
145
+ */
146
+ export function setSavingButton(button) {
147
+ if (!button) return
148
+ removeClasses(button, BUTTON_STATES.uploading)
149
+ applyClasses(button, BUTTON_STATES.saving)
150
+ removeClasses(button, ['cursor-pointer'])
151
+ button.disabled = true
152
+ }
153
+
154
+ /**
155
+ * Set button to uploading state
156
+ * @param {HTMLElement} button - Button element
157
+ */
158
+ export function setUploadingButton(button) {
159
+ if (!button) return
160
+ toggleClasses(button, BUTTON_STATES.uploading, [
161
+ ...BUTTON_STATES.primary,
162
+ ...BUTTON_STATES.secondary,
163
+ ...BUTTON_STATES.saved,
164
+ ...BUTTON_STATES.error
165
+ ])
166
+ applyClasses(button, BUTTON_STATES.saving)
167
+ button.disabled = true
168
+ }
@@ -0,0 +1,9 @@
1
+ module Postnhost
2
+ class ApplicationJob < ::ActiveJob::Base
3
+ # Automatically retry jobs that encountered a deadlock
4
+ # retry_on ActiveRecord::Deadlocked
5
+
6
+ # Most jobs are safe to ignore if the underlying records are no longer available
7
+ # discard_on ActiveJob::DeserializationError
8
+ end
9
+ end
@@ -0,0 +1,13 @@
1
+ module Postnhost
2
+ class ScheduledArticleJob < ApplicationJob
3
+ queue_as :publishing
4
+
5
+ queue_with_priority 1
6
+
7
+ retry_on StandardError, attempts: 3
8
+
9
+ def perform(article_id)
10
+ Postnhost::Publishing::Articles::PublishScheduled.call(article_id:, job_id:)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,17 @@
1
+ module Postnhost
2
+ module Translation
3
+ class ArticleVariantJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ queue_with_priority 1
7
+
8
+ retry_on StandardError, attempts: 3
9
+
10
+ def perform(article_id, language_id)
11
+ result = ArticleVariantTranslator.call(article_id:, language_id:)
12
+
13
+ result.success? ? result : raise(StandardError, result.errors.to_sentence)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ module Postnhost
2
+ module Translation
3
+ class CategoryVariantJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ queue_with_priority 1
7
+
8
+ retry_on StandardError, attempts: 3
9
+
10
+ def perform(category_id, language_id)
11
+ result = CategoryVariantTranslator.call(category_id:, language_id:)
12
+
13
+ result.success? ? result : raise(StandardError, result.errors.to_sentence)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ module Postnhost
2
+ module Translation
3
+ class PageVariantJob < ApplicationJob
4
+ queue_as :default
5
+
6
+ queue_with_priority 1
7
+
8
+ retry_on StandardError, attempts: 3
9
+
10
+ def perform(page_id, language_id)
11
+ result = PageVariantTranslator.call(page_id:, language_id:)
12
+
13
+ result.success? ? result : raise(StandardError, result.errors.to_sentence)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,11 @@
1
+ module Postnhost::Article::ImageUploadable
2
+ extend ActiveSupport::Concern
3
+
4
+ include Postnhost::InlineImageUploadable
5
+
6
+ private
7
+
8
+ def inline_image_uploader_class
9
+ Postnhost::InlineArticleImageUploader
10
+ end
11
+ end
@@ -0,0 +1,57 @@
1
+ module Postnhost
2
+ module ExcerptOptimizable
3
+ extend ActiveSupport::Concern
4
+
5
+ CUSTOM_EXCERPT_MAX_CHARS = 160
6
+
7
+ included do
8
+ validate :custom_excerpt_length_within_limit
9
+ before_validation :populate_auto_excerpt, if: :should_populate_auto_excerpt?
10
+ end
11
+
12
+ class_methods do
13
+ def custom_excerpt_max_chars
14
+ CUSTOM_EXCERPT_MAX_CHARS
15
+ end
16
+
17
+ def computed_auto_excerpt(content)
18
+ plain_content = plain_text_with_spacing(content.to_s)
19
+ return if plain_content.blank?
20
+
21
+ sentence_excerpt = plain_content[/\A(?:.*?[.!?](?:\s+|$)){1,2}/m]&.strip
22
+ fallback_excerpt = sentence_excerpt.presence || plain_content
23
+ return fallback_excerpt if fallback_excerpt.length <= custom_excerpt_max_chars
24
+
25
+ "#{fallback_excerpt[0, custom_excerpt_max_chars].rstrip}..."
26
+ end
27
+
28
+ private
29
+
30
+ def plain_text_with_spacing(content)
31
+ return content.squish unless content.include?("<")
32
+
33
+ content_with_breaks = content.gsub(%r{</(p|div|li|h[1-6]|blockquote|pre|tr|td|th)>|<br\s*/?>}i, " ")
34
+ ActionController::Base.helpers.strip_tags(content_with_breaks).squish
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def custom_excerpt_length_within_limit
41
+ return if custom_excerpt.blank?
42
+
43
+ excerpt = custom_excerpt.to_s.squish
44
+ return if excerpt.length <= self.class.custom_excerpt_max_chars
45
+
46
+ errors.add(:custom_excerpt, "must be #{self.class.custom_excerpt_max_chars} characters or fewer")
47
+ end
48
+
49
+ def should_populate_auto_excerpt?
50
+ will_save_change_to_content? || auto_excerpt.blank?
51
+ end
52
+
53
+ def populate_auto_excerpt
54
+ self.auto_excerpt = self.class.computed_auto_excerpt(content)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,40 @@
1
+ module Postnhost::InlineImageUploadable
2
+ extend ActiveSupport::Concern
3
+
4
+ IMAGE_MAX_SIZE = 20.megabytes
5
+ SUPPORTED_CONTENT_TYPES = %w[image/jpeg image/png image/gif image/webp image/heic].freeze
6
+
7
+ def upload_inline_image(file)
8
+ return { success: false, error: "Invalid file type" } unless valid_image_file_type?(file)
9
+ return { success: false, error: "File too large" } if image_file_too_large?(file)
10
+
11
+ uploader = inline_image_uploader_class.new(self, :inline_image)
12
+ uploader.store!(file)
13
+
14
+ {
15
+ success: true,
16
+ url: uploader.url,
17
+ filename: uploader.identifier
18
+ }
19
+ rescue CarrierWave::ProcessingError, CarrierWave::IntegrityError => e
20
+ Rails.logger.error("CarrierWave inline image error: #{e.message}")
21
+ { success: false, error: "Invalid image file" }
22
+ rescue StandardError => e
23
+ Rails.logger.error("Inline image processing error: #{e.message}")
24
+ { success: false, error: "Error processing image" }
25
+ end
26
+
27
+ private
28
+
29
+ def valid_image_file_type?(file)
30
+ SUPPORTED_CONTENT_TYPES.include?(file.content_type)
31
+ end
32
+
33
+ def image_file_too_large?(file)
34
+ file.size > IMAGE_MAX_SIZE
35
+ end
36
+
37
+ def inline_image_uploader_class
38
+ raise NotImplementedError, "including class must define #inline_image_uploader_class"
39
+ end
40
+ end
@@ -0,0 +1,11 @@
1
+ module Postnhost::Page::ImageUploadable
2
+ extend ActiveSupport::Concern
3
+
4
+ include Postnhost::InlineImageUploadable
5
+
6
+ private
7
+
8
+ def inline_image_uploader_class
9
+ Postnhost::InlinePageImageUploader
10
+ end
11
+ end
@@ -0,0 +1,18 @@
1
+ module Postnhost
2
+ module PrimaryLanguageGuard
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ validate :primary_language_must_not_duplicate_a_variant
7
+ end
8
+
9
+ private
10
+
11
+ def primary_language_must_not_duplicate_a_variant
12
+ return if language_id.blank?
13
+ return unless translation_variants.exists?(language_id: language_id)
14
+
15
+ errors.add(:language_id, "cannot duplicate an existing translation")
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,15 @@
1
+ module Postnhost
2
+ module PublicRevisionTouch
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ after_commit :bump_public_site_revision
7
+ end
8
+
9
+ private
10
+
11
+ def bump_public_site_revision
12
+ Postnhost::PublicSiteRevision.bump!
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,28 @@
1
+ module Postnhost
2
+ module SchemaLocalizable
3
+ extend ActiveSupport::Concern
4
+
5
+ def schema_locale_overrides_hash
6
+ schema_locale_overrides.is_a?(Hash) ? schema_locale_overrides.deep_stringify_keys : {}
7
+ end
8
+
9
+ def schema_translations_for(locale)
10
+ schema_locale_overrides_hash.fetch(locale.to_s, {})
11
+ end
12
+
13
+ def schema_text_for(locale:, key:, fallback:)
14
+ field = key.to_s
15
+ value = schema_translations_for(locale)[field].presence
16
+ return value if value.present?
17
+
18
+ default_locale_value = schema_translations_for(schema_default_locale_key)[field].presence
19
+ return default_locale_value if default_locale_value.present?
20
+
21
+ fallback
22
+ end
23
+
24
+ def schema_default_locale_key
25
+ @schema_default_locale_key ||= Postnhost::Language.find_by(default: true)&.html_lang.presence || I18n.default_locale.to_s
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,5 @@
1
+ module Postnhost
2
+ class ApplicationRecord < ::ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end