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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ca180baf5c569ccbce1681b0a26d914f7846225fef225ce0f3f5bb3a52b5e1f6
4
- data.tar.gz: bbd44a938dcc053bc63d466cdca8b9ef65ec4360b8cabac2ac7e92b25304991f
3
+ metadata.gz: 3b4d3167c04697942a2b695b1f4b81554a4f12daabc6a8d730b0d4374bb31292
4
+ data.tar.gz: 95f184235ea7319ebdd254592924c25f8503ac0fee7943282cd939478d037a54
5
5
  SHA512:
6
- metadata.gz: 7ff93f334e19313d03ee861c7da265fb8bb98047ca02e189a5d48e0660cc6012578da93508aa91b75dba64a8e4e70405a88a50ac935b93955771358fb3794399
7
- data.tar.gz: 68f52f53cc1f2067a5f75920d282412de882463c394470276ac7495ba439d7a1a04f1fe0270b130b5bfbb706c6fc5893024e4829247bbd3624cf3de58641f714
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
- protect_from_forgery with: :exception
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
- # Gate for the triage dashboard. Independent of #require_available (see
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 < ApplicationController
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
- <header class="page">
4
- <h1><%= t('i18n_proofreading.dashboard.title', default: 'I18nProofreading') %></h1>
5
- <p class="lede"><%= t('i18n_proofreading.dashboard.subtitle', default: 'Review what reviewers proposed, then apply it to your locale files.') %></p>
6
- </header>
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
- <aside class="review-sidebar" aria-label="<%= t('i18n_proofreading.dashboard.suggestions', default: 'Suggestions') %>">
10
- <nav class="tabs">
11
- <% I18nProofreading::Suggestion::STATUSES.each do |status| %>
12
- <%= link_to suggestions_path(status: status, locale: @locale),
13
- class: ('active' if status == @status) do %>
14
- <%= t("i18n_proofreading.statuses.#{status}", default: status.humanize) %>
15
- <span class="count"><%= @counts.fetch(status, 0) %></span>
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
- <% end %>
18
- </nav>
19
+ </nav>
19
20
 
20
- <% if @locales.many? %>
21
- <form class="filters" method="get">
22
- <input type="hidden" name="status" value="<%= @status %>">
23
- <label for="i18np-locale"><%= t('i18n_proofreading.dashboard.locale', default: 'Locale') %></label>
24
- <select id="i18np-locale" name="locale" onchange="this.form.submit()">
25
- <option value=""><%= t('i18n_proofreading.dashboard.all_locales', default: 'All locales') %></option>
26
- <% @locales.each do |locale| %>
27
- <option value="<%= locale %>" <%= 'selected' if locale == @locale %>><%= locale %></option>
28
- <% end %>
29
- </select>
30
- <%# Inline onchange auto-submits where allowed; this button is the reliable
31
- path where a strict CSP blocks inline handlers, or with JS off. %>
32
- <button><%= t('i18n_proofreading.dashboard.filter', default: 'Filter') %></button>
33
- </form>
34
- <% end %>
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
- <div class="suggestion-list">
37
- <% if @suggestions.any? %>
38
- <% @suggestions.each do |suggestion| %>
39
- <%= link_to suggestions_path(status: @status, locale: @locale, page: @page,
40
- suggestion_id: suggestion.id),
41
- class: ['suggestion-row', ('active' if @selected_suggestion&.id == suggestion.id)].compact do %>
42
- <span class="suggestion-main">
43
- <span class="suggestion-topline">
44
- <code class="key"><%= suggestion.translation_key %></code>
45
- <span class="badge locale"><%= suggestion.locale %></span>
46
- <span class="muted suggestion-time" title="<%= suggestion.created_at %>">
47
- <%= time_ago_in_words(suggestion.created_at) %> <%= t('i18n_proofreading.dashboard.ago', default: 'ago') %>
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="suggestion-copy" dir="auto"><%= truncate(suggestion.proposed_value, length: 110) %></span>
51
- <span class="muted suggestion-meta">
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
- </span>
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
- <% else %>
64
- <div class="empty"><%= t('i18n_proofreading.dashboard.empty', default: 'Nothing here yet.') %></div>
65
- <% end %>
66
- </div>
67
+ </div>
67
68
 
68
- <nav class="pager">
69
- <span>
70
- <% if @page > 1 %>
71
- <%= link_to t('i18n_proofreading.dashboard.newer', default: '← Newer'),
72
- suggestions_path(status: @status, locale: @locale, page: @page - 1) %>
73
- <% end %>
74
- </span>
75
- <span>
76
- <% if @more %>
77
- <%= link_to t('i18n_proofreading.dashboard.older', default: 'Older →'),
78
- suggestions_path(status: @status, locale: @locale, page: @page + 1) %>
79
- <% end %>
80
- </span>
81
- </nav>
82
- </aside>
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
- <main class="review-detail" aria-label="<%= t('i18n_proofreading.dashboard.current_suggestion', default: 'Current suggestion') %>">
85
- <% if @selected_suggestion %>
86
- <p class="mobile-back">
87
- <%= link_to t('i18n_proofreading.dashboard.back', default: '← All suggestions'),
88
- suggestions_path(status: @status, locale: @locale, page: @page) %>
89
- </p>
90
- <%= render 'suggestion_panel', suggestion: @selected_suggestion %>
91
- <% else %>
92
- <div class="empty-state">
93
- <h2><%= t('i18n_proofreading.dashboard.select_suggestion', default: 'Select a suggestion') %></h2>
94
- <p class="muted"><%= t('i18n_proofreading.dashboard.select_suggestion_hint',
95
- default: 'Open a row to review the proposed wording beside the queue.') %></p>
96
- </div>
97
- <% end %>
98
- </main>
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
- <p class="breadcrumb">
4
- <%= link_to t('i18n_proofreading.dashboard.title', default: 'I18nProofreading'), suggestions_path %>
5
- / #<%= @suggestion.id %>
6
- </p>
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
- <%= stylesheet_link_tag "#{dashboard_stylesheet_path}?v=#{I18nProofreading::Widget.dashboard_stylesheet_fingerprint}",
10
- 'data-turbo-track': 'reload' %>
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="container">
14
- <%= yield %>
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
- resources :suggestions, only: %i[index show create] do
14
- get :context, on: :collection
15
- end
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
@@ -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 = 'i18n_proofreading/application'
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
- :root {
2
- color-scheme: light dark;
3
- --bg: #f6f7f9; --surface: #fff; --text: #1c2024; --muted: #6b7280;
4
- --border: #e5e7eb; --accent: #2563eb; --accent-text: #fff; --code: #f0f1f3;
5
- --pending: #d97706; --applied: #16a34a; --rejected: #6b7280;
6
- }
7
- @media (prefers-color-scheme: dark) {
8
- :root {
9
- --bg: #111418; --surface: #1a1f26; --text: #e6e8ea; --muted: #9aa2ab;
10
- --border: #2a313a; --accent: #3b82f6; --code: #232a33;
11
- }
12
- }
13
- * { box-sizing: border-box; }
14
- body {
15
- margin: 0; background: var(--bg); color: var(--text);
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
- dl.diff { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; }
66
- dl.diff dt { color: var(--muted); font-size: 13px; }
67
- dl.diff dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
68
- dd .old { color: var(--muted); }
69
- dd .new { font-weight: 600; }
70
- .comment { margin-top: 10px; padding: 8px 10px; background: var(--code); border-radius: 8px; font-size: 14px; overflow-wrap: anywhere; }
71
-
72
- button {
73
- padding: 7px 13px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer;
74
- background: var(--surface); color: var(--text); font: inherit; font-size: 14px; font-weight: 600;
75
- }
76
- button:hover { border-color: var(--muted); }
77
- :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
78
-
79
- .empty { padding: 48px 16px; text-align: center; color: var(--muted); border: 1px dashed var(--border); border-radius: 12px; }
80
- .pager { margin-top: 16px; display: flex; justify-content: space-between; }
81
- .pager .muted { color: var(--muted); }
82
- .breadcrumb { margin: 0 0 12px; font-size: 13px; }
83
- .case-id { color: var(--muted); font-weight: 400; font-size: 15px; }
84
- .panel-head { flex: 0 0 auto; }
85
- .panel-head h1 { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
86
- .detail-scroll { min-height: 0; display: flex; flex-direction: column; gap: 12px; }
87
- .suggestion-panel { min-width: 0; display: flex; flex-direction: column; }
88
- .meta-list { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; }
89
- .meta-list dt { color: var(--muted); font-size: 13px; }
90
- .meta-list dd { margin: 0; overflow-wrap: anywhere; }
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
- /* Desktop review: queue on the left, selected suggestion on the right. */
93
- .ip-index, .ip-index .container { height: 100vh; height: 100dvh; }
94
- .ip-index { overflow: hidden; }
95
- .ip-index .container { max-width: 1280px; display: flex; flex-direction: column; padding-bottom: 16px; }
96
- .ip-index header.page { flex: 0 0 auto; margin-bottom: 14px; }
97
- .review-shell { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(330px, 430px) 1fr;
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
- /* Standalone show pages keep normal page scrolling. */
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
- .ip-show .detail-scroll { overflow: visible; }
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
+ }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module I18nProofreading
4
- VERSION = '0.10.6'
4
+ VERSION = '0.11.0'
5
5
  end
@@ -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.10.6
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