i18n_proofreading 0.10.6 → 0.11.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 +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +1 -0
- data/app/controllers/concerns/i18n_proofreading/request_context.rb +41 -0
- data/app/controllers/i18n_proofreading/application_controller.rb +9 -29
- data/app/controllers/i18n_proofreading/dashboard_controller.rb +30 -0
- data/app/controllers/i18n_proofreading/submissions_controller.rb +74 -0
- data/app/controllers/i18n_proofreading/suggestions_controller.rb +1 -64
- data/app/views/i18n_proofreading/shared/_dashboard.html.erb +13 -0
- data/app/views/i18n_proofreading/suggestions/index.html.erb +88 -86
- data/app/views/i18n_proofreading/suggestions/show.html.erb +7 -5
- data/app/views/layouts/i18n_proofreading/application.html.erb +8 -5
- data/config/routes.rb +7 -3
- data/lib/generators/i18n_proofreading/install/install_generator.rb +2 -6
- data/lib/generators/i18n_proofreading/install/templates/create_i18n_proofreading_suggestions.rb.tt +1 -1
- data/lib/generators/i18n_proofreading/install/templates/initializer.rb +8 -0
- data/lib/generators/i18n_proofreading/migration_helpers.rb +28 -0
- data/lib/i18n_proofreading/configuration.rb +21 -1
- data/lib/i18n_proofreading/dashboard.css +153 -156
- data/lib/i18n_proofreading/version.rb +1 -1
- data/lib/i18n_proofreading.rb +8 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3b4d3167c04697942a2b695b1f4b81554a4f12daabc6a8d730b0d4374bb31292
|
|
4
|
+
data.tar.gz: 95f184235ea7319ebdd254592924c25f8503ac0fee7943282cd939478d037a54
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 564ff0282d7ba45a0ef399c82ff6b287dfe54108512aa91356769a6e652fa66875bb3a04c195aed57bce3507eabf50a0d7505ec386ebbe34ed0186e7c8ff065f
|
|
7
|
+
data.tar.gz: de5b07512272838e8f15483978cb536d0f4ae55e33fe6c5f17d1d3d7b4b5430badf6bfc54c96a01f37802976565afc287c78f5ccc3d40924e41f7e54137fae7e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.11.0
|
|
4
|
+
|
|
5
|
+
- **`config.admin_layout` now works on its own.** The dashboard's stylesheet was
|
|
6
|
+
declared in the gem's layout, so replacing that layout dropped it and the
|
|
7
|
+
dashboard rendered unstyled. It moves into the views, so every layout gets it
|
|
8
|
+
with nothing asked of the host.
|
|
9
|
+
- **The dashboard stylesheet no longer claims selectors it does not own.** It
|
|
10
|
+
styled bare `*`, `body` and `a`, and its `.container`, `.card` and `.tabs` are
|
|
11
|
+
names other frameworks use too. Component rules now nest inside an
|
|
12
|
+
`.ip-dashboard` wrapper the views render, and every custom property is `--ip-`
|
|
13
|
+
prefixed — that collision ran both ways, so a host defining `--bg` recoloured
|
|
14
|
+
the dashboard just as easily.
|
|
15
|
+
- **Added `config.base_controller_class`.** Name the controller your own admin
|
|
16
|
+
inherits from and the dashboard adopts its layout, helpers, authentication and
|
|
17
|
+
request context. Default is unchanged.
|
|
18
|
+
- **The widget's endpoints moved to `I18nProofreading::SubmissionsController`.**
|
|
19
|
+
One controller served both them and the triage dashboard, so
|
|
20
|
+
`base_controller_class` would have put staff authentication in front of every
|
|
21
|
+
proofreader. `POST /suggestions` and `GET /suggestions/context` now route
|
|
22
|
+
there; both URLs are unchanged, and the per-IP rate limiter moved with the
|
|
23
|
+
action. If you referenced `SuggestionsController#create` or `#context`, that is
|
|
24
|
+
the breaking change in this release.
|
|
25
|
+
- **Migrations follow the host's `primary_key_type`,** the same
|
|
26
|
+
`Rails.configuration.generators` lookup Rails' own Active Storage migration
|
|
27
|
+
does, so a uuid-keyed host gets a uuid table.
|
|
28
|
+
- A `BackboneTest` now fails the build on any of the above regressing.
|
|
29
|
+
|
|
3
30
|
## [Unreleased]
|
|
4
31
|
|
|
5
32
|
## [0.10.6]
|
data/README.md
CHANGED
|
@@ -105,6 +105,7 @@ Everything is optional — the defaults work out of the box in development. In
|
|
|
105
105
|
| `enabled_environments` | `%w[development staging]` | Environments the tool is active in |
|
|
106
106
|
| `enabled` | everyone | Extra per-request gate. `false` hides the tool |
|
|
107
107
|
| `authorize_admin` | development only | **Who can open the review board.** Independent of the gates above |
|
|
108
|
+
| `base_controller_class` | `ActionController::Base` | Controller the dashboard inherits — name your admin's and it adopts its layout, helpers and auth |
|
|
108
109
|
| `current_user` | `nil` | Attribute a suggestion to a user. Receives the request |
|
|
109
110
|
| `author_label` | the user's `email` | Label shown for the author |
|
|
110
111
|
| `available_locales` | `I18n.available_locales` | Which locales a suggestion may target |
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nProofreading
|
|
4
|
+
# Who is asking, and the gates that answer it.
|
|
5
|
+
#
|
|
6
|
+
# A concern rather than inherited behaviour because the engine has two
|
|
7
|
+
# controller roots: the public endpoints hang off ActionController::Base, and
|
|
8
|
+
# the dashboard hangs off whatever the host set as `base_controller_class`.
|
|
9
|
+
module RequestContext
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def i18n_proofreading_admin_layout
|
|
15
|
+
I18nProofreading.config.admin_layout
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Gate for the widget's public API (submit a suggestion, read prior context).
|
|
19
|
+
# The client can set the cookie, but it can never reach the endpoints unless
|
|
20
|
+
# the app itself says the tool is available for this request.
|
|
21
|
+
def require_available
|
|
22
|
+
head :forbidden unless I18nProofreading.available?(request)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Gate for the triage dashboard. Independent of #require_available (see
|
|
26
|
+
# I18nProofreading.admin?). Renders a plain 403 with a hint rather than a bare
|
|
27
|
+
# head, since a human is usually looking at it.
|
|
28
|
+
def require_admin
|
|
29
|
+
return if I18nProofreading.admin?(request)
|
|
30
|
+
|
|
31
|
+
render plain: 'Forbidden. Set I18nProofreading.config.authorize_admin to grant access.',
|
|
32
|
+
status: :forbidden
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def current_author
|
|
36
|
+
return @current_author if defined?(@current_author)
|
|
37
|
+
|
|
38
|
+
@current_author = I18nProofreading.config.current_user.call(request)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -1,36 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module I18nProofreading
|
|
4
|
+
# Root of the engine's PUBLIC surface: widget.js, the context lookup and the
|
|
5
|
+
# suggestion endpoint. These stay on a plain ActionController::Base
|
|
6
|
+
# deliberately — a proofreader suggesting a translation must not be routed
|
|
7
|
+
# through a host's admin controller, which would demand a staff session.
|
|
8
|
+
#
|
|
9
|
+
# The dashboard's root is DashboardController, and that is where
|
|
10
|
+
# `config.base_controller_class` applies.
|
|
4
11
|
class ApplicationController < ActionController::Base
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
private
|
|
8
|
-
|
|
9
|
-
def i18n_proofreading_admin_layout
|
|
10
|
-
I18nProofreading.config.admin_layout
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
# Gate for the widget's public API (submit a suggestion, read prior context).
|
|
14
|
-
# The client can set the cookie, but it can never reach the endpoints unless
|
|
15
|
-
# the app itself says the tool is available for this request.
|
|
16
|
-
def require_available
|
|
17
|
-
head :forbidden unless I18nProofreading.available?(request)
|
|
18
|
-
end
|
|
12
|
+
include RequestContext
|
|
19
13
|
|
|
20
|
-
|
|
21
|
-
# I18nProofreading.admin?). Renders a plain 403 with a hint rather than a bare
|
|
22
|
-
# head, since a human is usually looking at it.
|
|
23
|
-
def require_admin
|
|
24
|
-
return if I18nProofreading.admin?(request)
|
|
25
|
-
|
|
26
|
-
render plain: 'Forbidden. Set I18nProofreading.config.authorize_admin to grant access.',
|
|
27
|
-
status: :forbidden
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def current_author
|
|
31
|
-
return @current_author if defined?(@current_author)
|
|
32
|
-
|
|
33
|
-
@current_author = I18nProofreading.config.current_user.call(request)
|
|
34
|
-
end
|
|
14
|
+
protect_from_forgery with: :exception
|
|
35
15
|
end
|
|
36
16
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nProofreading
|
|
4
|
+
# Root of the STAFF surface: the suggestion triage dashboard.
|
|
5
|
+
#
|
|
6
|
+
# Inherits from `config.base_controller_class` — by default a plain
|
|
7
|
+
# ActionController::Base, which is why `authorize_admin` exists. Point it at
|
|
8
|
+
# the controller your own admin already inherits from and the dashboard picks
|
|
9
|
+
# up that stack wholesale: your layout, your helpers, your authentication, and
|
|
10
|
+
# whatever request context your before_actions establish.
|
|
11
|
+
#
|
|
12
|
+
# Only the dashboard hangs off it. The widget's endpoints stay on
|
|
13
|
+
# ApplicationController, so wiring an admin base controller here can never
|
|
14
|
+
# demand a staff session from someone suggesting a translation.
|
|
15
|
+
class DashboardController < I18nProofreading.base_controller
|
|
16
|
+
include RequestContext
|
|
17
|
+
|
|
18
|
+
# A host base controller brings its own layout, and declaring one here would
|
|
19
|
+
# override it. So the gem only claims the layout when it owns the decision:
|
|
20
|
+
# no host base controller, or a host that named an `admin_layout` explicitly.
|
|
21
|
+
layout :i18n_proofreading_admin_layout unless superclass != ActionController::Base &&
|
|
22
|
+
I18nProofreading.config.admin_layout == Configuration::DEFAULT_ADMIN_LAYOUT
|
|
23
|
+
|
|
24
|
+
before_action :require_admin
|
|
25
|
+
|
|
26
|
+
# A host base controller has configured CSRF already; declaring it twice
|
|
27
|
+
# would run the check twice.
|
|
28
|
+
protect_from_forgery with: :exception if superclass == ActionController::Base
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nProofreading
|
|
4
|
+
# The widget's public API: the context lookup it reads and the suggestion it
|
|
5
|
+
# posts.
|
|
6
|
+
#
|
|
7
|
+
# Public, so it stays on ApplicationController and never inherits a host's
|
|
8
|
+
# admin base controller — a proofreader must not be asked for a staff session.
|
|
9
|
+
# The triage actions live in SuggestionsController, which does inherit it.
|
|
10
|
+
class SubmissionsController < ApplicationController
|
|
11
|
+
before_action :require_available
|
|
12
|
+
|
|
13
|
+
# Throttle the public submission endpoint per IP so one user or bot can't
|
|
14
|
+
# flood the table. Uses the rate limiter built into Rails 7.2+ (backed by
|
|
15
|
+
# Rails.cache); a no-op on Rails 7.1. Tune or disable via config.rate_limit —
|
|
16
|
+
# read once at boot, after the host's initializer.
|
|
17
|
+
if respond_to?(:rate_limit) && I18nProofreading.config.rate_limit
|
|
18
|
+
rate_limit(**I18nProofreading.config.rate_limit,
|
|
19
|
+
only: :create,
|
|
20
|
+
with: lambda {
|
|
21
|
+
render json: { errors: ['Too many suggestions. Please slow down and try again.'] },
|
|
22
|
+
status: :too_many_requests
|
|
23
|
+
})
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Pending suggestions for one key/locale, shown as read-only context when the
|
|
27
|
+
# proofreader reopens the popover for a string someone already flagged.
|
|
28
|
+
def context
|
|
29
|
+
suggestions = Suggestion
|
|
30
|
+
.where(translation_key: params[:key], locale: params[:locale])
|
|
31
|
+
.status_pending
|
|
32
|
+
.newest_first
|
|
33
|
+
.limit(20)
|
|
34
|
+
|
|
35
|
+
render json: suggestions.map { |suggestion| suggestion_json(suggestion) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def create
|
|
39
|
+
suggestion = Suggestion.new(suggestion_params)
|
|
40
|
+
attribute_author(suggestion)
|
|
41
|
+
|
|
42
|
+
if suggestion.save
|
|
43
|
+
I18nProofreading.config.on_submit.call(suggestion)
|
|
44
|
+
head :created
|
|
45
|
+
else
|
|
46
|
+
render json: { errors: suggestion.errors.full_messages }, status: :unprocessable_entity
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def attribute_author(suggestion)
|
|
53
|
+
author = current_author
|
|
54
|
+
return unless author
|
|
55
|
+
|
|
56
|
+
suggestion.author_id = author.id.to_s if author.respond_to?(:id)
|
|
57
|
+
suggestion.author_label = I18nProofreading.config.author_label.call(author)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def suggestion_json(suggestion)
|
|
61
|
+
{
|
|
62
|
+
proposed_value: suggestion.proposed_value,
|
|
63
|
+
author_label: suggestion.author_label,
|
|
64
|
+
created_at: suggestion.created_at.iso8601
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def suggestion_params
|
|
69
|
+
params
|
|
70
|
+
.require(:suggestion)
|
|
71
|
+
.permit(:translation_key, :locale, :old_value, :proposed_value, :comment, :page_url)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -1,29 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module I18nProofreading
|
|
4
|
-
class SuggestionsController <
|
|
4
|
+
class SuggestionsController < DashboardController
|
|
5
5
|
PER_PAGE = 50
|
|
6
6
|
|
|
7
7
|
# Public widget API is available-gated; the read-only dashboard is admin-gated.
|
|
8
|
-
before_action :require_available, only: %i[context create]
|
|
9
|
-
before_action :require_admin, only: %i[index show]
|
|
10
8
|
before_action :set_suggestion, only: :show
|
|
11
9
|
|
|
12
|
-
layout :i18n_proofreading_admin_layout, only: %i[index show]
|
|
13
|
-
|
|
14
|
-
# Throttle the public submission endpoint per IP so one user or bot can't
|
|
15
|
-
# flood the table. Uses the rate limiter built into Rails 7.2+ (backed by
|
|
16
|
-
# Rails.cache); a no-op on Rails 7.1. Tune or disable via config.rate_limit —
|
|
17
|
-
# read once at boot, after the host's initializer.
|
|
18
|
-
if respond_to?(:rate_limit) && I18nProofreading.config.rate_limit
|
|
19
|
-
rate_limit(**I18nProofreading.config.rate_limit,
|
|
20
|
-
only: :create,
|
|
21
|
-
with: lambda {
|
|
22
|
-
render json: { errors: ['Too many suggestions. Please slow down and try again.'] },
|
|
23
|
-
status: :too_many_requests
|
|
24
|
-
})
|
|
25
|
-
end
|
|
26
|
-
|
|
27
10
|
# --- triage dashboard (admin) --------------------------------------------
|
|
28
11
|
|
|
29
12
|
def index
|
|
@@ -46,56 +29,10 @@ module I18nProofreading
|
|
|
46
29
|
|
|
47
30
|
# --- widget API (public) -------------------------------------------------
|
|
48
31
|
|
|
49
|
-
# Pending suggestions for one key/locale, shown as read-only context when the
|
|
50
|
-
# proofreader reopens the popover for a string someone already flagged.
|
|
51
|
-
def context
|
|
52
|
-
suggestions = Suggestion
|
|
53
|
-
.where(translation_key: params[:key], locale: params[:locale])
|
|
54
|
-
.status_pending
|
|
55
|
-
.newest_first
|
|
56
|
-
.limit(20)
|
|
57
|
-
|
|
58
|
-
render json: suggestions.map { |suggestion| suggestion_json(suggestion) }
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def create
|
|
62
|
-
suggestion = Suggestion.new(suggestion_params)
|
|
63
|
-
attribute_author(suggestion)
|
|
64
|
-
|
|
65
|
-
if suggestion.save
|
|
66
|
-
I18nProofreading.config.on_submit.call(suggestion)
|
|
67
|
-
head :created
|
|
68
|
-
else
|
|
69
|
-
render json: { errors: suggestion.errors.full_messages }, status: :unprocessable_entity
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
|
|
73
32
|
private
|
|
74
33
|
|
|
75
34
|
def set_suggestion
|
|
76
35
|
@suggestion = Suggestion.find(params[:id])
|
|
77
36
|
end
|
|
78
|
-
|
|
79
|
-
def attribute_author(suggestion)
|
|
80
|
-
author = current_author
|
|
81
|
-
return unless author
|
|
82
|
-
|
|
83
|
-
suggestion.author_id = author.id.to_s if author.respond_to?(:id)
|
|
84
|
-
suggestion.author_label = I18nProofreading.config.author_label.call(author)
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def suggestion_json(suggestion)
|
|
88
|
-
{
|
|
89
|
-
proposed_value: suggestion.proposed_value,
|
|
90
|
-
author_label: suggestion.author_label,
|
|
91
|
-
created_at: suggestion.created_at.iso8601
|
|
92
|
-
}
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def suggestion_params
|
|
96
|
-
params
|
|
97
|
-
.require(:suggestion)
|
|
98
|
-
.permit(:translation_key, :locale, :old_value, :proposed_value, :comment, :page_url)
|
|
99
|
-
end
|
|
100
37
|
end
|
|
101
38
|
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<%# The dashboard's own shell, rendered by the views rather than the layout.
|
|
2
|
+
That is deliberate: `config.admin_layout` and `config.base_controller_class`
|
|
3
|
+
both let a host replace the layout, and a stylesheet declared in a layout the
|
|
4
|
+
host replaces simply vanishes — the dashboard then renders unstyled.
|
|
5
|
+
Declaring it here means every layout works, host or gem.
|
|
6
|
+
|
|
7
|
+
The `ip-dashboard` wrapper is what scopes dashboard.css (see the comment at
|
|
8
|
+
the top of that file), so it has to enclose the content, not precede it. %>
|
|
9
|
+
<%= stylesheet_link_tag "#{dashboard_stylesheet_path}?v=#{I18nProofreading::Widget.dashboard_stylesheet_fingerprint}",
|
|
10
|
+
'data-turbo-track': 'reload' %>
|
|
11
|
+
<div class="ip-dashboard">
|
|
12
|
+
<%= yield %>
|
|
13
|
+
</div>
|
|
@@ -1,99 +1,101 @@
|
|
|
1
1
|
<% content_for :body_class, 'ip-index' %>
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
<%= render layout: 'i18n_proofreading/shared/dashboard' do %>
|
|
4
|
+
<header class="page">
|
|
5
|
+
<h1><%= t('i18n_proofreading.dashboard.title', default: 'I18nProofreading') %></h1>
|
|
6
|
+
<p class="lede"><%= t('i18n_proofreading.dashboard.subtitle', default: 'Review what reviewers proposed, then apply it to your locale files.') %></p>
|
|
7
|
+
</header>
|
|
7
8
|
|
|
8
|
-
<div class="review-shell <%= 'has-selected' if @selected_suggestion %>">
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
<div class="review-shell <%= 'has-selected' if @selected_suggestion %>">
|
|
10
|
+
<aside class="review-sidebar" aria-label="<%= t('i18n_proofreading.dashboard.suggestions', default: 'Suggestions') %>">
|
|
11
|
+
<nav class="tabs">
|
|
12
|
+
<% I18nProofreading::Suggestion::STATUSES.each do |status| %>
|
|
13
|
+
<%= link_to suggestions_path(status: status, locale: @locale),
|
|
14
|
+
class: ('active' if status == @status) do %>
|
|
15
|
+
<%= t("i18n_proofreading.statuses.#{status}", default: status.humanize) %>
|
|
16
|
+
<span class="count"><%= @counts.fetch(status, 0) %></span>
|
|
17
|
+
<% end %>
|
|
16
18
|
<% end %>
|
|
17
|
-
|
|
18
|
-
</nav>
|
|
19
|
+
</nav>
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
21
|
+
<% if @locales.many? %>
|
|
22
|
+
<form class="filters" method="get">
|
|
23
|
+
<input type="hidden" name="status" value="<%= @status %>">
|
|
24
|
+
<label for="i18np-locale"><%= t('i18n_proofreading.dashboard.locale', default: 'Locale') %></label>
|
|
25
|
+
<select id="i18np-locale" name="locale" onchange="this.form.submit()">
|
|
26
|
+
<option value=""><%= t('i18n_proofreading.dashboard.all_locales', default: 'All locales') %></option>
|
|
27
|
+
<% @locales.each do |locale| %>
|
|
28
|
+
<option value="<%= locale %>" <%= 'selected' if locale == @locale %>><%= locale %></option>
|
|
29
|
+
<% end %>
|
|
30
|
+
</select>
|
|
31
|
+
<%# Inline onchange auto-submits where allowed; this button is the reliable
|
|
32
|
+
path where a strict CSP blocks inline handlers, or with JS off. %>
|
|
33
|
+
<button><%= t('i18n_proofreading.dashboard.filter', default: 'Filter') %></button>
|
|
34
|
+
</form>
|
|
35
|
+
<% end %>
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
37
|
+
<div class="suggestion-list">
|
|
38
|
+
<% if @suggestions.any? %>
|
|
39
|
+
<% @suggestions.each do |suggestion| %>
|
|
40
|
+
<%= link_to suggestions_path(status: @status, locale: @locale, page: @page,
|
|
41
|
+
suggestion_id: suggestion.id),
|
|
42
|
+
class: ['suggestion-row', ('active' if @selected_suggestion&.id == suggestion.id)].compact do %>
|
|
43
|
+
<span class="suggestion-main">
|
|
44
|
+
<span class="suggestion-topline">
|
|
45
|
+
<code class="key"><%= suggestion.translation_key %></code>
|
|
46
|
+
<span class="badge locale"><%= suggestion.locale %></span>
|
|
47
|
+
<span class="muted suggestion-time" title="<%= suggestion.created_at %>">
|
|
48
|
+
<%= time_ago_in_words(suggestion.created_at) %> <%= t('i18n_proofreading.dashboard.ago', default: 'ago') %>
|
|
49
|
+
</span>
|
|
50
|
+
</span>
|
|
51
|
+
<span class="suggestion-copy" dir="auto"><%= truncate(suggestion.proposed_value, length: 110) %></span>
|
|
52
|
+
<span class="muted suggestion-meta">
|
|
53
|
+
<span>#<%= suggestion.id %></span>
|
|
54
|
+
<% if suggestion.author_label.present? %>
|
|
55
|
+
<span><%= suggestion.author_label %></span>
|
|
56
|
+
<% end %>
|
|
48
57
|
</span>
|
|
49
58
|
</span>
|
|
50
|
-
<span class="
|
|
51
|
-
|
|
52
|
-
<span>#<%= suggestion.id %></span>
|
|
53
|
-
<% if suggestion.author_label.present? %>
|
|
54
|
-
<span><%= suggestion.author_label %></span>
|
|
55
|
-
<% end %>
|
|
59
|
+
<span class="badge status-<%= suggestion.status %>">
|
|
60
|
+
<%= t("i18n_proofreading.statuses.#{suggestion.status}", default: suggestion.status.humanize) %>
|
|
56
61
|
</span>
|
|
57
|
-
|
|
58
|
-
<span class="badge status-<%= suggestion.status %>">
|
|
59
|
-
<%= t("i18n_proofreading.statuses.#{suggestion.status}", default: suggestion.status.humanize) %>
|
|
60
|
-
</span>
|
|
62
|
+
<% end %>
|
|
61
63
|
<% end %>
|
|
64
|
+
<% else %>
|
|
65
|
+
<div class="empty"><%= t('i18n_proofreading.dashboard.empty', default: 'Nothing here yet.') %></div>
|
|
62
66
|
<% end %>
|
|
63
|
-
|
|
64
|
-
<div class="empty"><%= t('i18n_proofreading.dashboard.empty', default: 'Nothing here yet.') %></div>
|
|
65
|
-
<% end %>
|
|
66
|
-
</div>
|
|
67
|
+
</div>
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
69
|
+
<nav class="pager">
|
|
70
|
+
<span>
|
|
71
|
+
<% if @page > 1 %>
|
|
72
|
+
<%= link_to t('i18n_proofreading.dashboard.newer', default: '← Newer'),
|
|
73
|
+
suggestions_path(status: @status, locale: @locale, page: @page - 1) %>
|
|
74
|
+
<% end %>
|
|
75
|
+
</span>
|
|
76
|
+
<span>
|
|
77
|
+
<% if @more %>
|
|
78
|
+
<%= link_to t('i18n_proofreading.dashboard.older', default: 'Older →'),
|
|
79
|
+
suggestions_path(status: @status, locale: @locale, page: @page + 1) %>
|
|
80
|
+
<% end %>
|
|
81
|
+
</span>
|
|
82
|
+
</nav>
|
|
83
|
+
</aside>
|
|
83
84
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
</div>
|
|
85
|
+
<main class="review-detail" aria-label="<%= t('i18n_proofreading.dashboard.current_suggestion', default: 'Current suggestion') %>">
|
|
86
|
+
<% if @selected_suggestion %>
|
|
87
|
+
<p class="mobile-back">
|
|
88
|
+
<%= link_to t('i18n_proofreading.dashboard.back', default: '← All suggestions'),
|
|
89
|
+
suggestions_path(status: @status, locale: @locale, page: @page) %>
|
|
90
|
+
</p>
|
|
91
|
+
<%= render 'suggestion_panel', suggestion: @selected_suggestion %>
|
|
92
|
+
<% else %>
|
|
93
|
+
<div class="empty-state">
|
|
94
|
+
<h2><%= t('i18n_proofreading.dashboard.select_suggestion', default: 'Select a suggestion') %></h2>
|
|
95
|
+
<p class="muted"><%= t('i18n_proofreading.dashboard.select_suggestion_hint',
|
|
96
|
+
default: 'Open a row to review the proposed wording beside the queue.') %></p>
|
|
97
|
+
</div>
|
|
98
|
+
<% end %>
|
|
99
|
+
</main>
|
|
100
|
+
</div>
|
|
101
|
+
<% end %>
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
<% content_for :body_class, 'ip-show' %>
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
<%= render layout: 'i18n_proofreading/shared/dashboard' do %>
|
|
4
|
+
<p class="breadcrumb">
|
|
5
|
+
<%= link_to t('i18n_proofreading.dashboard.title', default: 'I18nProofreading'), suggestions_path %>
|
|
6
|
+
/ #<%= @suggestion.id %>
|
|
7
|
+
</p>
|
|
7
8
|
|
|
8
|
-
<%= render 'suggestion_panel', suggestion: @suggestion %>
|
|
9
|
+
<%= render 'suggestion_panel', suggestion: @suggestion %>
|
|
10
|
+
<% end %>
|
|
@@ -6,12 +6,15 @@
|
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
7
|
<%= csrf_meta_tags %>
|
|
8
8
|
<%= csp_meta_tag %>
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
<%# The dashboard's stylesheet is declared by the views
|
|
10
|
+
(i18n_proofreading/shared/_dashboard), not here, so it survives a
|
|
11
|
+
host replacing this layout via config.admin_layout or config.base_controller_class. %>
|
|
11
12
|
</head>
|
|
12
|
-
<body class="<%= content_for?(:body_class) ? yield(:body_class) : '' %>">
|
|
13
|
-
<div class="
|
|
14
|
-
|
|
13
|
+
<body class="ip-page <%= content_for?(:body_class) ? yield(:body_class) : '' %>">
|
|
14
|
+
<div class="ip-dashboard">
|
|
15
|
+
<div class="container">
|
|
16
|
+
<%= yield %>
|
|
17
|
+
</div>
|
|
15
18
|
</div>
|
|
16
19
|
</body>
|
|
17
20
|
</html>
|
data/config/routes.rb
CHANGED
|
@@ -10,9 +10,13 @@ I18nProofreading::Engine.routes.draw do
|
|
|
10
10
|
# route are the widget's public API (see SuggestionsController). There is
|
|
11
11
|
# deliberately no update/destroy — the tool never mutates suggestions from the
|
|
12
12
|
# UI, since it can't write to the host's locale files.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
# The widget's two endpoints go to SubmissionsController: they are public and
|
|
14
|
+
# the dashboard is staff-only, and only the staff half inherits
|
|
15
|
+
# config.base_controller_class. Sharing one controller would put a host's admin
|
|
16
|
+
# authentication in front of every proofreader. URLs are unchanged.
|
|
17
|
+
get 'suggestions/context', to: 'submissions#context', as: :context_suggestions
|
|
18
|
+
post 'suggestions', to: 'submissions#create', as: :submissions
|
|
19
|
+
resources :suggestions, only: %i[index show]
|
|
16
20
|
|
|
17
21
|
root to: 'suggestions#index'
|
|
18
22
|
end
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require 'rails/generators'
|
|
4
4
|
require 'rails/generators/active_record'
|
|
5
|
+
require_relative '../migration_helpers'
|
|
5
6
|
|
|
6
7
|
module I18nProofreading
|
|
7
8
|
module Generators
|
|
8
9
|
class InstallGenerator < Rails::Generators::Base
|
|
9
10
|
include ActiveRecord::Generators::Migration
|
|
11
|
+
include MigrationHelpers
|
|
10
12
|
|
|
11
13
|
source_root File.expand_path('templates', __dir__)
|
|
12
14
|
|
|
@@ -30,12 +32,6 @@ module I18nProofreading
|
|
|
30
32
|
say 'Optional: run `bin/rails i18n_proofreading:seed_demo` for sample suggestions.'
|
|
31
33
|
say "and look for the “Suggest edits” pill in the bottom-left corner.\n"
|
|
32
34
|
end
|
|
33
|
-
|
|
34
|
-
private
|
|
35
|
-
|
|
36
|
-
def migration_version
|
|
37
|
-
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
38
|
-
end
|
|
39
35
|
end
|
|
40
36
|
end
|
|
41
37
|
end
|
data/lib/generators/i18n_proofreading/install/templates/create_i18n_proofreading_suggestions.rb.tt
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
class CreateI18nProofreadingSuggestions < ActiveRecord::Migration<%= migration_version %>
|
|
4
4
|
def change
|
|
5
|
-
create_table :i18n_proofreading_suggestions do |t|
|
|
5
|
+
create_table :i18n_proofreading_suggestions<%= primary_key_type_option %> do |t|
|
|
6
6
|
t.string :translation_key, null: false
|
|
7
7
|
t.string :locale, null: false
|
|
8
8
|
t.text :old_value
|
|
@@ -20,6 +20,14 @@ I18nProofreading.configure do |config|
|
|
|
20
20
|
# Render the dashboard inside your app's admin layout. Default: the gem's
|
|
21
21
|
# standalone dashboard layout.
|
|
22
22
|
#
|
|
23
|
+
# Two ways to put the dashboard inside an admin you already have.
|
|
24
|
+
#
|
|
25
|
+
# The whole stack — your layout, helpers, authentication and any request
|
|
26
|
+
# context your before_actions set up. Only the dashboard inherits it; the
|
|
27
|
+
# widget's endpoints stay public, so this cannot gate a proofreader.
|
|
28
|
+
# config.base_controller_class = "Admin::BaseController"
|
|
29
|
+
#
|
|
30
|
+
# Or the layout alone:
|
|
23
31
|
# config.admin_layout = "admin/application"
|
|
24
32
|
|
|
25
33
|
# Attribute a suggestion to a user (optional). Return an object responding to
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module I18nProofreading
|
|
4
|
+
module Generators
|
|
5
|
+
# Shared bits every migration-writing generator needs.
|
|
6
|
+
module MigrationHelpers
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def migration_version
|
|
10
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Follow the host's own key type instead of forcing bigint, the same
|
|
14
|
+
# `Rails.configuration.generators` lookup Rails' own Active Storage,
|
|
15
|
+
# Action Text and Action Mailbox migrations do — so a host that set it
|
|
16
|
+
# once gets consistent tables from all of them.
|
|
17
|
+
#
|
|
18
|
+
# Rendered as a `create_table` option rather than a bare value, because a
|
|
19
|
+
# template is expanded at generate time: emitting the method name would
|
|
20
|
+
# put `id: primary_key_type` in the migration, where nothing defines it.
|
|
21
|
+
def primary_key_type_option
|
|
22
|
+
config = Rails.configuration.generators
|
|
23
|
+
type = config.options[config.orm][:primary_key_type]
|
|
24
|
+
type ? ", id: :#{type}" : ''
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -5,6 +5,10 @@ module I18nProofreading
|
|
|
5
5
|
# with zero configuration in development; the hooks below let an app decide who
|
|
6
6
|
# sees the tool and how suggestions are attributed.
|
|
7
7
|
class Configuration
|
|
8
|
+
# The gem's own dashboard layout. Compared against, so DashboardController
|
|
9
|
+
# can tell "the host left this alone" from "the host chose this".
|
|
10
|
+
DEFAULT_ADMIN_LAYOUT = 'i18n_proofreading/application'
|
|
11
|
+
|
|
8
12
|
# Environments the tool is active in. It is never active anywhere else, so a
|
|
9
13
|
# production deploy can't accidentally expose the key markers or the endpoint.
|
|
10
14
|
attr_accessor :enabled_environments
|
|
@@ -27,6 +31,21 @@ module I18nProofreading
|
|
|
27
31
|
# your app's admin shell, e.g. "admin/application".
|
|
28
32
|
attr_accessor :admin_layout
|
|
29
33
|
|
|
34
|
+
# The controller the DASHBOARD inherits from, as a String so it resolves
|
|
35
|
+
# lazily rather than at config time. Default: a plain
|
|
36
|
+
# 'ActionController::Base', where `authorize_admin` is the only gate.
|
|
37
|
+
#
|
|
38
|
+
# Name the controller your own admin already inherits from and the dashboard
|
|
39
|
+
# adopts that whole stack — layout, helpers, authentication, and any request
|
|
40
|
+
# context your before_actions set up. `admin_layout` covers only the layout,
|
|
41
|
+
# which leaves a host layout calling its own helpers to raise NameError under
|
|
42
|
+
# the engine's isolated namespace.
|
|
43
|
+
#
|
|
44
|
+
# Only the dashboard uses it. The widget's endpoints stay on the engine's own
|
|
45
|
+
# public controller, so an admin base controller here can never demand a
|
|
46
|
+
# staff session from someone suggesting a translation.
|
|
47
|
+
attr_accessor :base_controller_class
|
|
48
|
+
|
|
30
49
|
# Resolve the current user for attribution (optional). Return an object
|
|
31
50
|
# responding to #id, or nil. Receives the Rack::Request.
|
|
32
51
|
attr_accessor :current_user
|
|
@@ -74,7 +93,8 @@ module I18nProofreading
|
|
|
74
93
|
@enabled_environments = %w[development staging]
|
|
75
94
|
@enabled = ->(_request) { true }
|
|
76
95
|
@authorize_admin = ->(_request) { Rails.env.development? }
|
|
77
|
-
@admin_layout =
|
|
96
|
+
@admin_layout = DEFAULT_ADMIN_LAYOUT
|
|
97
|
+
@base_controller_class = 'ActionController::Base'
|
|
78
98
|
@current_user = ->(_request) {}
|
|
79
99
|
@author_label = ->(user) { user.respond_to?(:email) ? user.email : user&.to_s }
|
|
80
100
|
@available_locales = -> { I18n.available_locales.map(&:to_s) }
|
|
@@ -1,161 +1,158 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
17
|
-
}
|
|
18
|
-
a { color: var(--accent); text-decoration: none; }
|
|
19
|
-
a:hover { text-decoration: underline; }
|
|
20
|
-
.container { max-width: 960px; margin: 0 auto; padding: 24px 16px 64px; }
|
|
21
|
-
h1 { font-size: 22px; margin: 0; }
|
|
22
|
-
.lede { color: var(--muted); margin: 4px 0 0; }
|
|
23
|
-
header.page { margin-bottom: 20px; }
|
|
24
|
-
|
|
25
|
-
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 16px; flex-wrap: wrap; }
|
|
26
|
-
.tabs a { padding: 8px 14px; border-radius: 8px 8px 0 0; color: var(--muted); }
|
|
27
|
-
.tabs a.active {
|
|
28
|
-
color: var(--text); font-weight: 600; border: 1px solid var(--border);
|
|
29
|
-
border-bottom: 2px solid var(--surface); background: var(--surface); margin-bottom: -1px;
|
|
30
|
-
}
|
|
31
|
-
.tabs a:hover { text-decoration: none; color: var(--text); }
|
|
32
|
-
.count {
|
|
33
|
-
display: inline-block; min-width: 20px; padding: 0 6px; margin-left: 4px; border-radius: 999px;
|
|
34
|
-
background: var(--border); font-size: 12px; text-align: center; font-variant-numeric: tabular-nums;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
.filters { margin-bottom: 16px; display: flex; gap: 8px; align-items: center; }
|
|
38
|
-
.filters label { color: var(--muted); font-size: 13px; }
|
|
39
|
-
.filters select {
|
|
40
|
-
padding: 6px 8px; border: 1px solid var(--border); border-radius: 8px;
|
|
41
|
-
background: var(--surface); color: var(--text); font: inherit;
|
|
42
|
-
}
|
|
1
|
+
/* Dashboard styles.
|
|
2
|
+
*
|
|
3
|
+
* Three layers, and the split is what lets a host replace the layout:
|
|
4
|
+
*
|
|
5
|
+
* :root custom properties, all `--ip-` prefixed so they can
|
|
6
|
+
* neither overwrite a host's nor be overwritten by them.
|
|
7
|
+
* .ip-page the page frame (was bare html/body). Only the gem's own
|
|
8
|
+
* layout puts this class on <body>, so inside a host admin
|
|
9
|
+
* these rules simply do not apply.
|
|
10
|
+
* .ip-dashboard everything else, nested. Names like .container, .card
|
|
11
|
+
* and .tabs are confined to the dashboard's own markup, so
|
|
12
|
+
* loading this file inside a host cannot restyle its chrome.
|
|
13
|
+
*
|
|
14
|
+
* Nothing here styles a bare `body`, `a`, `table` or `*` at the top level.
|
|
15
|
+
*/
|
|
43
16
|
|
|
44
|
-
.card {
|
|
45
|
-
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
|
46
|
-
padding: 14px 16px; margin-bottom: 12px;
|
|
47
|
-
}
|
|
48
|
-
.card.pad { margin: 0; }
|
|
49
|
-
.row-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
|
|
50
|
-
code.key {
|
|
51
|
-
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
|
|
52
|
-
background: var(--code); padding: 2px 7px; border-radius: 6px; overflow-wrap: anywhere;
|
|
53
|
-
}
|
|
54
|
-
.badge {
|
|
55
|
-
display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600;
|
|
56
|
-
color: #fff; white-space: nowrap;
|
|
57
|
-
}
|
|
58
|
-
.badge.status-pending { background: var(--pending); }
|
|
59
|
-
.badge.status-applied { background: var(--applied); }
|
|
60
|
-
.badge.status-rejected { background: var(--rejected); }
|
|
61
|
-
.badge.locale { background: transparent; color: var(--muted); border: 1px solid var(--border); }
|
|
62
|
-
.spacer { flex: 1; }
|
|
63
|
-
.meta { color: var(--muted); font-size: 13px; }
|
|
64
17
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
18
|
+
:root {
|
|
19
|
+
color-scheme: light dark;
|
|
20
|
+
--ip-bg: #f6f7f9; --ip-surface: #fff; --ip-text: #1c2024; --ip-muted: #6b7280;
|
|
21
|
+
--ip-border: #e5e7eb; --ip-accent: #2563eb; --ip-accent-text: #fff; --ip-code: #f0f1f3;
|
|
22
|
+
--ip-pending: #d97706; --ip-applied: #16a34a; --ip-rejected: #6b7280;
|
|
23
|
+
}
|
|
24
|
+
@media (prefers-color-scheme: dark) {
|
|
25
|
+
:root {
|
|
26
|
+
--ip-bg: #111418; --ip-surface: #1a1f26; --ip-text: #e6e8ea; --ip-muted: #9aa2ab;
|
|
27
|
+
--ip-border: #2a313a; --ip-accent: #3b82f6; --ip-code: #232a33;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
@media (max-width: 760px) {
|
|
31
|
+
.ip-page { font-size: 14px; }
|
|
32
|
+
.ip-index, .ip-show { overflow-x: hidden; }
|
|
33
|
+
.ip-index { overflow-y: auto; overflow-x: hidden; }
|
|
34
|
+
.ip-index, .ip-index .container { min-height: 100vh; height: auto; }
|
|
35
|
+
.ip-index .container { padding-bottom: 10px; }
|
|
36
|
+
.ip-dashboard {
|
|
37
|
+
& .container { padding: 14px 10px 24px; }
|
|
38
|
+
& .review-detail,
|
|
39
|
+
& .suggestion-panel,
|
|
40
|
+
& .detail-scroll,
|
|
41
|
+
& .card,
|
|
42
|
+
& dl,
|
|
43
|
+
& dd { min-width: 0; max-width: 100%; }
|
|
44
|
+
& .suggestion-panel .card { width: calc(100vw - 20px); }
|
|
45
|
+
& .lede,
|
|
46
|
+
& dd,
|
|
47
|
+
& .comment { max-width: calc(100vw - 54px); white-space: normal; overflow-wrap: anywhere; }
|
|
48
|
+
& .review-shell { display: block; min-height: calc(100vh - 76px); }
|
|
49
|
+
& .review-sidebar,
|
|
50
|
+
& .review-detail { min-height: calc(100vh - 76px); }
|
|
51
|
+
& .review-shell.has-selected .review-sidebar { display: none; }
|
|
52
|
+
& .review-shell:not(.has-selected) .review-detail { display: none; }
|
|
53
|
+
& .suggestion-list { overflow: visible; }
|
|
54
|
+
& .suggestion-row { padding: 12px; }
|
|
55
|
+
& .suggestion-meta { display: none; }
|
|
56
|
+
& .review-detail .suggestion-panel { min-height: calc(100vh - 64px); }
|
|
57
|
+
& .mobile-back { display: block; flex: 0 0 auto; margin: 10px 10px 0; font-size: 13px; }
|
|
58
|
+
& .panel-head h1 { font-size: 18px; }
|
|
59
|
+
& .panel-head .key { min-width: 0; white-space: normal; overflow-wrap: anywhere; }
|
|
60
|
+
& dl.diff,
|
|
61
|
+
& .meta-list { grid-template-columns: 1fr; gap: 3px; }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
91
64
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
gap: 16px; }
|
|
99
|
-
.review-sidebar { min-width: 0; min-height: 0; display: flex; flex-direction: column;
|
|
100
|
-
background: var(--surface); border: 1px solid var(--border);
|
|
101
|
-
border-radius: 8px; overflow: hidden; }
|
|
102
|
-
.review-sidebar .tabs { flex: 0 0 auto; margin: 12px 12px 0; padding: 0; background: transparent;
|
|
103
|
-
border: 0; border-radius: 0; gap: 6px; flex-wrap: nowrap; }
|
|
104
|
-
.review-sidebar .tabs a { flex: 1 1 0; position: relative; display: flex; align-items: center;
|
|
105
|
-
justify-content: center; gap: 6px; min-height: 34px; padding: 5px 10px 8px;
|
|
106
|
-
border-radius: 8px; color: var(--muted); font-weight: 600; }
|
|
107
|
-
.review-sidebar .tabs a.active { color: var(--text); border: 0; background: transparent; box-shadow: none; margin: 0; }
|
|
108
|
-
.review-sidebar .tabs a.active::after { content: ""; position: absolute; left: 20px; right: 20px; bottom: 0;
|
|
109
|
-
height: 2px; border-radius: 999px; background: var(--accent); }
|
|
110
|
-
.review-sidebar .tabs a:not(.active):hover { text-decoration: none; color: var(--text); background: var(--bg); }
|
|
111
|
-
.review-sidebar .tabs .count { margin-left: 0; background: color-mix(in srgb, var(--muted) 14%, transparent); }
|
|
112
|
-
.review-sidebar .tabs a.active .count { background: var(--border); }
|
|
113
|
-
.review-sidebar .filters { flex: 0 0 auto; margin: 0; padding: 12px; border-bottom: 1px solid var(--border); }
|
|
114
|
-
.suggestion-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
|
115
|
-
.suggestion-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 13px 14px;
|
|
116
|
-
color: var(--text); border-bottom: 1px solid var(--border); }
|
|
117
|
-
.suggestion-row > .badge { align-self: start; }
|
|
118
|
-
.suggestion-row:hover { text-decoration: none; background: var(--bg); }
|
|
119
|
-
.suggestion-row.active { background: color-mix(in srgb, var(--accent) 9%, var(--surface));
|
|
120
|
-
box-shadow: inset 3px 0 0 var(--accent); }
|
|
121
|
-
.suggestion-main { min-width: 0; display: flex; flex-direction: column; gap: 5px; }
|
|
122
|
-
.suggestion-topline, .suggestion-meta { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
123
|
-
.suggestion-topline .key { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
124
|
-
.suggestion-copy, .suggestion-meta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
125
|
-
.suggestion-copy { display: block; font-weight: 600; }
|
|
126
|
-
.suggestion-time { margin-left: auto; white-space: nowrap; }
|
|
127
|
-
.review-sidebar .pager { flex: 0 0 auto; margin: 0; padding: 10px 12px; border-top: 1px solid var(--border); }
|
|
128
|
-
.review-detail { min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
|
129
|
-
.review-detail .suggestion-panel { flex: 1 1 auto; min-height: 0; }
|
|
130
|
-
.review-detail .detail-scroll { flex: 1 1 auto; overflow-y: auto; padding-right: 2px; }
|
|
131
|
-
.empty-state { margin: auto; max-width: 320px; padding: 24px; text-align: center; }
|
|
132
|
-
.empty-state h2 { margin: 0 0 6px; font-size: 18px; }
|
|
133
|
-
.empty-state p { margin: 0; }
|
|
134
|
-
.mobile-back { display: none; }
|
|
65
|
+
/* Page frame — the gem's own layout only. */
|
|
66
|
+
.ip-page { margin: 0; background: var(--ip-bg); color: var(--ip-text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
67
|
+
.ip-index { overflow: hidden; }
|
|
68
|
+
.ip-index .container { max-width: 1280px; display: flex; flex-direction: column; padding-bottom: 16px; }
|
|
69
|
+
.ip-index header.page { flex: 0 0 auto; margin-bottom: 14px; }
|
|
70
|
+
.ip-show .detail-scroll { overflow: visible; }
|
|
135
71
|
|
|
136
|
-
|
|
72
|
+
/* Dashboard components — any layout. */
|
|
73
|
+
.ip-dashboard {
|
|
74
|
+
& * { box-sizing: border-box; }
|
|
75
|
+
& a { color: var(--ip-accent); text-decoration: none; }
|
|
76
|
+
& a:hover { text-decoration: underline; }
|
|
77
|
+
& .container { max-width: 960px; margin: 0 auto; padding: 24px 16px 64px; }
|
|
78
|
+
& h1 { font-size: 22px; margin: 0; }
|
|
79
|
+
& .lede { color: var(--ip-muted); margin: 4px 0 0; }
|
|
80
|
+
& header.page { margin-bottom: 20px; }
|
|
81
|
+
& .tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--ip-border); margin-bottom: 16px; flex-wrap: wrap; }
|
|
82
|
+
& .tabs a { padding: 8px 14px; border-radius: 8px 8px 0 0; color: var(--ip-muted); }
|
|
83
|
+
& .tabs a.active { color: var(--ip-text); font-weight: 600; border: 1px solid var(--ip-border); border-bottom: 2px solid var(--ip-surface); background: var(--ip-surface); margin-bottom: -1px; }
|
|
84
|
+
& .tabs a:hover { text-decoration: none; color: var(--ip-text); }
|
|
85
|
+
& .count { display: inline-block; min-width: 20px; padding: 0 6px; margin-left: 4px; border-radius: 999px; background: var(--ip-border); font-size: 12px; text-align: center; font-variant-numeric: tabular-nums; }
|
|
86
|
+
& .filters { margin-bottom: 16px; display: flex; gap: 8px; align-items: center; }
|
|
87
|
+
& .filters label { color: var(--ip-muted); font-size: 13px; }
|
|
88
|
+
& .filters select { padding: 6px 8px; border: 1px solid var(--ip-border); border-radius: 8px; background: var(--ip-surface); color: var(--ip-text); font: inherit; }
|
|
89
|
+
& .card { background: var(--ip-surface); border: 1px solid var(--ip-border); border-radius: 12px; padding: 14px 16px; margin-bottom: 12px; }
|
|
90
|
+
& .card.pad { margin: 0; }
|
|
91
|
+
& .row-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
|
|
92
|
+
& code.key { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; background: var(--ip-code); padding: 2px 7px; border-radius: 6px; overflow-wrap: anywhere; }
|
|
93
|
+
& .badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; color: #fff; white-space: nowrap; }
|
|
94
|
+
& .badge.status-pending { background: var(--ip-pending); }
|
|
95
|
+
& .badge.status-applied { background: var(--ip-applied); }
|
|
96
|
+
& .badge.status-rejected { background: var(--ip-rejected); }
|
|
97
|
+
& .badge.locale { background: transparent; color: var(--ip-muted); border: 1px solid var(--ip-border); }
|
|
98
|
+
& .spacer { flex: 1; }
|
|
99
|
+
& .meta { color: var(--ip-muted); font-size: 13px; }
|
|
100
|
+
& dl.diff { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; }
|
|
101
|
+
& dl.diff dt { color: var(--ip-muted); font-size: 13px; }
|
|
102
|
+
& dl.diff dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
|
|
103
|
+
& dd .old { color: var(--ip-muted); }
|
|
104
|
+
& dd .new { font-weight: 600; }
|
|
105
|
+
& .comment { margin-top: 10px; padding: 8px 10px; background: var(--ip-code); border-radius: 8px; font-size: 14px; overflow-wrap: anywhere; }
|
|
106
|
+
& button { padding: 7px 13px; border: 1px solid var(--ip-border); border-radius: 8px; cursor: pointer; background: var(--ip-surface); color: var(--ip-text); font: inherit; font-size: 14px; font-weight: 600; }
|
|
107
|
+
& button:hover { border-color: var(--ip-muted); }
|
|
108
|
+
& :focus-visible { outline: 2px solid var(--ip-accent); outline-offset: 2px; }
|
|
109
|
+
& .empty { padding: 48px 16px; text-align: center; color: var(--ip-muted); border: 1px dashed var(--ip-border); border-radius: 12px; }
|
|
110
|
+
& .pager { margin-top: 16px; display: flex; justify-content: space-between; }
|
|
111
|
+
& .pager .muted { color: var(--ip-muted); }
|
|
112
|
+
& .breadcrumb { margin: 0 0 12px; font-size: 13px; }
|
|
113
|
+
& .case-id { color: var(--ip-muted); font-weight: 400; font-size: 15px; }
|
|
114
|
+
& .panel-head { flex: 0 0 auto; }
|
|
115
|
+
& .panel-head h1 { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
|
|
116
|
+
& .detail-scroll { min-height: 0; display: flex; flex-direction: column; gap: 12px; }
|
|
117
|
+
& .suggestion-panel { min-width: 0; display: flex; flex-direction: column; }
|
|
118
|
+
& .meta-list { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; }
|
|
119
|
+
& .meta-list dt { color: var(--ip-muted); font-size: 13px; }
|
|
120
|
+
& .meta-list dd { margin: 0; overflow-wrap: anywhere; }
|
|
121
|
+
& /* Desktop review: queue on the left,
|
|
122
|
+
& selected suggestion on the right. */
|
|
123
|
+
.ip-index,
|
|
124
|
+
& .ip-index .container { height: 100vh; height: 100dvh; }
|
|
125
|
+
& .review-shell { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(330px, 430px) 1fr; gap: 16px; }
|
|
126
|
+
& .review-sidebar { min-width: 0; min-height: 0; display: flex; flex-direction: column; background: var(--ip-surface); border: 1px solid var(--ip-border); border-radius: 8px; overflow: hidden; }
|
|
127
|
+
& .review-sidebar .tabs { flex: 0 0 auto; margin: 12px 12px 0; padding: 0; background: transparent; border: 0; border-radius: 0; gap: 6px; flex-wrap: nowrap; }
|
|
128
|
+
& .review-sidebar .tabs a { flex: 1 1 0; position: relative; display: flex; align-items: center; justify-content: center; gap: 6px; min-height: 34px; padding: 5px 10px 8px; border-radius: 8px; color: var(--ip-muted); font-weight: 600; }
|
|
129
|
+
& .review-sidebar .tabs a.active { color: var(--ip-text); border: 0; background: transparent; box-shadow: none; margin: 0; }
|
|
130
|
+
& .review-sidebar .tabs a.active::after { content: ""; position: absolute; left: 20px; right: 20px; bottom: 0; height: 2px; border-radius: 999px; background: var(--ip-accent); }
|
|
131
|
+
& .review-sidebar .tabs a:not(.active):hover { text-decoration: none; color: var(--ip-text); background: var(--ip-bg); }
|
|
132
|
+
& .review-sidebar .tabs .count { margin-left: 0; background: color-mix(in srgb, var(--ip-muted) 14%, transparent); }
|
|
133
|
+
& .review-sidebar .tabs a.active .count { background: var(--ip-border); }
|
|
134
|
+
& .review-sidebar .filters { flex: 0 0 auto; margin: 0; padding: 12px; border-bottom: 1px solid var(--ip-border); }
|
|
135
|
+
& .suggestion-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
|
136
|
+
& .suggestion-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 13px 14px; color: var(--ip-text); border-bottom: 1px solid var(--ip-border); }
|
|
137
|
+
& .suggestion-row > .badge { align-self: start; }
|
|
138
|
+
& .suggestion-row:hover { text-decoration: none; background: var(--ip-bg); }
|
|
139
|
+
& .suggestion-row.active { background: color-mix(in srgb, var(--ip-accent) 9%, var(--ip-surface)); box-shadow: inset 3px 0 0 var(--ip-accent); }
|
|
140
|
+
& .suggestion-main { min-width: 0; display: flex; flex-direction: column; gap: 5px; }
|
|
141
|
+
& .suggestion-topline,
|
|
142
|
+
& .suggestion-meta { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
143
|
+
& .suggestion-topline .key { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
144
|
+
& .suggestion-copy,
|
|
145
|
+
& .suggestion-meta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
146
|
+
& .suggestion-copy { display: block; font-weight: 600; }
|
|
147
|
+
& .suggestion-time { margin-left: auto; white-space: nowrap; }
|
|
148
|
+
& .review-sidebar .pager { flex: 0 0 auto; margin: 0; padding: 10px 12px; border-top: 1px solid var(--ip-border); }
|
|
149
|
+
& .review-detail { min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
|
150
|
+
& .review-detail .suggestion-panel { flex: 1 1 auto; min-height: 0; }
|
|
151
|
+
& .review-detail .detail-scroll { flex: 1 1 auto; overflow-y: auto; padding-right: 2px; }
|
|
152
|
+
& .empty-state { margin: auto; max-width: 320px; padding: 24px; text-align: center; }
|
|
153
|
+
& .empty-state h2 { margin: 0 0 6px; font-size: 18px; }
|
|
154
|
+
& .empty-state p { margin: 0; }
|
|
155
|
+
& .mobile-back { display: none; }
|
|
156
|
+
& /* Standalone show pages keep normal page scrolling. */
|
|
137
157
|
.ip-show { min-height: 100vh; overflow: auto; }
|
|
138
|
-
|
|
139
|
-
@media (max-width: 760px) {
|
|
140
|
-
body { font-size: 14px; }
|
|
141
|
-
.container { padding: 14px 10px 24px; }
|
|
142
|
-
.ip-index, .ip-show { overflow-x: hidden; }
|
|
143
|
-
.review-detail, .suggestion-panel, .detail-scroll, .card, dl, dd { min-width: 0; max-width: 100%; }
|
|
144
|
-
.suggestion-panel .card { width: calc(100vw - 20px); }
|
|
145
|
-
.lede, dd, .comment { max-width: calc(100vw - 54px); white-space: normal; overflow-wrap: anywhere; }
|
|
146
|
-
.ip-index { overflow-y: auto; overflow-x: hidden; }
|
|
147
|
-
.ip-index, .ip-index .container { min-height: 100vh; height: auto; }
|
|
148
|
-
.ip-index .container { padding-bottom: 10px; }
|
|
149
|
-
.review-shell { display: block; min-height: calc(100vh - 76px); }
|
|
150
|
-
.review-sidebar, .review-detail { min-height: calc(100vh - 76px); }
|
|
151
|
-
.review-shell.has-selected .review-sidebar { display: none; }
|
|
152
|
-
.review-shell:not(.has-selected) .review-detail { display: none; }
|
|
153
|
-
.suggestion-list { overflow: visible; }
|
|
154
|
-
.suggestion-row { padding: 12px; }
|
|
155
|
-
.suggestion-meta { display: none; }
|
|
156
|
-
.review-detail .suggestion-panel { min-height: calc(100vh - 64px); }
|
|
157
|
-
.mobile-back { display: block; flex: 0 0 auto; margin: 10px 10px 0; font-size: 13px; }
|
|
158
|
-
.panel-head h1 { font-size: 18px; }
|
|
159
|
-
.panel-head .key { min-width: 0; white-space: normal; overflow-wrap: anywhere; }
|
|
160
|
-
dl.diff, .meta-list { grid-template-columns: 1fr; gap: 3px; }
|
|
161
|
-
}
|
|
158
|
+
}
|
data/lib/i18n_proofreading.rb
CHANGED
|
@@ -31,6 +31,14 @@ module I18nProofreading
|
|
|
31
31
|
# May this request browse and triage the dashboard? Independent of
|
|
32
32
|
# `available?` — the dashboard has its own gate so maintainers can review
|
|
33
33
|
# suggestions from production even where the widget itself is off.
|
|
34
|
+
# The class I18nProofreading::DashboardController inherits from. Resolved on
|
|
35
|
+
# every call rather than memoized, so a host that reassigns
|
|
36
|
+
# base_controller_class in a reloadable initializer is not pinned to a stale,
|
|
37
|
+
# unloaded constant.
|
|
38
|
+
def base_controller
|
|
39
|
+
config.base_controller_class.to_s.constantize
|
|
40
|
+
end
|
|
41
|
+
|
|
34
42
|
def admin?(request)
|
|
35
43
|
!!config.authorize_admin.call(request)
|
|
36
44
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: i18n_proofreading
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yaroslav Shmarov
|
|
@@ -39,12 +39,16 @@ files:
|
|
|
39
39
|
- MIT-LICENSE
|
|
40
40
|
- README.md
|
|
41
41
|
- Rakefile
|
|
42
|
+
- app/controllers/concerns/i18n_proofreading/request_context.rb
|
|
42
43
|
- app/controllers/i18n_proofreading/application_controller.rb
|
|
44
|
+
- app/controllers/i18n_proofreading/dashboard_controller.rb
|
|
45
|
+
- app/controllers/i18n_proofreading/submissions_controller.rb
|
|
43
46
|
- app/controllers/i18n_proofreading/suggestions_controller.rb
|
|
44
47
|
- app/controllers/i18n_proofreading/widgets_controller.rb
|
|
45
48
|
- app/helpers/i18n_proofreading/tag_helper.rb
|
|
46
49
|
- app/models/i18n_proofreading/application_record.rb
|
|
47
50
|
- app/models/i18n_proofreading/suggestion.rb
|
|
51
|
+
- app/views/i18n_proofreading/shared/_dashboard.html.erb
|
|
48
52
|
- app/views/i18n_proofreading/suggestions/_suggestion_panel.html.erb
|
|
49
53
|
- app/views/i18n_proofreading/suggestions/index.html.erb
|
|
50
54
|
- app/views/i18n_proofreading/suggestions/show.html.erb
|
|
@@ -79,6 +83,7 @@ files:
|
|
|
79
83
|
- lib/generators/i18n_proofreading/install/install_generator.rb
|
|
80
84
|
- lib/generators/i18n_proofreading/install/templates/create_i18n_proofreading_suggestions.rb.tt
|
|
81
85
|
- lib/generators/i18n_proofreading/install/templates/initializer.rb
|
|
86
|
+
- lib/generators/i18n_proofreading/migration_helpers.rb
|
|
82
87
|
- lib/i18n_proofreading.rb
|
|
83
88
|
- lib/i18n_proofreading/configuration.rb
|
|
84
89
|
- lib/i18n_proofreading/dashboard.css
|