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,97 @@
1
+ module Postnhost
2
+ module CommonCssHelper
3
+ def icon(name, css_class: "w-4 h-4", subfolder: nil)
4
+ path_parts = %w[app assets images postnhost icons]
5
+ path_parts << subfolder if subfolder
6
+ path_parts << "#{name}.svg"
7
+
8
+ file_path = Postnhost::Engine.root.join(*path_parts)
9
+ svg_content = File.read(file_path)
10
+ svg_content.sub("<svg", "<svg class=\"#{css_class}\" aria-hidden=\"true\"").html_safe
11
+ end
12
+
13
+ # Button class helpers
14
+ def button_classes(variant: :primary, disabled: false)
15
+ base = "inline-flex items-center justify-center font-medium rounded-xl transition-all duration-200 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 px-4 py-2"
16
+
17
+ variant_classes = case variant
18
+ when :secondary
19
+ "bg-white text-gray-700 hover:bg-gray-50 border border-gray-200 focus:ring-gray-500"
20
+ when :danger
21
+ "bg-red-50 text-red-700 hover:bg-red-100 focus:ring-red-500"
22
+ else
23
+ "bg-gray-900 text-white hover:bg-gray-800 focus:ring-gray-900"
24
+ end
25
+
26
+ disabled_classes = disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
27
+
28
+ "#{base} #{variant_classes} #{disabled_classes}"
29
+ end
30
+
31
+ def icon_button_classes(variant: :default)
32
+ base = "text-gray-400 transition-colors"
33
+
34
+ hover_classes = case variant
35
+ when :danger then "hover:text-red-600"
36
+ else "hover:text-gray-600"
37
+ end
38
+
39
+ "#{base} #{hover_classes}"
40
+ end
41
+
42
+ def page_header_classes
43
+ "flex flex-col space-y-4 md:flex-row md:items-center md:justify-between md:space-y-0"
44
+ end
45
+
46
+ def form_card_classes
47
+ "bg-white rounded-2xl border border-gray-200 p-6 space-y-6"
48
+ end
49
+
50
+ def error_container_classes
51
+ "bg-red-50 border border-red-200 rounded-lg p-4"
52
+ end
53
+
54
+ def table_row_classes
55
+ "px-6 py-4 hover:bg-gray-50 transition-colors duration-200"
56
+ end
57
+
58
+ def stats_badge_classes(variant: :blue)
59
+ base = "items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
60
+
61
+ color_classes = case variant
62
+ when :purple then "bg-purple-100 text-purple-800"
63
+ when :green then "bg-green-100 text-green-800"
64
+ when :gray then "bg-gray-100 text-gray-800"
65
+ else "bg-blue-100 text-blue-800"
66
+ end
67
+
68
+ "#{base} #{color_classes}"
69
+ end
70
+
71
+ def editor_button_classes(variant: :secondary)
72
+ base = "w-full py-2.5 px-4 rounded-xl focus:outline-none focus:ring-2 focus:ring-offset-2 transition duration-200 text-sm font-medium text-center block"
73
+
74
+ variant_classes = case variant
75
+ when :primary
76
+ "bg-green-600 text-white hover:bg-green-700 focus:ring-green-600"
77
+ else
78
+ "bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-gray-900 cursor-pointer"
79
+ end
80
+
81
+ "#{base} #{variant_classes}"
82
+ end
83
+
84
+ # Form field class helpers
85
+ def form_label_classes
86
+ "block text-sm font-medium text-gray-700 mb-2"
87
+ end
88
+
89
+ def form_input_classes
90
+ "block w-full rounded-xl border-gray-200 bg-gray-50 px-4 py-3 text-sm font-medium text-gray-900 transition-all duration-200 focus:border-gray-900 focus:bg-white focus:ring-0"
91
+ end
92
+
93
+ def form_textarea_classes
94
+ "block w-full rounded-xl border-gray-200 bg-gray-50 px-4 py-3 text-sm font-medium text-gray-900 transition-all duration-200 focus:border-gray-900 focus:bg-white focus:ring-0"
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,148 @@
1
+ module Postnhost
2
+ module NavigationHelper
3
+ def navigation_label(item)
4
+ return if item.blank?
5
+
6
+ @navigation_label_cache ||= {}
7
+ cache_key = navigation_item_cache_key(item)
8
+ return @navigation_label_cache[cache_key] if @navigation_label_cache.key?(cache_key)
9
+
10
+ @navigation_label_cache[cache_key] =
11
+ item.label_for(locale_key: @current_language&.html_lang, default_locale_key: default_navigation_locale_key).presence ||
12
+ navigation_target_label(item)
13
+ end
14
+
15
+ def navigation_href(item)
16
+ return if item.blank? || item.kind != "link"
17
+
18
+ @navigation_href_cache ||= {}
19
+ cache_key = navigation_item_cache_key(item)
20
+ return @navigation_href_cache[cache_key] if @navigation_href_cache.key?(cache_key)
21
+
22
+ @navigation_href_cache[cache_key] = resolve_navigation_href(item)
23
+ end
24
+
25
+ def navigation_link_options(item)
26
+ return {} unless item.target_kind == "external"
27
+
28
+ rel_value = item.nofollow ? "noopener noreferrer nofollow" : "noopener noreferrer"
29
+ { target: "_blank", rel: rel_value }
30
+ end
31
+
32
+ def default_navigation_locale_key
33
+ @default_navigation_locale_key ||= @default_language&.html_lang || I18n.default_locale.to_s
34
+ end
35
+
36
+ private
37
+
38
+ def resolve_navigation_href(item)
39
+ case item.target_kind
40
+ when "article"
41
+ article = navigation_target(item)
42
+ return unless article
43
+
44
+ if localized_navigation?
45
+ variant = navigation_variant(article)
46
+ return postnhost.localized_public_article_path(@current_language.html_lang, article.slug) if variant.present?
47
+ end
48
+
49
+ postnhost.public_article_path(article.slug)
50
+ when "page"
51
+ page = navigation_target(item)
52
+ return unless page
53
+
54
+ if localized_navigation?
55
+ variant = navigation_variant(page)
56
+ return postnhost.localized_public_static_page_path(locale: @current_language.html_lang, slug: page.slug) if variant.present?
57
+ end
58
+
59
+ postnhost.public_static_page_path(slug: page.slug)
60
+ when "category"
61
+ category = navigation_target(item)
62
+ return if category.blank?
63
+
64
+ language_category_path(category.slug, @current_language)
65
+ when "static_page"
66
+ language_static_page_path(item.target_slug, @current_language)
67
+ when "external"
68
+ item.url
69
+ when "text"
70
+ nil
71
+ end
72
+ end
73
+
74
+ def navigation_target_label(item)
75
+ @navigation_target_label_cache ||= {}
76
+ cache_key = navigation_target_cache_key(item)
77
+ return @navigation_target_label_cache[cache_key] if @navigation_target_label_cache.key?(cache_key)
78
+
79
+ @navigation_target_label_cache[cache_key] = resolve_navigation_target_label(item)
80
+ end
81
+
82
+ def resolve_navigation_target_label(item)
83
+ case item.target_kind
84
+ when "article"
85
+ article = navigation_target(item)
86
+ return if article.blank?
87
+
88
+ if localized_navigation?
89
+ variant = navigation_variant(article)
90
+ return variant.title if variant.present?
91
+ end
92
+
93
+ article.title
94
+ when "page"
95
+ page = navigation_target(item)
96
+ return if page.blank?
97
+
98
+ if localized_navigation?
99
+ variant = navigation_variant(page)
100
+ return variant.title if variant.present?
101
+ end
102
+
103
+ page.title
104
+ when "category"
105
+ category = navigation_target(item)
106
+ category.present? ? localized_category_name(category, @current_language) : nil
107
+ when "static_page"
108
+ item.target_slug.to_s.tr("_-", " ").humanize
109
+ when "external"
110
+ item.url
111
+ when "text"
112
+ nil
113
+ end
114
+ end
115
+
116
+ def navigation_target(item)
117
+ @navigation_target_cache.fetch(item.target_kind, {})[item.target_id]
118
+ end
119
+
120
+ def navigation_variant(record)
121
+ case record
122
+ when Postnhost::Snapshot::Article
123
+ @navigation_variant_cache.fetch(record.class.name, {})[record.article_id]
124
+ when Postnhost::Snapshot::Page
125
+ @navigation_variant_cache.fetch(record.class.name, {})[record.page_id]
126
+ end
127
+ end
128
+
129
+ def navigation_item_cache_key(item)
130
+ [item.id || item.object_id, @current_language&.id, @current_language&.html_lang]
131
+ end
132
+
133
+ def navigation_target_cache_key(item)
134
+ [
135
+ item.target_kind,
136
+ item.target_id,
137
+ item.target_slug,
138
+ item.url,
139
+ @current_language&.id,
140
+ @current_language&.html_lang
141
+ ]
142
+ end
143
+
144
+ def localized_navigation?
145
+ @current_language.present? && !@current_language.default?
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,103 @@
1
+ module Postnhost
2
+ module PagyHelper
3
+ DEFAULT_PAGINATION_SLOTS = 7
4
+
5
+ def pagination_nav(pagy)
6
+ return "".html_safe if pagy.pages <= 1
7
+
8
+ content_tag(:nav,
9
+ class: "flex items-center justify-center space-x-2",
10
+ aria: { label: I18n.t("postnhost.public.pagination.navigation", default: "Pagination") }) do
11
+ safe_join(
12
+ [
13
+ pagination_previous_link(pagy),
14
+ *pagination_series_fragments(pagy),
15
+ pagination_next_link(pagy)
16
+ ],
17
+ ""
18
+ )
19
+ end
20
+ end
21
+
22
+ # Keep first pages canonical while preserving unrelated query parameters.
23
+ def pagination_url(pagy, page)
24
+ pagy.page_url(page == 1 ? :first : page)
25
+ end
26
+
27
+ def pagination_series(pagy)
28
+ slots = pagy.options.fetch(:slots, DEFAULT_PAGINATION_SLOTS)
29
+ return [] if slots.zero?
30
+ return (1..pagy.pages).to_a if slots >= pagy.pages
31
+
32
+ half = (slots - 1) / 2
33
+ start = if pagy.page <= half
34
+ 1
35
+ elsif pagy.page > pagy.pages - slots + half
36
+ pagy.pages - slots + 1
37
+ else
38
+ pagy.page - half
39
+ end
40
+
41
+ (start...(start + slots)).to_a.tap do |series|
42
+ next if pagy.options[:compact] || slots < DEFAULT_PAGINATION_SLOTS
43
+
44
+ series[0] = 1
45
+ series[1] = :gap unless series[1] == 2
46
+ series[-2] = :gap unless series[-2] == pagy.pages - 1
47
+ series[-1] = pagy.pages
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def pagination_previous_link(pagy)
54
+ label = I18n.t("postnhost.public.pagination.previous")
55
+ enabled_classes = "inline-flex items-center justify-center p-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 hover:text-gray-700 cursor-pointer"
56
+ disabled_classes = "inline-flex items-center justify-center p-2 text-sm font-medium text-gray-600 bg-gray-100 border border-gray-200 rounded-lg cursor-not-allowed opacity-50"
57
+
58
+ if pagy.previous
59
+ link_to(pagination_url(pagy, pagy.previous), class: enabled_classes, aria: { label: label }) do
60
+ icon("chevron-left", css_class: "h-5 w-5")
61
+ end
62
+ else
63
+ tag.span(icon("chevron-left", css_class: "h-5 w-5"),
64
+ class: disabled_classes,
65
+ aria: { label: label, disabled: true })
66
+ end
67
+ end
68
+
69
+ def pagination_next_link(pagy)
70
+ label = I18n.t("postnhost.public.pagination.next")
71
+ enabled_classes = "inline-flex items-center justify-center p-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 hover:text-gray-700 cursor-pointer"
72
+ disabled_classes = "inline-flex items-center justify-center p-2 text-sm font-medium text-gray-600 bg-gray-100 border border-gray-200 rounded-lg cursor-not-allowed opacity-50"
73
+
74
+ if pagy.next
75
+ link_to(pagination_url(pagy, pagy.next), class: enabled_classes, aria: { label: label }) do
76
+ icon("chevron-right", css_class: "h-5 w-5")
77
+ end
78
+ else
79
+ tag.span(icon("chevron-right", css_class: "h-5 w-5"),
80
+ class: disabled_classes,
81
+ aria: { label: label, disabled: true })
82
+ end
83
+ end
84
+
85
+ def pagination_series_fragments(pagy)
86
+ pagination_series(pagy).filter_map do |item|
87
+ case item
88
+ when Integer
89
+ if item == pagy.page
90
+ tag.span(item,
91
+ class: "px-3 py-2 text-sm font-medium text-white bg-gray-900 border border-gray-900 rounded-lg",
92
+ aria: { current: "page" })
93
+ else
94
+ link_to(item, pagination_url(pagy, item),
95
+ class: "px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 hover:text-gray-700 cursor-pointer")
96
+ end
97
+ when :gap
98
+ tag.span("…", class: "px-3 py-2 text-sm font-medium text-gray-300")
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,287 @@
1
+ module Postnhost
2
+ module PublicPagesHelper
3
+ def document_lang
4
+ return content_for(:lang) if content_for?(:lang)
5
+
6
+ @current_language&.html_lang.presence || I18n.locale.to_s.presence || "en"
7
+ end
8
+
9
+ # Language switcher helpers
10
+ def current_path_without_locale
11
+ base_path = request.path_info.to_s
12
+ base_path = "/" if base_path.blank?
13
+
14
+ locale = params[:locale].presence
15
+ stripped_path = locale.present? ? base_path.sub(%r{\A/#{Regexp.escape(locale)}(?=/|$)}, "") : base_path
16
+
17
+ stripped_path.presence || "/"
18
+ end
19
+
20
+ def localized_path(language)
21
+ base_path = current_path_without_locale
22
+ base_path = "/" if base_path.empty?
23
+
24
+ locale_path = if language.default
25
+ base_path
26
+ else
27
+ base_path == "/" ? "/#{language.html_lang}" : "/#{language.html_lang}#{base_path}"
28
+ end
29
+
30
+ public_template_preview_path("#{request.script_name}#{locale_path}")
31
+ end
32
+
33
+ def public_template_preview_path(path)
34
+ previewing_template = respond_to?(:user_signed_in?, true) && user_signed_in? && params[:preview_template].present?
35
+ return path unless previewing_template
36
+
37
+ "#{path}?preview_template=#{ERB::Util.url_encode(params[:preview_template])}"
38
+ end
39
+
40
+ # Language-aware navigation helpers for public pages
41
+ def language_root_path(language)
42
+ return postnhost.root_path unless language
43
+
44
+ if language.default
45
+ postnhost.root_path
46
+ else
47
+ postnhost.localized_root_path(locale: language.html_lang)
48
+ end
49
+ end
50
+
51
+ def language_search_path(language)
52
+ return postnhost.public_search_path unless language
53
+
54
+ if language.default
55
+ postnhost.public_search_path
56
+ else
57
+ postnhost.localized_public_search_path(language.html_lang)
58
+ end
59
+ end
60
+
61
+ def language_search_url(language)
62
+ return postnhost.public_search_url unless language
63
+
64
+ if language.default
65
+ postnhost.public_search_url
66
+ else
67
+ postnhost.localized_public_search_url(language.html_lang)
68
+ end
69
+ end
70
+
71
+ def language_category_path(category_slug, language)
72
+ return postnhost.public_category_path(category_slug) unless language
73
+
74
+ if language.default
75
+ postnhost.public_category_path(category_slug)
76
+ else
77
+ postnhost.localized_public_category_path(language.html_lang, category_slug)
78
+ end
79
+ end
80
+
81
+ def language_static_page_path(page_slug, language)
82
+ return postnhost.public_static_page_path(slug: page_slug) unless language
83
+
84
+ if language.default
85
+ postnhost.public_static_page_path(slug: page_slug)
86
+ else
87
+ postnhost.localized_public_static_page_path(locale: language.html_lang, slug: page_slug)
88
+ end
89
+ end
90
+
91
+ def language_author_path(author_slug, language)
92
+ return unless author_pages_enabled?
93
+ return postnhost.public_author_path(author_slug) unless language
94
+
95
+ if language.default
96
+ postnhost.public_author_path(author_slug)
97
+ else
98
+ postnhost.localized_public_author_path(language.html_lang, author_slug)
99
+ end
100
+ end
101
+
102
+ def language_author_url(author_slug, language)
103
+ return unless author_pages_enabled?
104
+ return postnhost.public_author_url(author_slug) unless language
105
+
106
+ if language.default
107
+ postnhost.public_author_url(author_slug)
108
+ else
109
+ postnhost.localized_public_author_url(language.html_lang, author_slug)
110
+ end
111
+ end
112
+
113
+ # Public page metadata helpers
114
+ def public_page_number
115
+ params[:page].to_i
116
+ end
117
+
118
+ def paginated_public_page?
119
+ public_page_number > 1
120
+ end
121
+
122
+ def public_blog_index_title
123
+ if paginated_public_page?
124
+ t(
125
+ "postnhost.public.site.blog_meta_title_paginated",
126
+ page: public_page_number,
127
+ blog_meta_title: t("postnhost.public.site.blog_meta_title")
128
+ )
129
+ else
130
+ t("postnhost.public.site.blog_meta_title")
131
+ end
132
+ end
133
+
134
+ def public_search_query
135
+ @search_query.presence || params[:s].to_s.strip.presence
136
+ end
137
+
138
+ def public_search_endpoint?
139
+ controller_name == "articles" && action_name == "search"
140
+ end
141
+
142
+ def public_search_page?
143
+ public_search_query.present?
144
+ end
145
+
146
+ def public_search_title
147
+ t("postnhost.public.search.title", query: public_search_query)
148
+ end
149
+
150
+ def public_search_heading
151
+ t("postnhost.public.search.heading", query: public_search_query)
152
+ end
153
+
154
+ def public_articles_index_title
155
+ return public_search_title if public_search_page?
156
+ return t("postnhost.public.search.submit") if public_search_endpoint?
157
+
158
+ public_blog_index_title
159
+ end
160
+
161
+ def public_articles_index_heading
162
+ public_search_page? ? public_search_heading : public_blog_heading
163
+ end
164
+
165
+ def public_search_trigger_url
166
+ language_search_path(@current_language)
167
+ end
168
+
169
+ def public_blog_index_url
170
+ if public_search_endpoint?
171
+ paginated_public_page? ? language_search_url(@current_language) : request.original_url
172
+ else
173
+ paginated_public_page? ? postnhost.root_url : request.original_url
174
+ end
175
+ end
176
+
177
+ def public_blog_heading
178
+ if paginated_public_page?
179
+ t("postnhost.public.blog.page_title", page: public_page_number)
180
+ else
181
+ t("postnhost.public.site.blog_tagline")
182
+ end
183
+ end
184
+
185
+ def show_public_blog_subtitle?
186
+ !paginated_public_page? && !public_search_page? && !public_search_endpoint?
187
+ end
188
+
189
+ def public_category_title(category)
190
+ if paginated_public_page?
191
+ t(
192
+ "postnhost.public.category.page_title",
193
+ page: public_page_number,
194
+ category: localized_category_name(category, @current_language)
195
+ )
196
+ else
197
+ t(
198
+ "postnhost.public.category.title",
199
+ category: localized_category_name(category, @current_language)
200
+ )
201
+ end
202
+ end
203
+
204
+ def public_category_index_url(category)
205
+ return postnhost.public_category_url(category.slug) if @localized_category_fallback
206
+
207
+ paginated_public_page? ? postnhost.public_category_url(category.slug) : request.original_url
208
+ end
209
+
210
+ def public_category_heading(category)
211
+ if paginated_public_page?
212
+ t(
213
+ "postnhost.public.category.page_heading",
214
+ page: public_page_number,
215
+ category: localized_category_name(category, @current_language)
216
+ )
217
+ else
218
+ localized_category_name(category, @current_language)
219
+ end
220
+ end
221
+
222
+ def public_category_description(category)
223
+ localized_category_meta_description(category, @current_language)
224
+ end
225
+
226
+ def show_public_category_description?(category)
227
+ public_category_description(category).present? && !paginated_public_page?
228
+ end
229
+
230
+ # Public layout language switcher helpers
231
+ def public_article_show_page?
232
+ controller_name == "articles" && action_name == "show"
233
+ end
234
+
235
+ def public_static_page_show_page?
236
+ controller_name == "static_pages" && action_name == "show"
237
+ end
238
+
239
+ def show_article_variant_language_switcher?
240
+ return false unless public_article_show_page?
241
+ return false if @available_variants.nil?
242
+
243
+ if @available_variants.respond_to?(:exists?)
244
+ @available_variants.offset(1).exists? || (@article.present? && @available_variants.exists?)
245
+ else
246
+ @available_variants.size > 1 || (@article.present? && @available_variants.any?)
247
+ end
248
+ end
249
+
250
+ def show_page_variant_language_switcher?
251
+ return false unless public_static_page_show_page?
252
+ return false if @available_variants.nil?
253
+
254
+ if @available_variants.respond_to?(:exists?)
255
+ @available_variants.offset(1).exists? || (@page.present? && @available_variants.exists?)
256
+ else
257
+ @available_variants.size > 1 || (@page.present? && @available_variants.any?)
258
+ end
259
+ end
260
+
261
+ def show_other_languages_switcher?
262
+ !public_article_show_page? && !public_static_page_show_page? && @other_languages&.any?
263
+ end
264
+
265
+ def localized_language_options(primary)
266
+ options = [[@current_language.name, localized_path(@current_language)]]
267
+ primary_language = primary.is_a?(Postnhost::Snapshot::Article) ? @primary_language : primary&.language
268
+
269
+ options << [primary_language.name, localized_path(primary_language)] if primary_language.present? && primary_language != @current_language
270
+
271
+ return options if @available_variants.blank?
272
+
273
+ @available_variants.each do |variant|
274
+ next if variant.language == @current_language
275
+
276
+ options << [variant.language.name, localized_path(variant.language)]
277
+ end
278
+
279
+ options.uniq { |(_, path)| path }
280
+ end
281
+
282
+ def default_language_options
283
+ [[@current_language.name, localized_path(@current_language)]] +
284
+ @other_languages.to_a.map { |language| [language.name, localized_path(language)] }
285
+ end
286
+ end
287
+ end