postnhost 0.0.0 → 0.1.1

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 (500) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/{LICENSE.txt → LICENSE} +6 -6
  4. data/README.md +343 -15
  5. data/Rakefile +66 -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 +347 -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 +236 -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 +55 -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 +100 -0
  476. data/lib/generators/postnhost/tailwindcss/templates/host.tailwind.css +4 -0
  477. data/lib/generators/postnhost/user/user_generator.rb +36 -0
  478. data/lib/generators/postnhost/views/views_generator.rb +140 -0
  479. data/lib/postnhost/configuration.rb +64 -0
  480. data/lib/postnhost/css_build.rb +68 -0
  481. data/lib/postnhost/css_scope.rb +77 -0
  482. data/lib/postnhost/engine.rb +57 -0
  483. data/lib/postnhost/sample_data.rb +865 -0
  484. data/lib/postnhost/settings/i18n_overrides.rb +137 -0
  485. data/lib/postnhost/settings/i18n_patch.rb +18 -0
  486. data/lib/postnhost/tailwind_compiler.rb +75 -0
  487. data/lib/postnhost/tailwind_input.rb +32 -0
  488. data/lib/postnhost/version.rb +1 -3
  489. data/lib/postnhost.rb +26 -3
  490. data/lib/tasks/postnhost_snapshots.rake +77 -0
  491. data/lib/tasks/postnhost_tasks.rake +71 -0
  492. metadata +679 -21
  493. data/.github/workflows/main.yml +0 -27
  494. data/.gitignore +0 -11
  495. data/.rspec +0 -3
  496. data/.rubocop.yml +0 -8
  497. data/Gemfile +0 -13
  498. data/bin/console +0 -11
  499. data/bin/setup +0 -8
  500. data/postnhost.gemspec +0 -31
@@ -0,0 +1,812 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { Editor, Extension, textInputRule } from '@tiptap/core'
3
+ import StarterKit from '@tiptap/starter-kit'
4
+ import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
5
+ import HorizontalRule from '@tiptap/extension-horizontal-rule'
6
+ import TextAlign from '@tiptap/extension-text-align'
7
+ import Superscript from '@tiptap/extension-superscript'
8
+ import Subscript from '@tiptap/extension-subscript'
9
+ import Underline from '@tiptap/extension-underline'
10
+ import Highlight from '@tiptap/extension-highlight'
11
+ import Link from '@tiptap/extension-link'
12
+ import TaskList from '@tiptap/extension-task-list'
13
+ import TaskItem from '@tiptap/extension-task-item'
14
+ import ImageResizeBase from 'tiptap-extension-resize-image'
15
+
16
+ // Extend ImageResize to preserve alt attribute and output wrapper divs for alignment
17
+ const ImageResize = ImageResizeBase.extend({
18
+ addAttributes() {
19
+ return {
20
+ ...this.parent?.(),
21
+ alt: {
22
+ default: null,
23
+ parseHTML: element => element.getAttribute('alt'),
24
+ renderHTML: attributes => {
25
+ if (!attributes.alt) {
26
+ return {}
27
+ }
28
+ return { alt: attributes.alt }
29
+ },
30
+ },
31
+ }
32
+ },
33
+
34
+ renderHTML({ HTMLAttributes, node }) {
35
+ const { containerStyle, wrapperStyle, ...imgAttrs } = HTMLAttributes
36
+
37
+ // If no alignment styles, just render the img
38
+ if (!containerStyle && !wrapperStyle) {
39
+ return ['img', imgAttrs]
40
+ }
41
+
42
+ // Render with wrapper divs for alignment (flex container + positioned inner div)
43
+ return [
44
+ 'div',
45
+ { style: containerStyle || 'display: flex;', class: 'image-container' },
46
+ [
47
+ 'div',
48
+ { style: wrapperStyle || 'position: relative;', class: 'image-wrapper' },
49
+ ['img', imgAttrs]
50
+ ]
51
+ ]
52
+ },
53
+
54
+ parseHTML() {
55
+ return [
56
+ {
57
+ // Parse wrapper structure: div.image-container > div.image-wrapper > img
58
+ tag: 'div.image-container',
59
+ getAttrs: node => {
60
+ const wrapper = node.querySelector('.image-wrapper')
61
+ const img = wrapper?.querySelector('img') || node.querySelector('img')
62
+ if (!img) return false
63
+
64
+ return {
65
+ src: img.getAttribute('src'),
66
+ alt: img.getAttribute('alt'),
67
+ width: img.getAttribute('width'),
68
+ containerStyle: node.getAttribute('style'),
69
+ wrapperStyle: wrapper?.getAttribute('style'),
70
+ }
71
+ },
72
+ },
73
+ {
74
+ // Also parse plain img tags
75
+ tag: 'img[src]',
76
+ getAttrs: node => ({
77
+ src: node.getAttribute('src'),
78
+ alt: node.getAttribute('alt'),
79
+ width: node.getAttribute('width'),
80
+ }),
81
+ },
82
+ ]
83
+ },
84
+ })
85
+ import { Table } from '@tiptap/extension-table'
86
+ import { TableRow } from '@tiptap/extension-table-row'
87
+ import { TableHeader } from '@tiptap/extension-table-header'
88
+ import { TableCell } from '@tiptap/extension-table-cell'
89
+ import Youtube from '@tiptap/extension-youtube'
90
+ import { codeLowlight } from '../helpers/code_highlighting'
91
+ import { applyClasses, removeClasses, EDITOR_STATES } from "../helpers/style_helpers"
92
+
93
+ const SmartDashes = Extension.create({
94
+ name: 'smartDashes',
95
+ addInputRules() {
96
+ return [
97
+ textInputRule({ find: /–-$/, replace: '—' }),
98
+ textInputRule({ find: /---$/, replace: '—' }),
99
+ textInputRule({ find: /--$/, replace: '–' }),
100
+ ]
101
+ },
102
+ })
103
+
104
+ export default class extends Controller {
105
+ static targets = ["editor", "content", "linkDropdown", "linkInput", "altDropdown", "altInput", "youtubeDropdown", "youtubeInput", "codeLanguage"]
106
+ static values = {
107
+ content: String,
108
+ resourceType: String,
109
+ resourceId: String,
110
+ mountPath: { type: String, default: '' }
111
+ }
112
+
113
+ connect() {
114
+ this.initializeEditor()
115
+ window.addEventListener('resize', this.#positionCodeLanguageControl)
116
+ this.setupPasteHandler()
117
+ this.setupDragAndDropHandler()
118
+ }
119
+
120
+ disconnect() {
121
+ window.removeEventListener('resize', this.#positionCodeLanguageControl)
122
+ if (this.editor) {
123
+ this.editor.destroy()
124
+ }
125
+ }
126
+
127
+ initializeEditor() {
128
+ this.editor = new Editor({
129
+ element: this.editorTarget,
130
+ extensions: [
131
+ StarterKit.configure({
132
+ heading: {
133
+ levels: [1, 2, 3]
134
+ },
135
+ horizontalRule: false,
136
+ codeBlock: false,
137
+ }),
138
+ CodeBlockLowlight.configure({
139
+ lowlight: codeLowlight,
140
+ defaultLanguage: 'plaintext',
141
+ enableTabIndentation: true,
142
+ tabSize: 2,
143
+ }),
144
+ HorizontalRule,
145
+ SmartDashes,
146
+ TextAlign.configure({
147
+ types: ['heading', 'paragraph'],
148
+ }),
149
+
150
+ Superscript,
151
+ Subscript,
152
+ Underline,
153
+ Highlight.configure({
154
+ multicolor: true,
155
+ HTMLAttributes: {
156
+ class: 'highlight',
157
+ },
158
+ }),
159
+ Link.configure({
160
+ openOnClick: false,
161
+ HTMLAttributes: {
162
+ target: '_blank',
163
+ rel: 'noopener noreferrer nofollow',
164
+ },
165
+ }),
166
+ TaskList,
167
+ TaskItem.configure({
168
+ nested: true,
169
+ }),
170
+ ImageResize.configure({
171
+ inline: false,
172
+ allowBase64: false,
173
+ }),
174
+ Table.configure({
175
+ resizable: true,
176
+ HTMLAttributes: {
177
+ class: null,
178
+ },
179
+ }),
180
+ TableRow.configure({
181
+ HTMLAttributes: {
182
+ class: null,
183
+ },
184
+ }),
185
+ TableHeader.configure({
186
+ HTMLAttributes: {
187
+ class: null,
188
+ },
189
+ }),
190
+ TableCell.configure({
191
+ HTMLAttributes: {
192
+ class: null,
193
+ },
194
+ }),
195
+ Youtube.configure({
196
+ controls: true,
197
+ nocookie: true,
198
+ modestBranding: true,
199
+ width: 0,
200
+ height: 0,
201
+ HTMLAttributes: {
202
+ class: 'youtube-embed',
203
+ },
204
+ }),
205
+ ],
206
+ content: this.contentValue || '',
207
+ editorProps: {
208
+ attributes: {
209
+ class: 'prose max-w-none focus:outline-none min-h-[400px] p-4',
210
+ },
211
+ },
212
+ onUpdate: ({ editor }) => {
213
+ this.updateHiddenField(editor.getHTML())
214
+ // Dispatch event for auto-save
215
+ document.dispatchEvent(new CustomEvent('tiptap:content-changed'))
216
+ },
217
+ })
218
+
219
+ // Set initial content in hidden field
220
+ this.updateHiddenField(this.editor.getHTML())
221
+
222
+ // Focus the editor when loaded
223
+ this.editor.commands.focus()
224
+
225
+ // Setup toolbar buttons
226
+ this.setupToolbarButtons()
227
+ }
228
+
229
+ updateHiddenField(content) {
230
+ if (this.hasContentTarget) {
231
+ this.contentTarget.value = content
232
+ }
233
+ }
234
+
235
+ setupToolbarButtons() {
236
+ // Bold
237
+ this.setupButton('[data-action="bold"]', () => this.editor.chain().focus().toggleBold().run(), () => this.editor.isActive('bold'))
238
+
239
+ // Italic
240
+ this.setupButton('[data-action="italic"]', () => this.editor.chain().focus().toggleItalic().run(), () => this.editor.isActive('italic'))
241
+
242
+ // Strike
243
+ this.setupButton('[data-action="strike"]', () => this.editor.chain().focus().toggleStrike().run(), () => this.editor.isActive('strike'))
244
+
245
+ // Code
246
+ this.setupButton('[data-action="code"]', () => this.editor.chain().focus().toggleCode().run(), () => this.editor.isActive('code'))
247
+
248
+ // Underline
249
+ this.setupButton('[data-action="underline"]', () => this.editor.chain().focus().toggleUnderline().run(), () => this.editor.isActive('underline'))
250
+
251
+ // Superscript
252
+ this.setupButton('[data-action="superscript"]', () => this.editor.chain().focus().toggleSuperscript().run(), () => this.editor.isActive('superscript'))
253
+
254
+ // Subscript
255
+ this.setupButton('[data-action="subscript"]', () => this.editor.chain().focus().toggleSubscript().run(), () => this.editor.isActive('subscript'))
256
+
257
+ // Highlight
258
+ this.setupButton('[data-action="highlight"]', () => this.editor.chain().focus().toggleHighlight().run(), () => this.editor.isActive('highlight'))
259
+
260
+ // Headings
261
+ this.setupButton('[data-action="heading-1"]', () => this.editor.chain().focus().toggleHeading({ level: 1 }).run(), () => this.editor.isActive('heading', { level: 1 }))
262
+ this.setupButton('[data-action="heading-2"]', () => this.editor.chain().focus().toggleHeading({ level: 2 }).run(), () => this.editor.isActive('heading', { level: 2 }))
263
+ this.setupButton('[data-action="heading-3"]', () => this.editor.chain().focus().toggleHeading({ level: 3 }).run(), () => this.editor.isActive('heading', { level: 3 }))
264
+
265
+ // Lists
266
+ this.setupButton('[data-action="bullet-list"]', () => this.editor.chain().focus().toggleBulletList().run(), () => this.editor.isActive('bulletList'))
267
+ this.setupButton('[data-action="ordered-list"]', () => this.editor.chain().focus().toggleOrderedList().run(), () => this.editor.isActive('orderedList'))
268
+ this.setupButton('[data-action="task-list"]', () => this.editor.chain().focus().toggleTaskList().run(), () => this.editor.isActive('taskList'))
269
+
270
+ // Blockquote
271
+ this.setupButton('[data-action="blockquote"]', () => this.editor.chain().focus().toggleBlockquote().run(), () => this.editor.isActive('blockquote'))
272
+
273
+ // Code block
274
+ this.setupButton('[data-action="code-block"]', () => this.editor.chain().focus().toggleCodeBlock().run(), () => this.editor.isActive('codeBlock'))
275
+
276
+ // Horizontal rule
277
+ this.setupButton('[data-action="horizontal-rule"]', () => this.editor.chain().focus().setHorizontalRule().run())
278
+
279
+ // Text align
280
+ this.setupButton('[data-action="align-left"]', () => this.editor.chain().focus().setTextAlign('left').run(), () => this.editor.isActive({ textAlign: 'left' }))
281
+ this.setupButton('[data-action="align-center"]', () => this.editor.chain().focus().setTextAlign('center').run(), () => this.editor.isActive({ textAlign: 'center' }))
282
+ this.setupButton('[data-action="align-right"]', () => this.editor.chain().focus().setTextAlign('right').run(), () => this.editor.isActive({ textAlign: 'right' }))
283
+ this.setupButton('[data-action="align-justify"]', () => this.editor.chain().focus().setTextAlign('justify').run(), () => this.editor.isActive({ textAlign: 'justify' }))
284
+
285
+ // Link
286
+ this.setupButton('[data-action="link"]', () => this.showLinkDropdown(), () => this.editor.isActive('link'))
287
+
288
+ // Image
289
+ this.setupButton('[data-action="image"]', () => this.showImageUpload())
290
+
291
+ // Undo/Redo
292
+ this.setupButton('[data-action="undo"]', () => this.editor.chain().focus().undo().run(), null, () => this.editor.can().undo())
293
+ this.setupButton('[data-action="redo"]', () => this.editor.chain().focus().redo().run(), null, () => this.editor.can().redo())
294
+
295
+ // Table button (for highlighting when inside table)
296
+ this.setupButton('[data-action="click->dropdown#toggle table"]', null, () => this.editor.isActive('table'))
297
+
298
+ // Table actions
299
+ this.setupButton('[data-action="tiptap-editor#insertTable"]', () => this.insertTable())
300
+ this.setupButton('[data-action="tiptap-editor#deleteTable"]', () => this.deleteTable())
301
+ this.setupButton('[data-action="tiptap-editor#addColumnAfter"]', () => this.addColumnAfter())
302
+ this.setupButton('[data-action="tiptap-editor#deleteColumn"]', () => this.deleteColumn())
303
+ this.setupButton('[data-action="tiptap-editor#addRowAfter"]', () => this.addRowAfter())
304
+ this.setupButton('[data-action="tiptap-editor#deleteRow"]', () => this.deleteRow())
305
+
306
+ // YouTube
307
+ this.setupButton('[data-action="youtube"]', () => this.showYoutubeDropdown(), () => this.editor.isActive('youtube'))
308
+
309
+ // Update button states on selection change
310
+ this.editor.on('selectionUpdate', () => {
311
+ this.updateButtonStates()
312
+ })
313
+
314
+ this.editor.on('transaction', () => {
315
+ this.updateButtonStates()
316
+ })
317
+
318
+ // Handle clicks on links in the editor
319
+ this.editorTarget.addEventListener('click', (event) => {
320
+ const linkElement = event.target.closest('a')
321
+ if (linkElement) {
322
+ event.preventDefault()
323
+ event.stopImmediatePropagation()
324
+
325
+ // Set cursor position to the link
326
+ const pos = this.editor.view.posAtDOM(linkElement, 0)
327
+ this.editor.commands.setTextSelection(pos)
328
+
329
+ this.showLinkDropdown()
330
+ return false
331
+ }
332
+ }, true)
333
+
334
+ // Handle clicks on images in the editor - delegate to resize plugin, then show alt editor
335
+ this.editorTarget.addEventListener('click', (event) => {
336
+ const imageElement = event.target.closest('img')
337
+ if (imageElement) {
338
+ // Don't prevent default - let resize plugin handle it first
339
+ // Just schedule showing the alt editor after resize controls appear
340
+ setTimeout(() => {
341
+ this.showAltEditor()
342
+ }, 50)
343
+ }
344
+ })
345
+
346
+ // Hide alt editor when starting to resize image
347
+ this.editorTarget.addEventListener('mousedown', (event) => {
348
+ const isResizeHandle = event.target.closest('.ProseMirror-widget') ||
349
+ event.target.style?.cursor?.includes('resize')
350
+ if (isResizeHandle && this.altDropdownTarget && !this.altDropdownTarget.classList.contains('hidden')) {
351
+ this.hideAltEditor()
352
+ }
353
+ })
354
+
355
+ this.updateButtonStates()
356
+ }
357
+
358
+ setupButton(selector, action, isActiveCheck = null, isEnabledCheck = null) {
359
+ const button = this.element.querySelector(selector)
360
+ if (button) {
361
+ button.addEventListener('click', (e) => {
362
+ e.preventDefault()
363
+ action()
364
+ })
365
+
366
+ // Store references for state updates
367
+ if (!this.buttons) this.buttons = []
368
+ this.buttons.push({
369
+ element: button,
370
+ isActiveCheck,
371
+ isEnabledCheck
372
+ })
373
+ }
374
+ }
375
+
376
+ showLinkDropdown() {
377
+ const linkButton = this.element.querySelector('[data-action="link"]')
378
+ const rect = linkButton.getBoundingClientRect()
379
+ const editorRect = this.element.getBoundingClientRect()
380
+
381
+ // Position dropdown below the link button
382
+ this.linkDropdownTarget.style.left = `${rect.left - editorRect.left}px`
383
+ this.linkDropdownTarget.style.top = `${rect.bottom - editorRect.top + 5}px`
384
+
385
+ // Get current link URL if exists
386
+ const currentUrl = this.editor.getAttributes('link').href || ''
387
+ this.linkInputTarget.value = currentUrl
388
+
389
+ // Show dropdown
390
+ this.linkDropdownTarget.classList.remove('hidden')
391
+ this.linkInputTarget.focus()
392
+ this.linkInputTarget.select()
393
+
394
+ // Add click outside listener to close dropdown
395
+ this.handleClickOutside = (e) => {
396
+ if (!this.linkDropdownTarget.contains(e.target) && !linkButton.contains(e.target)) {
397
+ this.hideLinkDropdown()
398
+ }
399
+ }
400
+ document.addEventListener('click', this.handleClickOutside)
401
+
402
+ // Add escape key listener
403
+ this.handleEscapeKey = (e) => {
404
+ if (e.key === 'Escape') {
405
+ this.hideLinkDropdown()
406
+ }
407
+ }
408
+ document.addEventListener('keydown', this.handleEscapeKey)
409
+ }
410
+
411
+ hideLinkDropdown() {
412
+ this.linkDropdownTarget.classList.add('hidden')
413
+ // Disable validation when hidden to prevent form submission issues
414
+ this.linkInputTarget.removeAttribute('required')
415
+ this.linkInputTarget.value = ''
416
+ document.removeEventListener('click', this.handleClickOutside)
417
+ document.removeEventListener('keydown', this.handleEscapeKey)
418
+ }
419
+
420
+ applyLink() {
421
+ const url = this.linkInputTarget.value.trim()
422
+
423
+ if (url && url !== 'https://') {
424
+ this.editor.chain().focus().setLink({ href: url }).run()
425
+ } else {
426
+ this.editor.chain().focus().unsetLink().run()
427
+ }
428
+
429
+ this.hideLinkDropdown()
430
+ }
431
+
432
+ handleLinkInputKeydown(event) {
433
+ if (event.key === 'Enter') {
434
+ event.preventDefault()
435
+ this.applyLink()
436
+ }
437
+ }
438
+
439
+ removeLink() { this.editor.chain().focus().unsetLink().run(); this.hideLinkDropdown() }
440
+
441
+ showAltEditor() {
442
+ // Get the current image node position
443
+ const { state } = this.editor
444
+ const { selection } = state
445
+
446
+ // Find the image node - search in a wider range
447
+ let imageNode = null
448
+ let imagePos = null
449
+
450
+ // Try to find image node at or near selection
451
+ state.doc.descendants((node, pos) => {
452
+ if (node.type.name === 'imageResize') {
453
+ const nodeStart = pos
454
+ const nodeEnd = pos + node.nodeSize
455
+
456
+ // Check if selection is within or near this image node
457
+ if (selection.from >= nodeStart && selection.from <= nodeEnd) {
458
+ imageNode = node
459
+ imagePos = pos
460
+ return false
461
+ }
462
+ }
463
+ })
464
+
465
+ if (!imageNode) return
466
+
467
+ // Store the image position and original alt text for later use
468
+ this.currentImagePos = imagePos
469
+ this.originalAltText = imageNode.attrs.alt || ''
470
+
471
+ // Find the DOM element for the image
472
+ const imageElement = this.editorTarget.querySelector(`img[src="${imageNode.attrs.src}"]`)
473
+ if (!imageElement) return
474
+
475
+ // Get the wrapper div (parent of the img)
476
+ const wrapper = imageElement.closest('div[style*="width"]')
477
+ if (!wrapper) return
478
+
479
+ const rect = wrapper.getBoundingClientRect()
480
+ const editorRect = this.element.getBoundingClientRect()
481
+
482
+ // Position alt dropdown below the image
483
+ this.altDropdownTarget.style.left = `${rect.left - editorRect.left}px`
484
+ this.altDropdownTarget.style.top = `${rect.bottom - editorRect.top + 5}px`
485
+ this.altDropdownTarget.style.width = `${rect.width}px`
486
+
487
+ // Set current alt text
488
+ this.altInputTarget.value = this.originalAltText
489
+
490
+ // Show dropdown but don't focus the input - let user keep focus on image
491
+ this.altDropdownTarget.classList.remove('hidden')
492
+
493
+ // Add click outside listener to close dropdown
494
+ this.handleAltClickOutside = (e) => {
495
+ if (!this.altDropdownTarget.contains(e.target) && !wrapper.contains(e.target)) {
496
+ this.hideAltEditor()
497
+ }
498
+ }
499
+ setTimeout(() => {
500
+ document.addEventListener('click', this.handleAltClickOutside)
501
+ }, 100)
502
+ }
503
+
504
+ hideAltEditor() {
505
+ this.altDropdownTarget.classList.add('hidden')
506
+ this.altInputTarget.value = ''
507
+ this.currentImagePos = null
508
+ this.originalAltText = null
509
+ document.removeEventListener('click', this.handleAltClickOutside)
510
+ }
511
+
512
+ applyAlt() {
513
+ if (this.currentImagePos === null) return
514
+
515
+ const alt = this.altInputTarget.value.trim()
516
+ const { state } = this.editor
517
+ const node = state.doc.nodeAt(this.currentImagePos)
518
+
519
+ // Only apply if node exists and alt text actually changed
520
+ if (node && node.type.name === 'imageResize' && alt !== this.originalAltText) {
521
+ // Use native TipTap setImage command to update alt text
522
+ this.editor.chain()
523
+ .focus()
524
+ .setNodeSelection(this.currentImagePos)
525
+ .setImage({
526
+ src: node.attrs.src,
527
+ alt: alt || null,
528
+ width: node.attrs.width,
529
+ containerStyle: node.attrs.containerStyle,
530
+ wrapperStyle: node.attrs.wrapperStyle
531
+ })
532
+ .run()
533
+ }
534
+
535
+ this.hideAltEditor()
536
+ }
537
+
538
+ handleAltInputChange() {
539
+ // Trigger autosave while typing in alt field
540
+ document.dispatchEvent(new CustomEvent('tiptap:content-changed'))
541
+ }
542
+
543
+ handleAltInputKeydown(event) {
544
+ if (event.key === 'Enter') {
545
+ event.preventDefault()
546
+ this.applyAlt()
547
+ }
548
+ }
549
+
550
+ showYoutubeDropdown() {
551
+ const youtubeButton = this.element.querySelector('[data-action="youtube"]')
552
+ const rect = youtubeButton.getBoundingClientRect()
553
+ const editorRect = this.element.getBoundingClientRect()
554
+
555
+ // Position dropdown below the youtube button
556
+ this.youtubeDropdownTarget.style.left = `${rect.left - editorRect.left}px`
557
+ this.youtubeDropdownTarget.style.top = `${rect.bottom - editorRect.top + 5}px`
558
+
559
+ // Clear previous value
560
+ this.youtubeInputTarget.value = ''
561
+
562
+ // Show dropdown
563
+ this.youtubeDropdownTarget.classList.remove('hidden')
564
+ this.youtubeInputTarget.focus()
565
+
566
+ // Add click outside listener to close dropdown
567
+ this.handleYoutubeClickOutside = (e) => {
568
+ if (!this.youtubeDropdownTarget.contains(e.target) && !youtubeButton.contains(e.target)) {
569
+ this.hideYoutubeDropdown()
570
+ }
571
+ }
572
+ document.addEventListener('click', this.handleYoutubeClickOutside)
573
+
574
+ // Add escape key listener
575
+ this.handleYoutubeEscapeKey = (e) => {
576
+ if (e.key === 'Escape') {
577
+ this.hideYoutubeDropdown()
578
+ }
579
+ }
580
+ document.addEventListener('keydown', this.handleYoutubeEscapeKey)
581
+ }
582
+
583
+ hideYoutubeDropdown() {
584
+ this.youtubeDropdownTarget.classList.add('hidden')
585
+ this.youtubeInputTarget.value = ''
586
+ document.removeEventListener('click', this.handleYoutubeClickOutside)
587
+ document.removeEventListener('keydown', this.handleYoutubeEscapeKey)
588
+ }
589
+
590
+ applyYoutube() {
591
+ const url = this.youtubeInputTarget.value.trim()
592
+
593
+ if (url) {
594
+ this.editor.chain().focus().setYoutubeVideo({ src: url }).run()
595
+ }
596
+
597
+ this.hideYoutubeDropdown()
598
+ }
599
+
600
+ handleYoutubeInputKeydown(event) {
601
+ if (event.key === 'Enter') {
602
+ event.preventDefault()
603
+ this.applyYoutube()
604
+ }
605
+ }
606
+
607
+ changeCodeLanguage(event) {
608
+ if (!this.editor.isActive('codeBlock')) return
609
+
610
+ this.editor.chain().focus().updateAttributes('codeBlock', { language: event.currentTarget.value }).run()
611
+ }
612
+
613
+ updateButtonStates() {
614
+ if (!this.buttons) return
615
+
616
+ this.buttons.forEach(({ element, isActiveCheck, isEnabledCheck }) => {
617
+ // Update active state
618
+ if (isActiveCheck) {
619
+ if (isActiveCheck()) {
620
+ applyClasses(element, EDITOR_STATES.active)
621
+ removeClasses(element, EDITOR_STATES.inactive)
622
+ } else {
623
+ removeClasses(element, EDITOR_STATES.active)
624
+ applyClasses(element, EDITOR_STATES.inactive)
625
+ }
626
+ }
627
+
628
+ // Update enabled state
629
+ if (isEnabledCheck) {
630
+ if (isEnabledCheck()) {
631
+ element.disabled = false
632
+ element.classList.remove('opacity-50', 'cursor-not-allowed')
633
+ } else {
634
+ element.disabled = true
635
+ element.classList.add('opacity-50', 'cursor-not-allowed')
636
+ }
637
+ }
638
+ })
639
+
640
+ if (this.hasCodeLanguageTarget) {
641
+ const codeBlockActive = this.editor.isActive('codeBlock')
642
+ this.codeLanguageTarget.classList.toggle('hidden', !codeBlockActive)
643
+
644
+ if (codeBlockActive) {
645
+ this.codeLanguageTarget.value = this.editor.getAttributes('codeBlock').language || 'plaintext'
646
+ this.#positionCodeLanguageControl()
647
+ }
648
+ }
649
+ }
650
+
651
+ #positionCodeLanguageControl = () => {
652
+ if (!this.hasCodeLanguageTarget || !this.editor?.isActive('codeBlock')) return
653
+
654
+ const codeBlock = this.#activeCodeBlockElement
655
+ if (!codeBlock) return
656
+
657
+ const editorContainer = this.element.getBoundingClientRect()
658
+ const codeBlockBounds = codeBlock.getBoundingClientRect()
659
+ this.codeLanguageTarget.style.top = `${codeBlockBounds.top - editorContainer.top + 8}px`
660
+ this.codeLanguageTarget.style.right = `${editorContainer.right - codeBlockBounds.right + 8}px`
661
+ }
662
+
663
+ get #activeCodeBlockElement() {
664
+ const { $from } = this.editor.state.selection
665
+
666
+ for (let depth = $from.depth; depth > 0; depth -= 1) {
667
+ if ($from.node(depth).type.name !== 'codeBlock') continue
668
+
669
+ return this.editor.view.nodeDOM($from.before(depth))
670
+ }
671
+
672
+ return null
673
+ }
674
+
675
+ showImageUpload() {
676
+ // Create a hidden file input
677
+ const input = document.createElement('input')
678
+ input.type = 'file'
679
+ input.accept = 'image/*'
680
+ input.style.display = 'none'
681
+
682
+ input.addEventListener('change', (event) => {
683
+ const file = event.target.files[0]
684
+ if (file) {
685
+ this.uploadImage(file)
686
+ }
687
+ })
688
+
689
+ // Trigger file selection
690
+ input.click()
691
+ }
692
+
693
+ uploadImage(file) {
694
+ this.dispatch('upload-start', { bubbles: true })
695
+
696
+ const formData = new FormData()
697
+ formData.append('file', file)
698
+
699
+ const mountPath = this.mountPathValue === '/' ? '' : this.mountPathValue.replace(/\/$/, '')
700
+ const uploadUrl = this.resourceTypeValue === 'page'
701
+ ? `${mountPath}/pages/${this.resourceIdValue}/images`
702
+ : `${mountPath}/articles/${this.resourceIdValue}/images`
703
+
704
+ fetch(uploadUrl, {
705
+ method: 'POST',
706
+ body: formData,
707
+ headers: {
708
+ 'X-CSRF-Token': document.querySelector('[name="csrf-token"]').getAttribute('content')
709
+ }
710
+ })
711
+ .then(response => response.json())
712
+ .then(data => {
713
+ if (data.url) {
714
+ // Insert image at current cursor position
715
+ this.editor.chain().focus().setImage({ src: data.url }).run()
716
+ this.dispatch('upload-complete', { bubbles: true })
717
+ } else {
718
+ this.dispatch('upload-failed', { bubbles: true })
719
+ }
720
+ })
721
+ .catch(error => {
722
+ console.error('Upload error:', error)
723
+ this.dispatch('upload-failed', { bubbles: true })
724
+ })
725
+ }
726
+
727
+ setupPasteHandler() {
728
+ this.editorTarget.addEventListener('paste', (event) => {
729
+ const items = event.clipboardData?.items
730
+ if (!items) return
731
+
732
+ for (let i = 0; i < items.length; i++) {
733
+ const item = items[i]
734
+
735
+ if (item.type.indexOf('image') !== -1) {
736
+ event.preventDefault()
737
+ const file = item.getAsFile()
738
+ if (file) {
739
+ this.uploadImage(file)
740
+ }
741
+ break
742
+ }
743
+ }
744
+ })
745
+ }
746
+
747
+ setupDragAndDropHandler() {
748
+ // Prevent default drag behaviors on the entire editor element
749
+ ;['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
750
+ this.editorTarget.addEventListener(eventName, (e) => {
751
+ e.preventDefault()
752
+ e.stopPropagation()
753
+ })
754
+ })
755
+
756
+ // Add visual feedback for drag over
757
+ this.editorTarget.addEventListener('dragenter', (e) => {
758
+ applyClasses(this.editorTarget, EDITOR_STATES.dragOver)
759
+ })
760
+
761
+ this.editorTarget.addEventListener('dragleave', (e) => {
762
+ // Only remove styling if we're leaving the editor entirely
763
+ if (!this.editorTarget.contains(e.relatedTarget)) {
764
+ removeClasses(this.editorTarget, EDITOR_STATES.dragOver)
765
+ }
766
+ })
767
+
768
+ this.editorTarget.addEventListener('drop', (e) => {
769
+ // Remove visual feedback
770
+ removeClasses(this.editorTarget, EDITOR_STATES.dragOver)
771
+
772
+ const files = e.dataTransfer.files
773
+ if (files.length > 0) {
774
+ // Handle multiple files
775
+ Array.from(files).forEach(file => {
776
+ if (file.type.startsWith('image/')) {
777
+ this.uploadImage(file)
778
+ }
779
+ })
780
+ }
781
+ })
782
+ }
783
+
784
+ // Table methods
785
+ insertTable() {
786
+ // Don't insert table if already inside a table
787
+ if (this.editor.isActive('table')) {
788
+ return
789
+ }
790
+ this.editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
791
+ }
792
+
793
+ deleteTable() {
794
+ this.editor.chain().focus().deleteTable().run()
795
+ }
796
+
797
+ addColumnAfter() {
798
+ this.editor.chain().focus().addColumnAfter().run()
799
+ }
800
+
801
+ deleteColumn() {
802
+ this.editor.chain().focus().deleteColumn().run()
803
+ }
804
+
805
+ addRowAfter() {
806
+ this.editor.chain().focus().addRowAfter().run()
807
+ }
808
+
809
+ deleteRow() {
810
+ this.editor.chain().focus().deleteRow().run()
811
+ }
812
+ }