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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/MIT-LICENSE +20 -0
- data/README.md +172 -0
- data/Rakefile +8 -0
- data/app/assets/javascripts/pressroom/application.js +18 -0
- data/app/assets/javascripts/pressroom/controllers/editor_controller.js +146 -0
- data/app/assets/javascripts/pressroom/vendor/code.js +181 -0
- data/app/assets/javascripts/pressroom/vendor/delimiter.js +98 -0
- data/app/assets/javascripts/pressroom/vendor/editorjs.js +11217 -0
- data/app/assets/javascripts/pressroom/vendor/header.js +321 -0
- data/app/assets/javascripts/pressroom/vendor/image.js +1103 -0
- data/app/assets/javascripts/pressroom/vendor/inline-code.js +99 -0
- data/app/assets/javascripts/pressroom/vendor/list.js +2091 -0
- data/app/assets/javascripts/pressroom/vendor/marker.js +95 -0
- data/app/assets/javascripts/pressroom/vendor/quote.js +990 -0
- data/app/assets/javascripts/pressroom/vendor/stimulus.js +2563 -0
- data/app/assets/javascripts/pressroom/vendor/turbo.js +7256 -0
- data/app/assets/stylesheets/pressroom/admin.css +216 -0
- data/app/assets/stylesheets/pressroom/content.css +18 -0
- data/app/controllers/pressroom/application_controller.rb +41 -0
- data/app/controllers/pressroom/categories_controller.rb +50 -0
- data/app/controllers/pressroom/posts_controller.rb +98 -0
- data/app/controllers/pressroom/tags_controller.rb +39 -0
- data/app/controllers/pressroom/uploads_controller.rb +52 -0
- data/app/helpers/pressroom/application_helper.rb +4 -0
- data/app/helpers/pressroom/blocks_helper.rb +13 -0
- data/app/jobs/pressroom/application_job.rb +4 -0
- data/app/mailers/pressroom/application_mailer.rb +6 -0
- data/app/models/concerns/pressroom/authorable.rb +23 -0
- data/app/models/concerns/pressroom/sluggable.rb +66 -0
- data/app/models/pressroom/application_record.rb +7 -0
- data/app/models/pressroom/category.rb +54 -0
- data/app/models/pressroom/post.rb +90 -0
- data/app/models/pressroom/tag.rb +30 -0
- data/app/models/pressroom/tagging.rb +12 -0
- data/app/views/layouts/pressroom/admin.html.erb +34 -0
- data/app/views/layouts/pressroom/application.html.erb +17 -0
- data/app/views/pressroom/blocks/_code.html.erb +2 -0
- data/app/views/pressroom/blocks/_delimiter.html.erb +1 -0
- data/app/views/pressroom/blocks/_header.html.erb +5 -0
- data/app/views/pressroom/blocks/_image.html.erb +18 -0
- data/app/views/pressroom/blocks/_list.html.erb +6 -0
- data/app/views/pressroom/blocks/_list_items.html.erb +13 -0
- data/app/views/pressroom/blocks/_paragraph.html.erb +1 -0
- data/app/views/pressroom/blocks/_quote.html.erb +6 -0
- data/app/views/pressroom/categories/_form.html.erb +31 -0
- data/app/views/pressroom/categories/edit.html.erb +3 -0
- data/app/views/pressroom/categories/index.html.erb +34 -0
- data/app/views/pressroom/categories/new.html.erb +3 -0
- data/app/views/pressroom/posts/_form.html.erb +49 -0
- data/app/views/pressroom/posts/_post_row.html.erb +17 -0
- data/app/views/pressroom/posts/edit.html.erb +34 -0
- data/app/views/pressroom/posts/index.html.erb +38 -0
- data/app/views/pressroom/posts/new.html.erb +3 -0
- data/app/views/pressroom/tags/edit.html.erb +24 -0
- data/app/views/pressroom/tags/index.html.erb +27 -0
- data/config/editorjs_manifest.yml +53 -0
- data/config/importmap.rb +18 -0
- data/config/locales/pressroom.en.yml +16 -0
- data/config/locales/transliteration.ru.yml +70 -0
- data/config/locales/transliteration.uk.yml +70 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20260821000001_create_pressroom_posts.rb +42 -0
- data/db/migrate/20260821000002_create_pressroom_taxonomy.rb +33 -0
- data/lib/pressroom/blocks/definition.rb +103 -0
- data/lib/pressroom/blocks/registry.rb +56 -0
- data/lib/pressroom/blocks/renderer.rb +97 -0
- data/lib/pressroom/blocks/sanitizer.rb +36 -0
- data/lib/pressroom/blocks/schema.rb +247 -0
- data/lib/pressroom/blocks/text_extractor.rb +46 -0
- data/lib/pressroom/built_in_blocks.rb +97 -0
- data/lib/pressroom/configuration.rb +64 -0
- data/lib/pressroom/engine.rb +33 -0
- data/lib/pressroom/errors.rb +11 -0
- data/lib/pressroom/feed.rb +69 -0
- data/lib/pressroom/transliteration.rb +213 -0
- data/lib/pressroom/version.rb +3 -0
- data/lib/pressroom.rb +70 -0
- data/lib/tasks/pressroom_tasks.rake +4 -0
- data/lib/tasks/pressroom_vendor.rake +31 -0
- 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 @@
|
|
|
1
|
+
<hr class="pressroom-delimiter">
|
|
@@ -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,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,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,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"
|
data/config/importmap.rb
ADDED
|
@@ -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."
|