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,136 @@
1
+ module Postnhost
2
+ class PageVariantsController < ApplicationController
3
+ PAGE_SIZE = 15
4
+
5
+ before_action :authenticate_user!
6
+ before_action :load_page
7
+ before_action :load_page_variant, only: %i[edit update publish unpublish destroy]
8
+ before_action :ensure_variant_editable, only: %i[edit update publish unpublish]
9
+
10
+ layout :resolve_layout
11
+
12
+ def index
13
+ variants = @page.page_variants.includes(:language, :page_variant_snapshot).order(:created_at)
14
+ @pagy, @variants = pagy(:offset, variants, limit: PAGE_SIZE)
15
+ @available_languages = Postnhost::Language.where.not(id: @page.page_variants.select(:language_id))
16
+ @available_languages = @available_languages.where.not(id: @page.language_id) if @page.language_id.present?
17
+ end
18
+
19
+ def new
20
+ @page_variant = @page.page_variants.new
21
+ @page_variant.language = Postnhost::Language.find_by(id: params[:language_id]) if params[:language_id]
22
+ @page_variant.title = @page.title
23
+ @page_variant.title_tag = @page.title_tag
24
+ @page_variant.og_title = @page.og_title
25
+ @page_variant.meta_description = @page.meta_description
26
+ @page_variant.content = @page.content
27
+ @page_variant.save!
28
+
29
+ redirect_to edit_page_variant_path(@page, @page_variant),
30
+ notice: "Page variant was successfully created."
31
+ end
32
+
33
+ def edit; end
34
+
35
+ def update
36
+ if @page_variant.update(variant_params)
37
+ respond_to do |format|
38
+ format.html do
39
+ redirect_to edit_page_variant_path(@page, @page_variant),
40
+ notice: "Translation was successfully updated."
41
+ end
42
+ format.json { head :no_content }
43
+ format.turbo_stream
44
+ end
45
+ else
46
+ render :edit, status: :unprocessable_content
47
+ end
48
+ end
49
+
50
+ def publish
51
+ result = Postnhost::Publishing::PageVariants::Publish.call(page_variant: @page_variant)
52
+ @page_variant.reload
53
+ error = result.errors.to_sentence unless result.success?
54
+
55
+ respond_to do |format|
56
+ format.html do
57
+ redirect_to edit_page_variant_path(@page, @page_variant), **(error.present? ? { alert: error } : {})
58
+ end
59
+ format.turbo_stream do
60
+ flash.now[:alert] = error if error.present?
61
+ render :publish, status: result.status
62
+ end
63
+ end
64
+ end
65
+
66
+ def unpublish
67
+ result = Postnhost::Publishing::PageVariants::Unpublish.call(page_variant: @page_variant)
68
+ @page_variant.reload
69
+ error = result.errors.to_sentence unless result.success?
70
+
71
+ respond_to do |format|
72
+ format.html do
73
+ redirect_to edit_page_variant_path(@page, @page_variant), **(error.present? ? { alert: error } : {})
74
+ end
75
+ format.turbo_stream do
76
+ flash.now[:alert] = error if error.present?
77
+ render :unpublish, status: result.status
78
+ end
79
+ end
80
+ end
81
+
82
+ def destroy
83
+ @page_variant.destroy
84
+ redirect_to page_variants_path(@page), notice: "Translation was successfully deleted."
85
+ end
86
+
87
+ def bulk_publish
88
+ result = Postnhost::Publishing::PageVariants::PublishMany.call(page: @page, ids: params[:ids])
89
+ error = result.errors.to_sentence unless result.success?
90
+ error = "#{result.value[:failures].count} translation(s) failed." if result.success? && result.value[:failures].any?
91
+
92
+ redirect_to page_variants_path(@page), **(error.present? ? { alert: error } : {})
93
+ end
94
+
95
+ def bulk_unpublish
96
+ result = Postnhost::Publishing::PageVariants::UnpublishMany.call(page: @page, ids: params[:ids])
97
+ error = result.errors.to_sentence unless result.success?
98
+ error = "#{result.value[:failures].count} translation(s) failed." if result.success? && result.value[:failures].any?
99
+
100
+ redirect_to page_variants_path(@page), **(error.present? ? { alert: error } : {})
101
+ end
102
+
103
+ def bulk_destroy
104
+ variants = @page.page_variants.where(id: params[:ids]).where.not(generating: true)
105
+ count = variants.count
106
+ variants.destroy_all
107
+
108
+ redirect_to page_variants_path(@page), notice: "#{count} translation(s) deleted successfully."
109
+ end
110
+
111
+ private
112
+
113
+ def load_page
114
+ @page = Postnhost::Page.find(params[:page_id])
115
+ end
116
+
117
+ def load_page_variant
118
+ @page_variant = @page.page_variants.find(params[:id])
119
+ end
120
+
121
+ def ensure_variant_editable
122
+ return unless @page_variant.generating?
123
+
124
+ redirect_to page_variants_path(@page),
125
+ alert: "This translation is still in progress. You can edit it when it finishes."
126
+ end
127
+
128
+ def variant_params
129
+ params.require(:page_variant).permit(:title, :title_tag, :og_title, :meta_description, :content, :language_id)
130
+ end
131
+
132
+ def resolve_layout
133
+ action_name == "index" ? "postnhost/product" : "postnhost/articles"
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,28 @@
1
+ module Postnhost
2
+ class Pages::ImagesController < ApplicationController
3
+ before_action :authenticate_user!
4
+ before_action :load_page
5
+
6
+ def create
7
+ file = params[:file]
8
+
9
+ if file.present?
10
+ result = @page.upload_inline_image(file)
11
+
12
+ if result[:success]
13
+ render json: { url: result[:url] }, status: :ok
14
+ else
15
+ render json: { error: result[:error] }, status: :unprocessable_content
16
+ end
17
+ else
18
+ render json: { error: "No file provided" }, status: :bad_request
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def load_page
25
+ @page = Postnhost::Page.find(params[:page_id])
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,26 @@
1
+ module Postnhost
2
+ class Pages::TranslationsController < ApplicationController
3
+ before_action :authenticate_user!
4
+ before_action :load_page
5
+
6
+ def new; end
7
+
8
+ def create
9
+ language_ids = params[:language_ids] || []
10
+
11
+ language_ids.each do |language_id|
12
+ Postnhost::Translation::PageVariantJob.perform_later(@page.id, language_id.to_i)
13
+
14
+ @page.page_variants.create!(language: Postnhost::Language.find_by(id: language_id.to_i), generating: true)
15
+ end
16
+
17
+ redirect_to page_variants_path(@page)
18
+ end
19
+
20
+ private
21
+
22
+ def load_page
23
+ @page = Postnhost::Page.find(params[:page_id])
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,95 @@
1
+ module Postnhost
2
+ class PagesController < ApplicationController
3
+ before_action :authenticate_user!
4
+ before_action :load_page, only: %i[edit update destroy publish unpublish]
5
+
6
+ layout :resolve_layout
7
+
8
+ def index
9
+ @pages = Postnhost::Page.includes(:page_snapshot).order(updated_at: :desc)
10
+ @static_page_slugs = static_page_slugs
11
+ end
12
+
13
+ def new
14
+ @page = current_user.pages.new
15
+ @page.language = Postnhost::Language.blog_default
16
+ @page.save!
17
+ redirect_to edit_page_path(@page)
18
+ end
19
+
20
+ def edit; end
21
+
22
+ def update
23
+ if @page.update(page_params)
24
+ respond_to do |format|
25
+ format.html { redirect_to edit_page_path(@page) }
26
+ format.json { head :no_content }
27
+ format.turbo_stream { head :no_content }
28
+ end
29
+ else
30
+ respond_to do |format|
31
+ format.html { render :edit, status: :unprocessable_content }
32
+ format.json { render json: { errors: @page.errors.full_messages }, status: :unprocessable_content }
33
+ format.turbo_stream { head :unprocessable_content }
34
+ end
35
+ end
36
+ end
37
+
38
+ def publish
39
+ result = Postnhost::Publishing::Pages::Publish.call(page: @page)
40
+ @page.reload
41
+ error = result.errors.to_sentence unless result.success?
42
+
43
+ respond_to do |format|
44
+ format.html { redirect_to edit_page_path(@page), **(error.present? ? { alert: error } : {}) }
45
+ format.turbo_stream do
46
+ flash.now[:alert] = error if error.present?
47
+ render :publish, status: result.status
48
+ end
49
+ end
50
+ end
51
+
52
+ def unpublish
53
+ result = Postnhost::Publishing::Pages::Unpublish.call(page: @page)
54
+ @page.reload
55
+ error = result.errors.to_sentence unless result.success?
56
+
57
+ respond_to do |format|
58
+ format.html { redirect_to edit_page_path(@page), **(error.present? ? { alert: error } : {}) }
59
+ format.turbo_stream do
60
+ flash.now[:alert] = error if error.present?
61
+ render :unpublish, status: result.status
62
+ end
63
+ end
64
+ end
65
+
66
+ def destroy
67
+ @page.destroy
68
+ redirect_to pages_path
69
+ end
70
+
71
+ private
72
+
73
+ def load_page
74
+ @page = Postnhost::Page.find(params[:id])
75
+ end
76
+
77
+ def page_params
78
+ params.require(:page).permit(:title, :title_tag, :og_title, :meta_description, :content, :slug, :language_id)
79
+ end
80
+
81
+ def static_page_slugs
82
+ [
83
+ Rails.root.join("app/views/postnhost/static_pages/*.html.erb"),
84
+ Postnhost::Engine.root.join("app/views/postnhost/static_pages/*.html.erb")
85
+ ].flat_map { |pattern| Dir[pattern.to_s] }
86
+ .map { |template_path| File.basename(template_path, ".html.erb") }
87
+ .uniq
88
+ .sort
89
+ end
90
+
91
+ def resolve_layout
92
+ action_name == "index" ? "postnhost/product" : "postnhost/articles"
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,135 @@
1
+ module Postnhost
2
+ class Public::ArticlesController < Postnhost::PublicController
3
+ TOP_PICKS_LIMIT = 4
4
+
5
+ include CanonicalFirstPageRedirect
6
+ include LanguageSwitcher
7
+ include PublicFooterData
8
+ include PublicHttpCaching
9
+ include PublicPageSize
10
+ include PublicTemplateResolvable
11
+
12
+ layout :resolved_public_layout_template
13
+
14
+ before_action :authenticate_user!, only: %i[preview]
15
+ before_action :ensure_search_enabled, only: %i[search]
16
+ before_action :redirect_page_one_to_canonical!, only: %i[index search]
17
+
18
+ def index
19
+ apply_public_http_cache(params[:page].to_i, template: resolve_public_template("articles/index"))
20
+ return if performed?
21
+
22
+ set_footer_data
23
+ load_index_collection
24
+ render_public_template("articles/index")
25
+ end
26
+
27
+ def search
28
+ @search_query = normalized_search_query
29
+ apply_public_http_cache(@search_query, params[:page].to_i, template: resolve_public_template("articles/index"))
30
+ return if performed?
31
+
32
+ set_footer_data
33
+ load_index_collection(search_only: true)
34
+
35
+ render_public_template("articles/index")
36
+ end
37
+
38
+ def show
39
+ resolution = public_route_resolution(kind: :article, slug: params[:slug])
40
+ apply_public_http_cache(resolution.record_id, template: resolve_public_template("articles/show"))
41
+ return if performed?
42
+
43
+ @article = resolution.record
44
+ raise ActiveRecord::RecordNotFound if !localized_request? && @article.language_id != @current_language.id
45
+
46
+ preload_article_associations
47
+
48
+ if localized_request?
49
+ @article_variant = @article.article_variant_snapshots.for_language(@current_language).first!
50
+ @content = @article_variant
51
+ @actual_language = @article_variant.language
52
+ @primary_language = @article.language
53
+ else
54
+ @content = @article
55
+ @actual_language = @current_language
56
+ @primary_language = @current_language
57
+ end
58
+
59
+ @available_variants = @article.article_variant_snapshots.with_language.to_a
60
+
61
+ set_footer_data
62
+ render_public_template("articles/show")
63
+ end
64
+
65
+ def preview
66
+ set_footer_data
67
+ @article = Postnhost::Article.includes(:categories, :language, article_authors: :user).find(params[:id])
68
+
69
+ @content = @article
70
+ @actual_language = @article.language || @current_language
71
+
72
+ @available_variants = @article.article_variants.includes(:language)
73
+
74
+ render_public_template("articles/preview")
75
+ end
76
+
77
+ private
78
+
79
+ def ensure_search_enabled
80
+ raise_not_found unless current_setting.search_enabled?
81
+ end
82
+
83
+ def load_index_collection(search_only: false)
84
+ load_top_pick_collection unless search_only || params[:page].to_i > 1
85
+
86
+ collection = public_article_collection
87
+ collection = collection.matching(@search_query) if search_only
88
+ @pagy, snapshots = pagy(:offset, collection, limit: public_page_size, slots: pagination_slots)
89
+ assign_article_collection(snapshots)
90
+ end
91
+
92
+ def load_top_pick_collection
93
+ snapshots = public_article_collection.top_picks.limit(TOP_PICKS_LIMIT)
94
+
95
+ if localized_request?
96
+ @top_pick_article_variant_snapshots = snapshots
97
+ else
98
+ @top_pick_article_snapshots = snapshots
99
+ end
100
+ end
101
+
102
+ def preload_article_associations
103
+ associations = [:categories, { article_snapshot_authors: :user }]
104
+ associations << :language if localized_request?
105
+ ActiveRecord::Associations::Preloader.new(records: [@article], associations:).call
106
+ end
107
+
108
+ def public_article_collection
109
+ if localized_request?
110
+ Postnhost::Snapshot::ArticleVariant.for_language(@current_language)
111
+ .with_language
112
+ .with_categories
113
+ .with_authors
114
+ .recent_first
115
+ else
116
+ Postnhost::Snapshot::Article.for_language(@current_language)
117
+ .with_categories
118
+ .with_authors
119
+ .recent_first
120
+ end
121
+ end
122
+
123
+ def assign_article_collection(snapshots)
124
+ if localized_request?
125
+ @article_variant_snapshots = snapshots
126
+ else
127
+ @article_snapshots = snapshots
128
+ end
129
+ end
130
+
131
+ def normalized_search_query
132
+ params[:s].to_s.strip.presence
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,57 @@
1
+ module Postnhost
2
+ class Public::AuthorsController < Postnhost::PublicController
3
+ include CanonicalFirstPageRedirect
4
+ include LanguageSwitcher
5
+ include PublicFooterData
6
+ include PublicHttpCaching
7
+ include PublicPageSize
8
+ include PublicTemplateResolvable
9
+
10
+ layout :resolved_public_layout_template
11
+
12
+ before_action :ensure_author_pages_enabled
13
+ before_action :redirect_page_one_to_canonical!, only: %i[show]
14
+ before_action :load_author
15
+
16
+ def show
17
+ apply_public_http_cache(@author, params[:page].to_i, template: resolve_public_template("authors/show"))
18
+ return if performed?
19
+
20
+ set_footer_data
21
+ @pagy, snapshots = pagy(:offset, author_articles, limit: public_page_size, slots: pagination_slots)
22
+
23
+ if localized_request?
24
+ @article_variant_snapshots = snapshots
25
+ else
26
+ @article_snapshots = snapshots
27
+ end
28
+
29
+ render_public_template("authors/show")
30
+ end
31
+
32
+ private
33
+
34
+ def ensure_author_pages_enabled
35
+ raise_not_found unless current_setting.author_pages_enabled?
36
+ end
37
+
38
+ def load_author
39
+ @author = Postnhost::User.find_by!(slug: params[:slug])
40
+ end
41
+
42
+ def author_articles
43
+ if localized_request?
44
+ Postnhost::Snapshot::ArticleVariant.for_language(@current_language)
45
+ .by_author(@author)
46
+ .with_language
47
+ .with_categories
48
+ .recent_first
49
+ else
50
+ Postnhost::Snapshot::Article.for_language(@current_language)
51
+ .by_author(@author)
52
+ .with_categories
53
+ .recent_first
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,61 @@
1
+ module Postnhost
2
+ class Public::CategoriesController < Postnhost::PublicController
3
+ include CanonicalFirstPageRedirect
4
+ include LanguageSwitcher
5
+ include PublicFooterData
6
+ include PublicHttpCaching
7
+ include PublicPageSize
8
+ include PublicTemplateResolvable
9
+
10
+ layout :resolved_public_layout_template
11
+
12
+ before_action :redirect_page_one_to_canonical!, only: %i[index]
13
+
14
+ def index
15
+ resolution = public_route_resolution(kind: :category, slug: params[:category_slug])
16
+ apply_public_http_cache(resolution.record_id, params[:page].to_i,
17
+ template: resolve_public_template("categories/index"))
18
+ return if performed?
19
+
20
+ @category = resolution.record
21
+ ActiveRecord::Associations::Preloader.new(records: [@category], associations: :category_variants).call unless @current_language.default?
22
+ set_category_language_data
23
+ set_footer_data
24
+ @pagy, snapshots = pagy(:offset, category_articles, limit: public_page_size, slots: pagination_slots)
25
+
26
+ if localized_request?
27
+ @article_variant_snapshots = snapshots
28
+ else
29
+ @article_snapshots = snapshots
30
+ end
31
+
32
+ render_public_template("categories/index")
33
+ end
34
+
35
+ private
36
+
37
+ def set_category_language_data
38
+ @published_category_language_ids = Postnhost::Snapshot::ArticleVariant
39
+ .in_category(@category)
40
+ .distinct
41
+ .pluck(:language_id)
42
+ @localized_category_fallback = localized_request? &&
43
+ @published_category_language_ids.exclude?(@current_language.id)
44
+ end
45
+
46
+ def category_articles
47
+ if localized_request?
48
+ Postnhost::Snapshot::ArticleVariant.for_language(@current_language)
49
+ .in_category(@category)
50
+ .with_language
51
+ .with_authors
52
+ .recent_first
53
+ else
54
+ Postnhost::Snapshot::Article.for_language(@current_language)
55
+ .in_category(@category)
56
+ .with_authors
57
+ .recent_first
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,77 @@
1
+ module Postnhost
2
+ class Public::StaticPagesController < Postnhost::PublicController
3
+ include LanguageSwitcher
4
+ include PublicFooterData
5
+ include PublicHttpCaching
6
+ include PublicTemplateResolvable
7
+
8
+ layout :resolved_public_layout_template
9
+
10
+ def show
11
+ raise ActionController::RoutingError, "Not Found" unless valid_slug?
12
+
13
+ if page_resolution.present?
14
+ apply_public_http_cache(page_resolution.record_id, template: resolve_public_template("static_pages/show"))
15
+ return if performed?
16
+
17
+ set_footer_data
18
+ load_dynamic_page
19
+ return render_public_template("static_pages/show")
20
+ end
21
+
22
+ template = resolved_template
23
+ raise ActionController::RoutingError, "Not Found" unless template
24
+
25
+ apply_public_http_cache(params[:slug], template:)
26
+ return if performed?
27
+
28
+ set_footer_data
29
+ render template: template
30
+ end
31
+
32
+ private
33
+
34
+ def resolved_template
35
+ template_path = "postnhost/static_pages/#{params[:slug]}"
36
+ template_path if lookup_context.exists?(template_path, [], false)
37
+ end
38
+
39
+ def page_resolution
40
+ return @page_resolution if defined?(@page_resolution)
41
+
42
+ @page_resolution = public_request_context.resolve_kind(:page, params[:slug])
43
+ end
44
+
45
+ def load_dynamic_page
46
+ @page = page_snapshot
47
+ @available_variants = @page.page_variant_snapshots.with_language
48
+
49
+ if localized_request?
50
+ @content = dynamic_page_variant
51
+ @actual_language = dynamic_page_variant.language
52
+ else
53
+ @content = page_snapshot
54
+ @actual_language = page_snapshot.language || @current_language
55
+ end
56
+ end
57
+
58
+ def page_snapshot
59
+ return @page_snapshot if defined?(@page_snapshot)
60
+
61
+ @page_snapshot = page_resolution.record
62
+ ActiveRecord::Associations::Preloader.new(records: [@page_snapshot], associations: %i[page language]).call
63
+ @page_snapshot
64
+ end
65
+
66
+ def dynamic_page_variant
67
+ return @dynamic_page_variant if defined?(@dynamic_page_variant)
68
+ return @dynamic_page_variant = nil if page_snapshot.blank?
69
+
70
+ @dynamic_page_variant = page_snapshot.page_variant_snapshots.for_language(@current_language).first
71
+ end
72
+
73
+ def valid_slug?
74
+ params[:slug].to_s.match?(/\A[a-z0-9_-]+\z/)
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,32 @@
1
+ module Postnhost
2
+ class PublicController < ApplicationController
3
+ before_action :validate_public_locale
4
+ before_action :redirect_to_onboarding_without_users
5
+
6
+ private
7
+
8
+ def current_setting
9
+ public_request_context.setting
10
+ end
11
+
12
+ def public_route_resolution(kind:, slug:)
13
+ resolution = public_request_context.resolve(slug)
14
+ raise ActiveRecord::RecordNotFound unless resolution&.kind == kind
15
+
16
+ resolution
17
+ end
18
+
19
+ def raise_not_found
20
+ raise ActionController::RoutingError, "Not Found"
21
+ end
22
+
23
+ def validate_public_locale
24
+ language = public_request_context.requested_language
25
+ raise_not_found if params[:locale].present? && (language.blank? || language.default?)
26
+ end
27
+
28
+ def redirect_to_onboarding_without_users
29
+ redirect_to postnhost.onboarding_path unless Postnhost::User.exists?
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,37 @@
1
+ module Postnhost
2
+ class SessionsController < ApplicationController
3
+ before_action :redirect_to_onboarding_when_needed, only: %i[new create]
4
+ before_action :skip_authentication, only: %i[new create]
5
+
6
+ def new; end
7
+
8
+ def create
9
+ user = Postnhost::User.find_by(email: session_params[:email].to_s.downcase.strip)
10
+
11
+ if user&.authenticate(session_params[:password].to_s)
12
+ sign_in(user)
13
+ redirect_to articles_path
14
+ else
15
+ flash.now[:alert] = "Invalid email or password."
16
+ render :new, status: :unprocessable_content
17
+ end
18
+ end
19
+
20
+ def destroy
21
+ sign_out
22
+ redirect_to root_path
23
+ end
24
+
25
+ private
26
+
27
+ def redirect_to_onboarding_when_needed
28
+ return if user_signed_in?
29
+
30
+ redirect_to postnhost.onboarding_path if Postnhost::User.none? || session[:onboarding_pending_user_id].present?
31
+ end
32
+
33
+ def session_params
34
+ params.require(:user).permit(:email, :password)
35
+ end
36
+ end
37
+ end