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,216 @@
|
|
|
1
|
+
/* Pressroom admin. BEM: block, block__element, block--modifier. */
|
|
2
|
+
|
|
3
|
+
.pressroom-admin {
|
|
4
|
+
--pressroom-bg: #ffffff;
|
|
5
|
+
--pressroom-fg: #16181d;
|
|
6
|
+
--pressroom-muted: #6b7280;
|
|
7
|
+
--pressroom-line: #e5e7eb;
|
|
8
|
+
--pressroom-accent: #2563eb;
|
|
9
|
+
--pressroom-danger: #b91c1c;
|
|
10
|
+
--pressroom-surface: #f9fafb;
|
|
11
|
+
--pressroom-radius: 6px;
|
|
12
|
+
|
|
13
|
+
margin: 0;
|
|
14
|
+
background: var(--pressroom-bg);
|
|
15
|
+
color: var(--pressroom-fg);
|
|
16
|
+
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@media (prefers-color-scheme: dark) {
|
|
20
|
+
.pressroom-admin {
|
|
21
|
+
--pressroom-bg: #101215;
|
|
22
|
+
--pressroom-fg: #e8eaed;
|
|
23
|
+
--pressroom-muted: #9aa1ab;
|
|
24
|
+
--pressroom-line: #2a2f37;
|
|
25
|
+
--pressroom-accent: #6ea8fe;
|
|
26
|
+
--pressroom-danger: #f87171;
|
|
27
|
+
--pressroom-surface: #171a1f;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.pressroom-admin__header {
|
|
32
|
+
display: flex;
|
|
33
|
+
align-items: center;
|
|
34
|
+
gap: 24px;
|
|
35
|
+
padding: 12px 24px;
|
|
36
|
+
border-bottom: 1px solid var(--pressroom-line);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.pressroom-admin__brand {
|
|
40
|
+
font-weight: 600;
|
|
41
|
+
text-decoration: none;
|
|
42
|
+
color: inherit;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.pressroom-admin__nav { display: flex; gap: 16px; }
|
|
46
|
+
|
|
47
|
+
.pressroom-admin__nav-link {
|
|
48
|
+
color: var(--pressroom-muted);
|
|
49
|
+
text-decoration: none;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.pressroom-admin__nav-link:hover { color: var(--pressroom-fg); }
|
|
53
|
+
|
|
54
|
+
.pressroom-admin__main {
|
|
55
|
+
max-width: 860px;
|
|
56
|
+
margin: 0 auto;
|
|
57
|
+
padding: 24px;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.pressroom-toolbar {
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
justify-content: space-between;
|
|
64
|
+
gap: 16px;
|
|
65
|
+
margin-bottom: 16px;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.pressroom-toolbar__title { margin: 0; font-size: 22px; }
|
|
69
|
+
.pressroom-toolbar__actions { display: flex; gap: 8px; }
|
|
70
|
+
|
|
71
|
+
.pressroom-button {
|
|
72
|
+
display: inline-block;
|
|
73
|
+
padding: 7px 14px;
|
|
74
|
+
border: 1px solid var(--pressroom-line);
|
|
75
|
+
border-radius: var(--pressroom-radius);
|
|
76
|
+
background: var(--pressroom-surface);
|
|
77
|
+
color: inherit;
|
|
78
|
+
font: inherit;
|
|
79
|
+
text-decoration: none;
|
|
80
|
+
cursor: pointer;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.pressroom-button:hover { border-color: var(--pressroom-muted); }
|
|
84
|
+
|
|
85
|
+
.pressroom-button--primary {
|
|
86
|
+
background: var(--pressroom-accent);
|
|
87
|
+
border-color: var(--pressroom-accent);
|
|
88
|
+
color: #fff;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.pressroom-button--danger { color: var(--pressroom-danger); }
|
|
92
|
+
|
|
93
|
+
.pressroom-filters { display: flex; gap: 12px; margin-bottom: 16px; }
|
|
94
|
+
|
|
95
|
+
.pressroom-filters__link {
|
|
96
|
+
color: var(--pressroom-muted);
|
|
97
|
+
text-decoration: none;
|
|
98
|
+
text-transform: capitalize;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.pressroom-table { width: 100%; border-collapse: collapse; }
|
|
102
|
+
|
|
103
|
+
.pressroom-table__heading {
|
|
104
|
+
text-align: left;
|
|
105
|
+
padding: 8px 10px;
|
|
106
|
+
border-bottom: 1px solid var(--pressroom-line);
|
|
107
|
+
font-size: 12px;
|
|
108
|
+
text-transform: uppercase;
|
|
109
|
+
letter-spacing: 0.04em;
|
|
110
|
+
color: var(--pressroom-muted);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.pressroom-table__cell {
|
|
114
|
+
padding: 10px;
|
|
115
|
+
border-bottom: 1px solid var(--pressroom-line);
|
|
116
|
+
vertical-align: middle;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.pressroom-badge {
|
|
120
|
+
display: inline-block;
|
|
121
|
+
padding: 1px 8px;
|
|
122
|
+
border-radius: 999px;
|
|
123
|
+
background: var(--pressroom-surface);
|
|
124
|
+
border: 1px solid var(--pressroom-line);
|
|
125
|
+
font-size: 12px;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.pressroom-badge--published { border-color: var(--pressroom-accent); }
|
|
129
|
+
.pressroom-badge--scheduled { border-style: dashed; }
|
|
130
|
+
|
|
131
|
+
.pressroom-form { display: flex; flex-direction: column; gap: 16px; }
|
|
132
|
+
|
|
133
|
+
.pressroom-field { display: flex; flex-direction: column; gap: 6px; }
|
|
134
|
+
|
|
135
|
+
.pressroom-field__label { font-size: 13px; color: var(--pressroom-muted); }
|
|
136
|
+
|
|
137
|
+
.pressroom-field__input {
|
|
138
|
+
width: 100%;
|
|
139
|
+
padding: 8px 10px;
|
|
140
|
+
border: 1px solid var(--pressroom-line);
|
|
141
|
+
border-radius: var(--pressroom-radius);
|
|
142
|
+
background: var(--pressroom-bg);
|
|
143
|
+
color: inherit;
|
|
144
|
+
font: inherit;
|
|
145
|
+
box-sizing: border-box;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.pressroom-editor {
|
|
149
|
+
border: 1px solid var(--pressroom-line);
|
|
150
|
+
border-radius: var(--pressroom-radius);
|
|
151
|
+
padding: 8px 16px;
|
|
152
|
+
background: var(--pressroom-bg);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* Editor.js injects its own styles; this only sizes the container. */
|
|
156
|
+
.pressroom-editor__holder { min-height: 240px; }
|
|
157
|
+
|
|
158
|
+
.pressroom-schedule {
|
|
159
|
+
margin-bottom: 20px;
|
|
160
|
+
padding: 12px 16px;
|
|
161
|
+
border: 1px solid var(--pressroom-line);
|
|
162
|
+
border-radius: var(--pressroom-radius);
|
|
163
|
+
background: var(--pressroom-surface);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.pressroom-schedule__summary { cursor: pointer; }
|
|
167
|
+
|
|
168
|
+
.pressroom-schedule__form {
|
|
169
|
+
display: flex;
|
|
170
|
+
gap: 8px;
|
|
171
|
+
align-items: center;
|
|
172
|
+
margin-top: 10px;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.pressroom-schedule__hint {
|
|
176
|
+
margin: 8px 0 0;
|
|
177
|
+
font-size: 13px;
|
|
178
|
+
color: var(--pressroom-muted);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.pressroom-errors {
|
|
182
|
+
margin: 0;
|
|
183
|
+
padding: 12px 16px 12px 32px;
|
|
184
|
+
border: 1px solid var(--pressroom-danger);
|
|
185
|
+
border-radius: var(--pressroom-radius);
|
|
186
|
+
color: var(--pressroom-danger);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.pressroom-errors__item { margin: 2px 0; }
|
|
190
|
+
|
|
191
|
+
.pressroom-flash { max-width: 860px; margin: 12px auto 0; padding: 0 24px; }
|
|
192
|
+
|
|
193
|
+
.pressroom-flash__message {
|
|
194
|
+
margin: 0 0 8px;
|
|
195
|
+
padding: 8px 12px;
|
|
196
|
+
border-radius: var(--pressroom-radius);
|
|
197
|
+
background: var(--pressroom-surface);
|
|
198
|
+
border: 1px solid var(--pressroom-line);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.pressroom-flash__message--alert { border-color: var(--pressroom-danger); }
|
|
202
|
+
|
|
203
|
+
.pressroom-pagination { display: flex; gap: 6px; margin-top: 16px; }
|
|
204
|
+
|
|
205
|
+
.pressroom-pagination__link {
|
|
206
|
+
padding: 4px 9px;
|
|
207
|
+
border: 1px solid var(--pressroom-line);
|
|
208
|
+
border-radius: var(--pressroom-radius);
|
|
209
|
+
color: inherit;
|
|
210
|
+
text-decoration: none;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.pressroom-pagination__link--current {
|
|
214
|
+
border-color: var(--pressroom-accent);
|
|
215
|
+
font-weight: 600;
|
|
216
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/* Optional. Structural rules only -- the ones without which a rendered
|
|
2
|
+
block is broken rather than merely unstyled. Typography, colour and
|
|
3
|
+
spacing belong to the host application's design, not to this gem. */
|
|
4
|
+
|
|
5
|
+
.pressroom-image__img {
|
|
6
|
+
max-width: 100%;
|
|
7
|
+
height: auto;
|
|
8
|
+
display: block;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.pressroom-image--stretched .pressroom-image__img { width: 100%; }
|
|
12
|
+
|
|
13
|
+
.pressroom-code {
|
|
14
|
+
overflow-x: auto;
|
|
15
|
+
max-width: 100%;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.pressroom-list__nested { margin-left: 1.25em; }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pressroom
|
|
4
|
+
# Base controller for the admin.
|
|
5
|
+
#
|
|
6
|
+
# Inherits from the host application's controller so that its layout,
|
|
7
|
+
# helpers, current_user and middleware apply here too. The constant is
|
|
8
|
+
# resolved in the class body: initializers run before controllers are
|
|
9
|
+
# autoloaded, so configuration is already in place.
|
|
10
|
+
class ApplicationController < Pressroom.config.parent_controller.constantize
|
|
11
|
+
layout "pressroom/admin"
|
|
12
|
+
|
|
13
|
+
before_action :authenticate_pressroom_editor!
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def authenticate_pressroom_editor!
|
|
18
|
+
hook = Pressroom.config.authenticate_with
|
|
19
|
+
|
|
20
|
+
raise Pressroom::ConfigurationError, missing_hook_message if hook.nil?
|
|
21
|
+
|
|
22
|
+
instance_exec(&hook)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Failing closed is deliberate: an admin that opened to everyone until
|
|
26
|
+
# someone remembered to configure it would be a publishing panel left
|
|
27
|
+
# unlocked in production.
|
|
28
|
+
def missing_hook_message
|
|
29
|
+
<<~MESSAGE
|
|
30
|
+
Pressroom refuses to open its admin because no authentication hook
|
|
31
|
+
is configured. Add one to config/initializers/pressroom.rb:
|
|
32
|
+
|
|
33
|
+
Pressroom.configure do |config|
|
|
34
|
+
config.authenticate_with do
|
|
35
|
+
redirect_to main_app.root_path unless current_user&.editor?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
MESSAGE
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pressroom
|
|
4
|
+
class CategoriesController < ApplicationController
|
|
5
|
+
before_action :set_category, only: %i[edit update destroy]
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@categories = Category.order(:name)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def new
|
|
12
|
+
@category = Category.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def edit; end
|
|
16
|
+
|
|
17
|
+
def create
|
|
18
|
+
@category = Category.new(category_params)
|
|
19
|
+
|
|
20
|
+
if @category.save
|
|
21
|
+
redirect_to categories_path, notice: t("pressroom.categories.created")
|
|
22
|
+
else
|
|
23
|
+
render :new, status: 422
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def update
|
|
28
|
+
if @category.update(category_params)
|
|
29
|
+
redirect_to categories_path, notice: t("pressroom.categories.updated")
|
|
30
|
+
else
|
|
31
|
+
render :edit, status: 422
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def destroy
|
|
36
|
+
@category.destroy!
|
|
37
|
+
redirect_to categories_path, notice: t("pressroom.categories.destroyed")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def set_category
|
|
43
|
+
@category = Category.find(params[:id])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def category_params
|
|
47
|
+
params.require(:category).permit(:name, :slug, :parent_id, :description)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pressroom
|
|
4
|
+
class PostsController < ApplicationController
|
|
5
|
+
before_action :set_post, only: %i[edit update destroy publish unpublish]
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@page = [params[:page].to_i, 1].max
|
|
9
|
+
@per_page = Pressroom.config.admin_per_page
|
|
10
|
+
|
|
11
|
+
scope = Post.includes(:category).recent
|
|
12
|
+
scope = scope.where(status: params[:status]) if Post.statuses.key?(params[:status])
|
|
13
|
+
|
|
14
|
+
@total = scope.count
|
|
15
|
+
@posts = scope.offset((@page - 1) * @per_page).limit(@per_page)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def new
|
|
19
|
+
@post = Post.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def edit; end
|
|
23
|
+
|
|
24
|
+
def create
|
|
25
|
+
@post = Post.new(post_params)
|
|
26
|
+
|
|
27
|
+
if @post.save
|
|
28
|
+
redirect_to edit_post_path(@post), notice: t("pressroom.posts.created")
|
|
29
|
+
else
|
|
30
|
+
# Numeric rather than :unprocessable_entity: Rack deprecated that
|
|
31
|
+
# name, and its replacement is not available on every Rack version
|
|
32
|
+
# this gem supports.
|
|
33
|
+
render :new, status: 422
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def update
|
|
38
|
+
if @post.update(post_params)
|
|
39
|
+
redirect_to edit_post_path(@post), notice: t("pressroom.posts.updated")
|
|
40
|
+
else
|
|
41
|
+
render :edit, status: 422
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def destroy
|
|
46
|
+
@post.destroy!
|
|
47
|
+
redirect_to posts_path, notice: t("pressroom.posts.destroyed")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Manual publication, and scheduling, are the same action: the only
|
|
51
|
+
# difference is whether the timestamp is in the future. Nothing polls
|
|
52
|
+
# and nothing is enqueued -- Post.published compares against the clock.
|
|
53
|
+
def publish
|
|
54
|
+
@post.update!(status: :published, published_at: requested_publish_time)
|
|
55
|
+
|
|
56
|
+
notice = @post.published? ? t("pressroom.posts.published") : t("pressroom.posts.scheduled")
|
|
57
|
+
redirect_to edit_post_path(@post), notice: notice
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def unpublish
|
|
61
|
+
@post.update!(status: :draft, published_at: nil)
|
|
62
|
+
redirect_to edit_post_path(@post), notice: t("pressroom.posts.unpublished")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def set_post
|
|
68
|
+
@post = Post.find(params[:id])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def requested_publish_time
|
|
72
|
+
raw = params[:published_at]
|
|
73
|
+
return Time.current if raw.blank?
|
|
74
|
+
|
|
75
|
+
Time.zone.parse(raw.to_s) || Time.current
|
|
76
|
+
rescue ArgumentError
|
|
77
|
+
Time.current
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def post_params
|
|
81
|
+
permitted = params.require(:post)
|
|
82
|
+
.permit(:title, :slug, :excerpt, :category_id, :status,
|
|
83
|
+
:published_at, :tag_names, :content)
|
|
84
|
+
|
|
85
|
+
permitted.merge(content: parse_content(permitted[:content]))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The editor submits its document as a JSON string in a hidden field.
|
|
89
|
+
# A malformed payload must not lose the rest of the form.
|
|
90
|
+
def parse_content(raw)
|
|
91
|
+
return Post::EMPTY_DOCUMENT.deep_dup if raw.blank?
|
|
92
|
+
|
|
93
|
+
JSON.parse(raw)
|
|
94
|
+
rescue JSON::ParserError
|
|
95
|
+
Post::EMPTY_DOCUMENT.deep_dup
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pressroom
|
|
4
|
+
class TagsController < ApplicationController
|
|
5
|
+
before_action :set_tag, only: %i[edit update destroy]
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@tags = Tag.left_joins(:taggings)
|
|
9
|
+
.group(:id)
|
|
10
|
+
.select("pressroom_tags.*, COUNT(pressroom_taggings.id) AS posts_count")
|
|
11
|
+
.order(:name)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def edit; end
|
|
15
|
+
|
|
16
|
+
def update
|
|
17
|
+
if @tag.update(tag_params)
|
|
18
|
+
redirect_to tags_path, notice: t("pressroom.tags.updated")
|
|
19
|
+
else
|
|
20
|
+
render :edit, status: 422
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def destroy
|
|
25
|
+
@tag.destroy!
|
|
26
|
+
redirect_to tags_path, notice: t("pressroom.tags.destroyed")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def set_tag
|
|
32
|
+
@tag = Tag.find(params[:id])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def tag_params
|
|
36
|
+
params.require(:tag).permit(:name, :slug)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pressroom
|
|
4
|
+
# Upload endpoint for @editorjs/image.
|
|
5
|
+
#
|
|
6
|
+
# Answers the tool's contract: { success: 1, file: { url: ... } } on
|
|
7
|
+
# success, { success: 0, message: ... } on refusal.
|
|
8
|
+
class UploadsController < ApplicationController
|
|
9
|
+
def create
|
|
10
|
+
return head :not_found unless Pressroom.active_storage?
|
|
11
|
+
|
|
12
|
+
file = params[:image]
|
|
13
|
+
return head :bad_request if file.blank?
|
|
14
|
+
|
|
15
|
+
error = rejection_reason(file)
|
|
16
|
+
return refuse(error) if error
|
|
17
|
+
|
|
18
|
+
blob = ActiveStorage::Blob.create_and_upload!(
|
|
19
|
+
io: file.tempfile,
|
|
20
|
+
filename: file.original_filename,
|
|
21
|
+
content_type: file.content_type
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
render json: { success: 1, file: { url: blob_url(blob) } }
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
Rails.logger.warn("pressroom: upload failed: #{e.message}")
|
|
27
|
+
refuse("upload failed")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def rejection_reason(file)
|
|
33
|
+
unless Pressroom.config.image_content_types.include?(file.content_type)
|
|
34
|
+
return "#{file.content_type} is not an allowed image type"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
return "file is too large" if file.size > Pressroom.config.max_image_bytes
|
|
38
|
+
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def refuse(message)
|
|
43
|
+
render json: { success: 0, message: message }, status: 422
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Built through the host's routes: ActiveStorage lives in the main
|
|
47
|
+
# application, not in this engine.
|
|
48
|
+
def blob_url(blob)
|
|
49
|
+
Rails.application.routes.url_helpers.rails_blob_path(blob, only_path: true)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pressroom
|
|
4
|
+
# Public API for host applications: renders a stored document.
|
|
5
|
+
#
|
|
6
|
+
# <%= render_blocks @post %>
|
|
7
|
+
module BlocksHelper
|
|
8
|
+
def render_blocks(source)
|
|
9
|
+
document = source.respond_to?(:content) ? source.content : source
|
|
10
|
+
Pressroom.renderer.render(document, self)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pressroom
|
|
4
|
+
# Attaches the configurable author association.
|
|
5
|
+
#
|
|
6
|
+
# The author class is the host application's, so it may not be configured
|
|
7
|
+
# at all. Declaring belongs_to with a blank class_name in that case leaves
|
|
8
|
+
# a landmine: the association resolves lazily and blows up with a
|
|
9
|
+
# NoMethodError from deep inside ActiveRecord the first time anything
|
|
10
|
+
# touches post.author.
|
|
11
|
+
module Authorable
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
|
|
14
|
+
included do
|
|
15
|
+
if Pressroom.config.author_class.present?
|
|
16
|
+
belongs_to :author, class_name: Pressroom.config.author_class, optional: true
|
|
17
|
+
else
|
|
18
|
+
define_method(:author) { nil }
|
|
19
|
+
define_method(:author=) { |_record| nil }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pressroom
|
|
4
|
+
# Slug generation that survives non-Latin titles.
|
|
5
|
+
#
|
|
6
|
+
# ActiveSupport's parameterize drops Cyrillic entirely, so a Russian
|
|
7
|
+
# title would otherwise yield an empty slug. Transliteration runs first,
|
|
8
|
+
# through a configurable hook, using the rules the gem carries for :ru
|
|
9
|
+
# and :uk.
|
|
10
|
+
module Sluggable
|
|
11
|
+
extend ActiveSupport::Concern
|
|
12
|
+
|
|
13
|
+
FALLBACK_SLUG = "post"
|
|
14
|
+
|
|
15
|
+
included do
|
|
16
|
+
before_validation :assign_slug
|
|
17
|
+
validates :slug, presence: true, uniqueness: true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class_methods do
|
|
21
|
+
def slugify(string)
|
|
22
|
+
locale = Pressroom.config.transliteration_locale || I18n.locale
|
|
23
|
+
latin = Pressroom.config.transliterator.call(string.to_s, locale)
|
|
24
|
+
|
|
25
|
+
latin.downcase
|
|
26
|
+
.gsub(/[^a-z0-9]+/, "-")
|
|
27
|
+
.gsub(/\A-+|-+\z/, "")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# The source column for the slug. Models including this concern override
|
|
34
|
+
# it if they are not titled.
|
|
35
|
+
def slug_source
|
|
36
|
+
title
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def assign_slug
|
|
40
|
+
return if slug.present?
|
|
41
|
+
|
|
42
|
+
base = self.class.slugify(slug_source)
|
|
43
|
+
base = FALLBACK_SLUG if base.empty?
|
|
44
|
+
|
|
45
|
+
self.slug = unique_slug(base)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def unique_slug(base)
|
|
49
|
+
candidate = base
|
|
50
|
+
suffix = 1
|
|
51
|
+
|
|
52
|
+
while slug_taken?(candidate)
|
|
53
|
+
suffix += 1
|
|
54
|
+
candidate = "#{base}-#{suffix}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
candidate
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def slug_taken?(candidate)
|
|
61
|
+
scope = self.class.where(slug: candidate)
|
|
62
|
+
scope = scope.where.not(id: id) if persisted?
|
|
63
|
+
scope.exists?
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pressroom
|
|
4
|
+
class Category < ApplicationRecord
|
|
5
|
+
include Pressroom::Sluggable
|
|
6
|
+
|
|
7
|
+
self.table_name = "pressroom_categories"
|
|
8
|
+
|
|
9
|
+
belongs_to :parent, class_name: "Pressroom::Category", optional: true
|
|
10
|
+
has_many :children, class_name: "Pressroom::Category", foreign_key: :parent_id,
|
|
11
|
+
dependent: :nullify, inverse_of: :parent
|
|
12
|
+
has_many :posts, class_name: "Pressroom::Post", dependent: :nullify,
|
|
13
|
+
inverse_of: :category
|
|
14
|
+
|
|
15
|
+
validates :name, presence: true
|
|
16
|
+
validate :parent_is_not_a_cycle
|
|
17
|
+
|
|
18
|
+
scope :roots, -> { where(parent_id: nil) }
|
|
19
|
+
|
|
20
|
+
# Nearest ancestor first, root last.
|
|
21
|
+
def ancestors
|
|
22
|
+
result = []
|
|
23
|
+
node = parent
|
|
24
|
+
|
|
25
|
+
while node && !result.include?(node)
|
|
26
|
+
result << node
|
|
27
|
+
node = node.parent
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
result
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def depth
|
|
34
|
+
ancestors.size
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def slug_source
|
|
40
|
+
name
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parent_is_not_a_cycle
|
|
44
|
+
return if parent_id.nil?
|
|
45
|
+
|
|
46
|
+
if parent_id == id
|
|
47
|
+
errors.add(:parent_id, "cannot be the category itself")
|
|
48
|
+
return
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
errors.add(:parent_id, "would create a cycle") if parent&.ancestors&.include?(self)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|