pressroom 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 (82) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +22 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +172 -0
  5. data/Rakefile +8 -0
  6. data/app/assets/javascripts/pressroom/application.js +18 -0
  7. data/app/assets/javascripts/pressroom/controllers/editor_controller.js +146 -0
  8. data/app/assets/javascripts/pressroom/vendor/code.js +181 -0
  9. data/app/assets/javascripts/pressroom/vendor/delimiter.js +98 -0
  10. data/app/assets/javascripts/pressroom/vendor/editorjs.js +11217 -0
  11. data/app/assets/javascripts/pressroom/vendor/header.js +321 -0
  12. data/app/assets/javascripts/pressroom/vendor/image.js +1103 -0
  13. data/app/assets/javascripts/pressroom/vendor/inline-code.js +99 -0
  14. data/app/assets/javascripts/pressroom/vendor/list.js +2091 -0
  15. data/app/assets/javascripts/pressroom/vendor/marker.js +95 -0
  16. data/app/assets/javascripts/pressroom/vendor/quote.js +990 -0
  17. data/app/assets/javascripts/pressroom/vendor/stimulus.js +2563 -0
  18. data/app/assets/javascripts/pressroom/vendor/turbo.js +7256 -0
  19. data/app/assets/stylesheets/pressroom/admin.css +216 -0
  20. data/app/assets/stylesheets/pressroom/content.css +18 -0
  21. data/app/controllers/pressroom/application_controller.rb +41 -0
  22. data/app/controllers/pressroom/categories_controller.rb +50 -0
  23. data/app/controllers/pressroom/posts_controller.rb +98 -0
  24. data/app/controllers/pressroom/tags_controller.rb +39 -0
  25. data/app/controllers/pressroom/uploads_controller.rb +52 -0
  26. data/app/helpers/pressroom/application_helper.rb +4 -0
  27. data/app/helpers/pressroom/blocks_helper.rb +13 -0
  28. data/app/jobs/pressroom/application_job.rb +4 -0
  29. data/app/mailers/pressroom/application_mailer.rb +6 -0
  30. data/app/models/concerns/pressroom/authorable.rb +23 -0
  31. data/app/models/concerns/pressroom/sluggable.rb +66 -0
  32. data/app/models/pressroom/application_record.rb +7 -0
  33. data/app/models/pressroom/category.rb +54 -0
  34. data/app/models/pressroom/post.rb +90 -0
  35. data/app/models/pressroom/tag.rb +30 -0
  36. data/app/models/pressroom/tagging.rb +12 -0
  37. data/app/views/layouts/pressroom/admin.html.erb +34 -0
  38. data/app/views/layouts/pressroom/application.html.erb +17 -0
  39. data/app/views/pressroom/blocks/_code.html.erb +2 -0
  40. data/app/views/pressroom/blocks/_delimiter.html.erb +1 -0
  41. data/app/views/pressroom/blocks/_header.html.erb +5 -0
  42. data/app/views/pressroom/blocks/_image.html.erb +18 -0
  43. data/app/views/pressroom/blocks/_list.html.erb +6 -0
  44. data/app/views/pressroom/blocks/_list_items.html.erb +13 -0
  45. data/app/views/pressroom/blocks/_paragraph.html.erb +1 -0
  46. data/app/views/pressroom/blocks/_quote.html.erb +6 -0
  47. data/app/views/pressroom/categories/_form.html.erb +31 -0
  48. data/app/views/pressroom/categories/edit.html.erb +3 -0
  49. data/app/views/pressroom/categories/index.html.erb +34 -0
  50. data/app/views/pressroom/categories/new.html.erb +3 -0
  51. data/app/views/pressroom/posts/_form.html.erb +49 -0
  52. data/app/views/pressroom/posts/_post_row.html.erb +17 -0
  53. data/app/views/pressroom/posts/edit.html.erb +34 -0
  54. data/app/views/pressroom/posts/index.html.erb +38 -0
  55. data/app/views/pressroom/posts/new.html.erb +3 -0
  56. data/app/views/pressroom/tags/edit.html.erb +24 -0
  57. data/app/views/pressroom/tags/index.html.erb +27 -0
  58. data/config/editorjs_manifest.yml +53 -0
  59. data/config/importmap.rb +18 -0
  60. data/config/locales/pressroom.en.yml +16 -0
  61. data/config/locales/transliteration.ru.yml +70 -0
  62. data/config/locales/transliteration.uk.yml +70 -0
  63. data/config/routes.rb +17 -0
  64. data/db/migrate/20260821000001_create_pressroom_posts.rb +42 -0
  65. data/db/migrate/20260821000002_create_pressroom_taxonomy.rb +33 -0
  66. data/lib/pressroom/blocks/definition.rb +103 -0
  67. data/lib/pressroom/blocks/registry.rb +56 -0
  68. data/lib/pressroom/blocks/renderer.rb +97 -0
  69. data/lib/pressroom/blocks/sanitizer.rb +36 -0
  70. data/lib/pressroom/blocks/schema.rb +247 -0
  71. data/lib/pressroom/blocks/text_extractor.rb +46 -0
  72. data/lib/pressroom/built_in_blocks.rb +97 -0
  73. data/lib/pressroom/configuration.rb +64 -0
  74. data/lib/pressroom/engine.rb +33 -0
  75. data/lib/pressroom/errors.rb +11 -0
  76. data/lib/pressroom/feed.rb +69 -0
  77. data/lib/pressroom/transliteration.rb +213 -0
  78. data/lib/pressroom/version.rb +3 -0
  79. data/lib/pressroom.rb +70 -0
  80. data/lib/tasks/pressroom_tasks.rake +4 -0
  81. data/lib/tasks/pressroom_vendor.rake +31 -0
  82. metadata +171 -0
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pressroom
4
+ class Post < ApplicationRecord
5
+ SCHEMA_VERSION = 1
6
+ EMPTY_DOCUMENT = { "schema_version" => SCHEMA_VERSION, "blocks" => [] }.freeze
7
+ EXCERPT_LENGTH = 200
8
+
9
+ self.table_name = "pressroom_posts"
10
+
11
+ include Pressroom::Sluggable
12
+
13
+ attribute :content, :json, default: -> { EMPTY_DOCUMENT.deep_dup }
14
+
15
+ enum :status, { draft: "draft", published: "published", archived: "archived" },
16
+ validate: true
17
+
18
+ include Pressroom::Authorable
19
+ belongs_to :category, class_name: "Pressroom::Category", optional: true
20
+
21
+ has_many :taggings, class_name: "Pressroom::Tagging", dependent: :destroy,
22
+ inverse_of: :post
23
+ has_many :tags, through: :taggings, class_name: "Pressroom::Tag"
24
+
25
+ validates :title, presence: true
26
+
27
+ before_validation :stamp_published_at
28
+ before_save :refresh_plain_text
29
+
30
+ scope :recent, -> { order(published_at: :desc, created_at: :desc) }
31
+ scope :published, lambda {
32
+ where(status: "published")
33
+ .where(arel_table[:published_at].lteq(Time.current))
34
+ }
35
+ scope :tagged, lambda { |name|
36
+ joins(:tags).where(pressroom_tags: { slug: Pressroom::Tag.slugify(name) })
37
+ }
38
+
39
+ # Normalizes anything the caller hands us into a versioned document.
40
+ def content=(value)
41
+ super(normalize_document(value))
42
+ end
43
+
44
+ def blocks
45
+ Array(content.is_a?(Hash) ? content["blocks"] : nil)
46
+ end
47
+
48
+ def tag_names
49
+ tags.map(&:name)
50
+ end
51
+
52
+ # Accepts an array of names or a comma separated string, and replaces
53
+ # the whole tag set.
54
+ def tag_names=(value)
55
+ names = value.is_a?(String) ? value.split(",") : Array(value)
56
+ self.tags = Pressroom::Tag.for_names(names)
57
+ end
58
+
59
+ # Live to the public right now, as opposed to merely flagged published.
60
+ def published?
61
+ status == "published" && published_at.present? && published_at <= Time.current
62
+ end
63
+
64
+ private
65
+
66
+ def normalize_document(value)
67
+ case value
68
+ when Array then { "schema_version" => SCHEMA_VERSION, "blocks" => value }
69
+ when Hash
70
+ blocks = value["blocks"] || value[:blocks] || []
71
+ { "schema_version" => value["schema_version"] || SCHEMA_VERSION,
72
+ "blocks" => Array(blocks) }
73
+ else EMPTY_DOCUMENT.deep_dup
74
+ end
75
+ end
76
+
77
+ def stamp_published_at
78
+ self.published_at ||= Time.current if status == "published"
79
+ end
80
+
81
+ def refresh_plain_text
82
+ self.plain_text = Pressroom.text_extractor.extract(content)
83
+ self.excerpt = default_excerpt if excerpt.blank?
84
+ end
85
+
86
+ def default_excerpt
87
+ plain_text.to_s.truncate(EXCERPT_LENGTH, omission: "…")
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pressroom
4
+ class Tag < ApplicationRecord
5
+ include Pressroom::Sluggable
6
+
7
+ self.table_name = "pressroom_tags"
8
+
9
+ has_many :taggings, class_name: "Pressroom::Tagging", dependent: :destroy,
10
+ inverse_of: :tag
11
+ has_many :posts, through: :taggings, class_name: "Pressroom::Post"
12
+
13
+ validates :name, presence: true
14
+
15
+ # Finds or creates one tag per distinct non-blank name.
16
+ def self.for_names(names)
17
+ Array(names)
18
+ .map { |name| name.to_s.strip }
19
+ .reject(&:empty?)
20
+ .uniq
21
+ .map { |name| find_or_create_by!(slug: slugify(name)) { |tag| tag.name = name } }
22
+ end
23
+
24
+ private
25
+
26
+ def slug_source
27
+ name
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pressroom
4
+ class Tagging < ApplicationRecord
5
+ self.table_name = "pressroom_taggings"
6
+
7
+ belongs_to :post, class_name: "Pressroom::Post", inverse_of: :taggings
8
+ belongs_to :tag, class_name: "Pressroom::Tag", inverse_of: :taggings
9
+
10
+ validates :tag_id, uniqueness: { scope: :post_id }
11
+ end
12
+ end
@@ -0,0 +1,34 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title><%= content_for(:title) || "Pressroom" %></title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+ <%= stylesheet_link_tag "pressroom/admin", media: "all" %>
9
+ <%= javascript_importmap_tags "pressroom/application" %>
10
+ </head>
11
+
12
+ <body class="pressroom-admin">
13
+ <header class="pressroom-admin__header">
14
+ <a class="pressroom-admin__brand" href="<%= pressroom.root_path %>">Pressroom</a>
15
+ <nav class="pressroom-admin__nav">
16
+ <%= link_to "Posts", pressroom.posts_path, class: "pressroom-admin__nav-link" %>
17
+ <%= link_to "Categories", pressroom.categories_path, class: "pressroom-admin__nav-link" %>
18
+ <%= link_to "Tags", pressroom.tags_path, class: "pressroom-admin__nav-link" %>
19
+ </nav>
20
+ </header>
21
+
22
+ <% if flash.any? %>
23
+ <div class="pressroom-flash">
24
+ <% flash.each do |kind, message| %>
25
+ <p class="pressroom-flash__message pressroom-flash__message--<%= kind %>">
26
+ <%= message %>
27
+ </p>
28
+ <% end %>
29
+ </div>
30
+ <% end %>
31
+
32
+ <main class="pressroom-admin__main"><%= yield %></main>
33
+ </body>
34
+ </html>
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Pressroom</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= yield :head %>
9
+
10
+ <%= stylesheet_link_tag "pressroom/application", media: "all" %>
11
+ </head>
12
+ <body>
13
+
14
+ <%= yield %>
15
+
16
+ </body>
17
+ </html>
@@ -0,0 +1,2 @@
1
+ <%# data["code"] is a string field, so ERB escapes it. Code is shown, never run. %>
2
+ <pre class="pressroom-code"><code class="pressroom-code__source"><%= data["code"] %></code></pre>
@@ -0,0 +1 @@
1
+ <hr class="pressroom-delimiter">
@@ -0,0 +1,5 @@
1
+ <%# Levels are clamped so a stray value cannot emit an h1 and break the
2
+ host page's document outline. %>
3
+ <% level = data["level"].to_i.clamp(2, 4) %>
4
+ <%= content_tag("h#{level}", data["text"],
5
+ class: "pressroom-header pressroom-header--level-#{level}") %>
@@ -0,0 +1,18 @@
1
+ <% url = data.dig("file", "url").to_s %>
2
+ <% unless url.empty? %>
3
+ <%
4
+ classes = ["pressroom-image"]
5
+ classes << "pressroom-image--stretched" if data["stretched"]
6
+ classes << "pressroom-image--bordered" if data["withBorder"]
7
+ classes << "pressroom-image--background" if data["withBackground"]
8
+ %>
9
+ <figure class="<%= classes.join(" ") %>">
10
+ <%= tag.img src: url,
11
+ alt: strip_tags(data["caption"].to_s),
12
+ loading: "lazy",
13
+ class: "pressroom-image__img" %>
14
+ <% unless data["caption"].to_s.strip.empty? %>
15
+ <figcaption class="pressroom-image__caption"><%= data["caption"] %></figcaption>
16
+ <% end %>
17
+ </figure>
18
+ <% end %>
@@ -0,0 +1,6 @@
1
+ <% ordered = data["style"] == "ordered" %>
2
+ <%= content_tag(ordered ? :ol : :ul,
3
+ class: "pressroom-list pressroom-list--#{ordered ? 'ordered' : 'unordered'}") do %>
4
+ <%= render partial: "pressroom/blocks/list_items",
5
+ locals: { items: data["items"], ordered: ordered } %>
6
+ <% end %>
@@ -0,0 +1,13 @@
1
+ <%# Renders one level of a @editorjs/list 2.x tree and recurses into
2
+ children. Depth is already bounded by Schema::MAX_TREE_DEPTH. %>
3
+ <% Array(items).each do |item| %>
4
+ <li class="pressroom-list__item">
5
+ <span class="pressroom-list__content"><%= item["content"] %></span>
6
+ <% if Array(item["items"]).any? %>
7
+ <%= content_tag(ordered ? :ol : :ul, class: "pressroom-list__nested") do %>
8
+ <%= render partial: "pressroom/blocks/list_items",
9
+ locals: { items: item["items"], ordered: ordered } %>
10
+ <% end %>
11
+ <% end %>
12
+ </li>
13
+ <% end %>
@@ -0,0 +1 @@
1
+ <p class="pressroom-paragraph"><%= data["text"] %></p>
@@ -0,0 +1,6 @@
1
+ <figure class="pressroom-quote pressroom-quote--<%= data["alignment"] %>">
2
+ <blockquote class="pressroom-quote__text"><%= data["text"] %></blockquote>
3
+ <% unless data["caption"].to_s.strip.empty? %>
4
+ <figcaption class="pressroom-quote__caption"><%= data["caption"] %></figcaption>
5
+ <% end %>
6
+ </figure>
@@ -0,0 +1,31 @@
1
+ <%= form_with model: category,
2
+ url: category.persisted? ? category_path(category) : categories_path,
3
+ class: "pressroom-form" do |f| %>
4
+ <% if category.errors.any? %>
5
+ <ul class="pressroom-errors">
6
+ <% category.errors.full_messages.each do |message| %>
7
+ <li class="pressroom-errors__item"><%= message %></li>
8
+ <% end %>
9
+ </ul>
10
+ <% end %>
11
+
12
+ <div class="pressroom-field">
13
+ <%= f.label :name, class: "pressroom-field__label" %>
14
+ <%= f.text_field :name, class: "pressroom-field__input" %>
15
+ </div>
16
+
17
+ <div class="pressroom-field">
18
+ <%= f.label :parent_id, "Parent", class: "pressroom-field__label" %>
19
+ <%= f.collection_select :parent_id,
20
+ Pressroom::Category.where.not(id: category.id).order(:name),
21
+ :id, :name, { include_blank: "—" },
22
+ class: "pressroom-field__input" %>
23
+ </div>
24
+
25
+ <div class="pressroom-field">
26
+ <%= f.label :description, class: "pressroom-field__label" %>
27
+ <%= f.text_area :description, rows: 3, class: "pressroom-field__input" %>
28
+ </div>
29
+
30
+ <%= f.submit "Save", class: "pressroom-button pressroom-button--primary" %>
31
+ <% end %>
@@ -0,0 +1,3 @@
1
+ <% content_for :title, @category.name %>
2
+ <h1 class="pressroom-toolbar__title"><%= @category.name %></h1>
3
+ <%= render "pressroom/categories/form", category: @category %>
@@ -0,0 +1,34 @@
1
+ <% content_for :title, "Categories" %>
2
+
3
+ <div class="pressroom-toolbar">
4
+ <h1 class="pressroom-toolbar__title">Categories</h1>
5
+ <%= link_to "New category", new_category_path,
6
+ class: "pressroom-button pressroom-button--primary" %>
7
+ </div>
8
+
9
+ <table class="pressroom-table">
10
+ <thead>
11
+ <tr>
12
+ <th class="pressroom-table__heading">Name</th>
13
+ <th class="pressroom-table__heading">Slug</th>
14
+ <th class="pressroom-table__heading">Posts</th>
15
+ <th class="pressroom-table__heading"></th>
16
+ </tr>
17
+ </thead>
18
+ <tbody>
19
+ <% @categories.each do |category| %>
20
+ <tr class="pressroom-table__row">
21
+ <td class="pressroom-table__cell">
22
+ <%= "— " * category.depth %><%= link_to category.name, edit_category_path(category) %>
23
+ </td>
24
+ <td class="pressroom-table__cell"><%= category.slug %></td>
25
+ <td class="pressroom-table__cell"><%= category.posts.count %></td>
26
+ <td class="pressroom-table__cell">
27
+ <%= button_to "Delete", category_path(category), method: :delete,
28
+ class: "pressroom-button pressroom-button--danger",
29
+ form: { data: { turbo_confirm: "Delete this category?" } } %>
30
+ </td>
31
+ </tr>
32
+ <% end %>
33
+ </tbody>
34
+ </table>
@@ -0,0 +1,3 @@
1
+ <% content_for :title, "New category" %>
2
+ <h1 class="pressroom-toolbar__title">New category</h1>
3
+ <%= render "pressroom/categories/form", category: @category %>
@@ -0,0 +1,49 @@
1
+ <%= form_with model: post, url: post.persisted? ? post_path(post) : posts_path,
2
+ class: "pressroom-form" do |f| %>
3
+ <% if post.errors.any? %>
4
+ <ul class="pressroom-errors">
5
+ <% post.errors.full_messages.each do |message| %>
6
+ <li class="pressroom-errors__item"><%= message %></li>
7
+ <% end %>
8
+ </ul>
9
+ <% end %>
10
+
11
+ <div class="pressroom-field">
12
+ <%= f.label :title, class: "pressroom-field__label" %>
13
+ <%= f.text_field :title, class: "pressroom-field__input" %>
14
+ </div>
15
+
16
+ <%# The editor writes its JSON document into this hidden field on submit. %>
17
+ <div class="pressroom-editor"
18
+ data-controller="pressroom-editor"
19
+ <% if Pressroom.active_storage? %>data-pressroom-editor-upload-url-value="<%= upload_path %>"<% end %>>
20
+ <%= f.hidden_field :content, value: post.content.to_json,
21
+ data: { pressroom_editor_target: "field" } %>
22
+ <div class="pressroom-editor__holder" data-pressroom-editor-target="holder"></div>
23
+ </div>
24
+
25
+ <div class="pressroom-field">
26
+ <%= f.label :excerpt, class: "pressroom-field__label" %>
27
+ <%= f.text_area :excerpt, rows: 3, class: "pressroom-field__input" %>
28
+ </div>
29
+
30
+ <div class="pressroom-field">
31
+ <%= f.label :category_id, "Category", class: "pressroom-field__label" %>
32
+ <%= f.collection_select :category_id, Pressroom::Category.order(:name), :id, :name,
33
+ { include_blank: "—" }, class: "pressroom-field__input" %>
34
+ </div>
35
+
36
+ <div class="pressroom-field">
37
+ <%= f.label :tag_names, "Tags", class: "pressroom-field__label" %>
38
+ <%= f.text_field :tag_names, value: post.tag_names.join(", "),
39
+ class: "pressroom-field__input" %>
40
+ </div>
41
+
42
+ <div class="pressroom-field">
43
+ <%= f.label :slug, class: "pressroom-field__label" %>
44
+ <%= f.text_field :slug, class: "pressroom-field__input",
45
+ placeholder: "generated from the title" %>
46
+ </div>
47
+
48
+ <%= f.submit "Save", class: "pressroom-button pressroom-button--primary" %>
49
+ <% end %>
@@ -0,0 +1,17 @@
1
+ <tr class="pressroom-table__row">
2
+ <td class="pressroom-table__cell"><%= link_to post.title, edit_post_path(post) %></td>
3
+ <td class="pressroom-table__cell">
4
+ <span class="pressroom-badge pressroom-badge--<%= post.status %>"><%= post.status %></span>
5
+ </td>
6
+ <td class="pressroom-table__cell">
7
+ <%= post.published_at ? l(post.published_at, format: :short) : "—" %>
8
+ <% if post.status == "published" && !post.published? %>
9
+ <span class="pressroom-badge pressroom-badge--scheduled">scheduled</span>
10
+ <% end %>
11
+ </td>
12
+ <td class="pressroom-table__cell">
13
+ <%= button_to "Delete", post_path(post), method: :delete,
14
+ class: "pressroom-button pressroom-button--danger",
15
+ form: { data: { turbo_confirm: "Delete this post?" } } %>
16
+ </td>
17
+ </tr>
@@ -0,0 +1,34 @@
1
+ <% content_for :title, @post.title %>
2
+
3
+ <div class="pressroom-toolbar">
4
+ <h1 class="pressroom-toolbar__title"><%= @post.title %></h1>
5
+
6
+ <div class="pressroom-toolbar__actions">
7
+ <% if @post.status == "published" %>
8
+ <%= button_to "Unpublish", unpublish_post_path(@post), method: :patch,
9
+ class: "pressroom-button" %>
10
+ <% else %>
11
+ <%= button_to "Publish now", publish_post_path(@post), method: :patch,
12
+ class: "pressroom-button pressroom-button--primary" %>
13
+ <% end %>
14
+ </div>
15
+ </div>
16
+
17
+ <details class="pressroom-schedule">
18
+ <summary class="pressroom-schedule__summary">Schedule publication</summary>
19
+
20
+ <%= form_with url: publish_post_path(@post), method: :patch,
21
+ class: "pressroom-schedule__form" do |f| %>
22
+ <%= f.datetime_field :published_at,
23
+ value: @post.published_at&.strftime("%Y-%m-%dT%H:%M"),
24
+ class: "pressroom-field__input" %>
25
+ <%= f.submit "Schedule", class: "pressroom-button" %>
26
+ <% end %>
27
+
28
+ <p class="pressroom-schedule__hint">
29
+ A future timestamp keeps the post hidden until that moment. No background
30
+ job is involved.
31
+ </p>
32
+ </details>
33
+
34
+ <%= render "pressroom/posts/form", post: @post %>
@@ -0,0 +1,38 @@
1
+ <% content_for :title, "Posts" %>
2
+
3
+ <div class="pressroom-toolbar">
4
+ <h1 class="pressroom-toolbar__title">Posts</h1>
5
+ <%= link_to "New post", new_post_path, class: "pressroom-button pressroom-button--primary" %>
6
+ </div>
7
+
8
+ <nav class="pressroom-filters">
9
+ <%= link_to "All", posts_path, class: "pressroom-filters__link" %>
10
+ <% Pressroom::Post.statuses.each_key do |status| %>
11
+ <%= link_to status.titleize, posts_path(status: status),
12
+ class: "pressroom-filters__link" %>
13
+ <% end %>
14
+ </nav>
15
+
16
+ <table class="pressroom-table">
17
+ <thead>
18
+ <tr>
19
+ <th class="pressroom-table__heading">Title</th>
20
+ <th class="pressroom-table__heading">Status</th>
21
+ <th class="pressroom-table__heading">Published</th>
22
+ <th class="pressroom-table__heading"></th>
23
+ </tr>
24
+ </thead>
25
+ <tbody>
26
+ <%= render partial: "pressroom/posts/post_row", collection: @posts, as: :post %>
27
+ </tbody>
28
+ </table>
29
+
30
+ <% pages = (@total.to_f / @per_page).ceil %>
31
+ <% if pages > 1 %>
32
+ <nav class="pressroom-pagination">
33
+ <% (1..pages).each do |number| %>
34
+ <%= link_to number, posts_path(page: number, status: params[:status]),
35
+ class: "pressroom-pagination__link #{'pressroom-pagination__link--current' if number == @page}" %>
36
+ <% end %>
37
+ </nav>
38
+ <% end %>
@@ -0,0 +1,3 @@
1
+ <% content_for :title, "New post" %>
2
+ <h1 class="pressroom-toolbar__title">New post</h1>
3
+ <%= render "pressroom/posts/form", post: @post %>
@@ -0,0 +1,24 @@
1
+ <% content_for :title, @tag.name %>
2
+ <h1 class="pressroom-toolbar__title"><%= @tag.name %></h1>
3
+
4
+ <%= form_with model: @tag, url: tag_path(@tag), class: "pressroom-form" do |f| %>
5
+ <% if @tag.errors.any? %>
6
+ <ul class="pressroom-errors">
7
+ <% @tag.errors.full_messages.each do |message| %>
8
+ <li class="pressroom-errors__item"><%= message %></li>
9
+ <% end %>
10
+ </ul>
11
+ <% end %>
12
+
13
+ <div class="pressroom-field">
14
+ <%= f.label :name, class: "pressroom-field__label" %>
15
+ <%= f.text_field :name, class: "pressroom-field__input" %>
16
+ </div>
17
+
18
+ <div class="pressroom-field">
19
+ <%= f.label :slug, class: "pressroom-field__label" %>
20
+ <%= f.text_field :slug, class: "pressroom-field__input" %>
21
+ </div>
22
+
23
+ <%= f.submit "Save", class: "pressroom-button pressroom-button--primary" %>
24
+ <% end %>
@@ -0,0 +1,27 @@
1
+ <% content_for :title, "Tags" %>
2
+ <h1 class="pressroom-toolbar__title">Tags</h1>
3
+
4
+ <table class="pressroom-table">
5
+ <thead>
6
+ <tr>
7
+ <th class="pressroom-table__heading">Name</th>
8
+ <th class="pressroom-table__heading">Slug</th>
9
+ <th class="pressroom-table__heading">Posts</th>
10
+ <th class="pressroom-table__heading"></th>
11
+ </tr>
12
+ </thead>
13
+ <tbody>
14
+ <% @tags.each do |tag| %>
15
+ <tr class="pressroom-table__row">
16
+ <td class="pressroom-table__cell"><%= link_to tag.name, edit_tag_path(tag) %></td>
17
+ <td class="pressroom-table__cell"><%= tag.slug %></td>
18
+ <td class="pressroom-table__cell"><%= tag.posts_count %></td>
19
+ <td class="pressroom-table__cell">
20
+ <%= button_to "Delete", tag_path(tag), method: :delete,
21
+ class: "pressroom-button pressroom-button--danger",
22
+ form: { data: { turbo_confirm: "Delete this tag?" } } %>
23
+ </td>
24
+ </tr>
25
+ <% end %>
26
+ </tbody>
27
+ </table>
@@ -0,0 +1,53 @@
1
+ # Pinned Editor.js builds, vendored into the gem.
2
+ #
3
+ # Versions and dist filenames verified against npm and jsDelivr on
4
+ # 2026-08-21. Bump deliberately: @editorjs/list 2.x changed its data
5
+ # format, so a major bump here can change stored documents.
6
+ # Stimulus is vendored under a private pin name (pressroom/stimulus) rather
7
+ # than pinned as "@hotwired/stimulus": the host application very likely pins
8
+ # that name itself, and two pins for one specifier is a collision the gem
9
+ # must not create.
10
+ "@hotwired/stimulus":
11
+ version: "3.2.2"
12
+ remote: "dist/stimulus.js"
13
+ local: "stimulus.js"
14
+ "@hotwired/turbo":
15
+ version: "8.0.23"
16
+ remote: "dist/turbo.es2017-esm.js"
17
+ local: "turbo.js"
18
+ "@editorjs/editorjs":
19
+ version: "2.31.6"
20
+ remote: "dist/editorjs.mjs"
21
+ local: "editorjs.js"
22
+ "@editorjs/header":
23
+ version: "2.8.9"
24
+ remote: "dist/header.mjs"
25
+ local: "header.js"
26
+ "@editorjs/list":
27
+ version: "2.0.9"
28
+ remote: "dist/editorjs-list.mjs"
29
+ local: "list.js"
30
+ "@editorjs/quote":
31
+ version: "2.7.6"
32
+ remote: "dist/quote.mjs"
33
+ local: "quote.js"
34
+ "@editorjs/code":
35
+ version: "2.9.4"
36
+ remote: "dist/code.mjs"
37
+ local: "code.js"
38
+ "@editorjs/image":
39
+ version: "2.10.3"
40
+ remote: "dist/image.mjs"
41
+ local: "image.js"
42
+ "@editorjs/delimiter":
43
+ version: "1.4.2"
44
+ remote: "dist/delimiter.mjs"
45
+ local: "delimiter.js"
46
+ "@editorjs/marker":
47
+ version: "1.4.0"
48
+ remote: "dist/marker.mjs"
49
+ local: "marker.js"
50
+ "@editorjs/inline-code":
51
+ version: "1.5.2"
52
+ remote: "dist/inline-code.mjs"
53
+ local: "inline-code.js"
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Private pin: never "@hotwired/stimulus", which the host may pin itself.
4
+ pin "pressroom/stimulus", to: "pressroom/vendor/stimulus.js"
5
+ pin "pressroom/turbo", to: "pressroom/vendor/turbo.js"
6
+
7
+ pin "pressroom/application", preload: true
8
+ pin "pressroom/controllers/editor_controller"
9
+
10
+ pin "@editorjs/editorjs", to: "pressroom/vendor/editorjs.js"
11
+ pin "@editorjs/header", to: "pressroom/vendor/header.js"
12
+ pin "@editorjs/list", to: "pressroom/vendor/list.js"
13
+ pin "@editorjs/quote", to: "pressroom/vendor/quote.js"
14
+ pin "@editorjs/code", to: "pressroom/vendor/code.js"
15
+ pin "@editorjs/image", to: "pressroom/vendor/image.js"
16
+ pin "@editorjs/delimiter", to: "pressroom/vendor/delimiter.js"
17
+ pin "@editorjs/marker", to: "pressroom/vendor/marker.js"
18
+ pin "@editorjs/inline-code", to: "pressroom/vendor/inline-code.js"
@@ -0,0 +1,16 @@
1
+ en:
2
+ pressroom:
3
+ posts:
4
+ created: "Post created."
5
+ updated: "Post updated."
6
+ destroyed: "Post deleted."
7
+ published: "Post published."
8
+ scheduled: "Post scheduled."
9
+ unpublished: "Post moved back to draft."
10
+ categories:
11
+ created: "Category created."
12
+ updated: "Category updated."
13
+ destroyed: "Category deleted."
14
+ tags:
15
+ updated: "Tag updated."
16
+ destroyed: "Tag deleted."