i18n_feedback 0.7.1 → 0.8.1

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: 2d40ced592fbef71b06b90a276fdf459549df06666ee3dcfe2b2d91cf3fd3e0a
4
- data.tar.gz: 19ad82f30538a1611c260d132493423d36d6bba763c67e94392b3ff281612772
3
+ metadata.gz: 7961f050a8f18c3136f8bac4be58014c6d137b408539d9bbb589e3756c024674
4
+ data.tar.gz: eb772bb6ca0dbd76d2638f6d5de2fb455a3c1ac737b640856fff79677f50f873
5
5
  SHA512:
6
- metadata.gz: 9800f7f1019390b0452a2605d6d66be2099346cadc2e5b89674353934214457e99b3e67b2c53cb2e17ca124b824a65bbd48d470d7ba9da5047a27f3b7f5f1e08
7
- data.tar.gz: 14e4e70d37756b56d425313d16c1b582da719257002d4a65384a7021b6d7a6670c965822c59cf0994b1fbfe5f3fdbd9d40578fdc952f6623ff3f37c65f644b9a
6
+ metadata.gz: a586da5d40cda89a58312e02bdeaaa27af64bc9c7013fca26cbd94bf7dd01da8109dfb2ce7417bf07fae3525837740d85441a6e47e3c65cebcb85dfe4125c53f
7
+ data.tar.gz: 1978b54ebb78dadfbc90d09e97d4a8e656c2f77d34d134301e0844efbcf3848c13d95f8b2ea5311ef8f872f21439852c6c67564c981b3b42d08f2eee085376bc
data/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.8.1]
6
+
7
+ - Make the dashboard **read-only**. Apply / Reject / Reopen / Delete only ever
8
+ flipped the stored `status` — they never touched the host's locale files, so
9
+ offering them implied a capability the gem doesn't have. The buttons and their
10
+ `update` / `destroy` routes and actions are removed; the dashboard now just
11
+ lists suggestions for review, and you make the edits in your own locale files.
12
+ A suggestion's `status` still exists on the model for out-of-band tracking and
13
+ as groundwork for a future "apply to locale file" feature.
14
+
15
+ ## [0.8.0]
16
+
17
+ - Add a built-in **triage dashboard**, mounted at the engine root (your
18
+ `mount_path`, default `/i18n_feedback`): pending / applied / rejected tabs
19
+ with counts, a per-locale filter, each suggestion shown current-vs-proposed,
20
+ and one-click Apply / Reject / Reopen / Delete. Plain server-rendered HTML
21
+ with its own self-contained styling (light + dark via `prefers-color-scheme`)
22
+ — no host assets or JS framework required.
23
+ - Every dashboard string renders through Rails I18n under `i18n_feedback.dashboard.*`
24
+ and `i18n_feedback.statuses.*`, with English fallbacks, so a host can translate
25
+ or reword any of it from its own locale files.
26
+ - Add `config.authorize_admin` — the dashboard's gate, **defaulting to
27
+ development only** so a fresh install never exposes it in production. It is
28
+ independent of `enabled` / `enabled_environments`, so a maintainer can triage
29
+ from production even where the widget is off.
30
+ - The widget's "already suggested" context now loads from
31
+ `GET {mount_path}/suggestions/context` (was the collection index, now the
32
+ dashboard). Internal to the gem; the bundled widget was updated in lockstep.
33
+
5
34
  ## [0.7.1]
6
35
 
7
36
  - The `i18n_feedback.stop` toggle label now reads "Stop suggesting (Esc)" in
data/README.md CHANGED
@@ -97,6 +97,10 @@ I18nFeedback.configure do |config|
97
97
  # Extra per-request gate. Return false to hide the tool. Receives the request.
98
98
  config.enabled = ->(request) { true }
99
99
 
100
+ # Who may open the triage dashboard. Independent of the gates above; defaults
101
+ # to development only. Wire it to your own admin check to open it elsewhere.
102
+ config.authorize_admin = ->(request) { Rails.env.development? }
103
+
100
104
  # Attribute a suggestion to a user (optional). Return an object responding to
101
105
  # #id, or nil. Receives the request.
102
106
  config.current_user = ->(request) { nil }
@@ -233,7 +237,34 @@ blue accent stays the same in both.
233
237
 
234
238
  ## Reviewing suggestions
235
239
 
236
- Suggestions are ordinary records:
240
+ ### Review dashboard
241
+
242
+ Mounted at your `mount_path` (default `/i18n_feedback`), the engine root is a
243
+ built-in **read-only** review board: pending / applied / rejected tabs with
244
+ counts, a per-locale filter, and each suggestion shown as current-vs-proposed
245
+ with its comment and author. It's plain server-rendered HTML with its own
246
+ styling — no host assets or JS framework needed.
247
+
248
+ It is deliberately read-only. The gem never writes to your locale files, so it
249
+ doesn't pretend to: you review the suggestions here, then make the edits in your
250
+ own `config/locales/*.yml`. (A suggestion's `status` still exists on the model
251
+ for your own tracking — set it from the console — and is the groundwork for a
252
+ future "apply to locale file" feature.)
253
+
254
+ It has its own gate, `config.authorize_admin`, **defaulting to development
255
+ only** — so a fresh install never exposes it in production. Point it at your own
256
+ admin check to open it elsewhere:
257
+
258
+ ```ruby
259
+ config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
260
+ ```
261
+
262
+ The gate is independent of `enabled` / `enabled_environments`: the widget can be
263
+ dev/staging-only while a maintainer still triages from production.
264
+
265
+ ### From the console
266
+
267
+ Suggestions are also ordinary records:
237
268
 
238
269
  ```ruby
239
270
  I18nFeedback::Suggestion.where(status: "pending").newest_first.each do |s|
@@ -4,16 +4,25 @@ module I18nFeedback
4
4
  class ApplicationController < ActionController::Base
5
5
  protect_from_forgery with: :exception
6
6
 
7
- before_action :require_available
8
-
9
7
  private
10
8
 
11
- # Server-side gate. The client can set the cookie, but it can never reach the
12
- # endpoints unless the app itself says the tool is available for this request.
9
+ # Gate for the widget's public API (submit a suggestion, read prior context).
10
+ # The client can set the cookie, but it can never reach the endpoints unless
11
+ # the app itself says the tool is available for this request.
13
12
  def require_available
14
13
  head :forbidden unless I18nFeedback.available?(request)
15
14
  end
16
15
 
16
+ # Gate for the triage dashboard. Independent of #require_available (see
17
+ # I18nFeedback.admin?). Renders a plain 403 with a hint rather than a bare
18
+ # head, since a human is usually looking at it.
19
+ def require_admin
20
+ return if I18nFeedback.admin?(request)
21
+
22
+ render plain: 'Forbidden. Set I18nFeedback.config.authorize_admin to grant access.',
23
+ status: :forbidden
24
+ end
25
+
17
26
  def current_author
18
27
  return @current_author if defined?(@current_author)
19
28
 
@@ -2,6 +2,14 @@
2
2
 
3
3
  module I18nFeedback
4
4
  class SuggestionsController < ApplicationController
5
+ PER_PAGE = 50
6
+
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: :index
10
+
11
+ layout 'i18n_feedback/application', only: :index
12
+
5
13
  # Throttle the public submission endpoint per IP so one user or bot can't
6
14
  # flood the table. Uses the rate limiter built into Rails 7.2+ (backed by
7
15
  # Rails.cache); a no-op on Rails 7.1. Tune or disable via config.rate_limit —
@@ -15,9 +23,27 @@ module I18nFeedback
15
23
  })
16
24
  end
17
25
 
26
+ # --- triage dashboard (admin) --------------------------------------------
27
+
28
+ def index
29
+ @status = Suggestion::STATUSES.include?(params[:status]) ? params[:status] : 'pending'
30
+ @locale = params[:locale].presence
31
+ @counts = Suggestion.group(:status).count
32
+ @locales = Suggestion.distinct.pluck(:locale).compact.sort
33
+
34
+ scope = Suggestion.where(status: @status)
35
+ scope = scope.where(locale: @locale) if @locale
36
+ @page = [params[:page].to_i, 1].max
37
+ rows = scope.newest_first.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
38
+ @more = rows.size > PER_PAGE
39
+ @suggestions = rows.first(PER_PAGE)
40
+ end
41
+
42
+ # --- widget API (public) -------------------------------------------------
43
+
18
44
  # Pending suggestions for one key/locale, shown as read-only context when the
19
45
  # proofreader reopens the popover for a string someone already flagged.
20
- def index
46
+ def context
21
47
  suggestions = Suggestion
22
48
  .where(translation_key: params[:key], locale: params[:locale])
23
49
  .status_pending
@@ -0,0 +1,75 @@
1
+ <header class="page">
2
+ <h1><%= t('i18n_feedback.dashboard.title', default: 'Translation suggestions') %></h1>
3
+ <p class="lede"><%= t('i18n_feedback.dashboard.subtitle', default: 'Review what reviewers proposed, then apply it to your locale files.') %></p>
4
+ </header>
5
+
6
+ <nav class="tabs">
7
+ <% I18nFeedback::Suggestion::STATUSES.each do |status| %>
8
+ <%= link_to suggestions_path(status: status, locale: @locale),
9
+ class: ('active' if status == @status) do %>
10
+ <%= t("i18n_feedback.statuses.#{status}", default: status.humanize) %>
11
+ <span class="count"><%= @counts.fetch(status, 0) %></span>
12
+ <% end %>
13
+ <% end %>
14
+ </nav>
15
+
16
+ <% if @locales.many? %>
17
+ <form class="filters" method="get">
18
+ <input type="hidden" name="status" value="<%= @status %>">
19
+ <select name="locale" onchange="this.form.requestSubmit()"
20
+ aria-label="<%= t('i18n_feedback.dashboard.filter', default: 'Filter by locale') %>">
21
+ <option value=""><%= t('i18n_feedback.dashboard.all_locales', default: 'All locales') %></option>
22
+ <% @locales.each do |locale| %>
23
+ <option value="<%= locale %>" <%= 'selected' if locale == @locale %>><%= locale %></option>
24
+ <% end %>
25
+ </select>
26
+ <noscript><button><%= t('i18n_feedback.dashboard.filter', default: 'Filter') %></button></noscript>
27
+ </form>
28
+ <% end %>
29
+
30
+ <% if @suggestions.any? %>
31
+ <% @suggestions.each do |suggestion| %>
32
+ <article class="card">
33
+ <div class="row-top">
34
+ <code class="key"><%= suggestion.translation_key %></code>
35
+ <span class="badge locale"><%= suggestion.locale %></span>
36
+ <span class="badge status-<%= suggestion.status %>">
37
+ <%= t("i18n_feedback.statuses.#{suggestion.status}", default: suggestion.status.humanize) %>
38
+ </span>
39
+ <span class="spacer"></span>
40
+ <span class="meta">
41
+ <% if suggestion.author_label.present? %><%= suggestion.author_label %> · <% end %>
42
+ <span title="<%= suggestion.created_at %>"><%= time_ago_in_words(suggestion.created_at) %> <%= t('i18n_feedback.dashboard.ago', default: 'ago') %></span>
43
+ </span>
44
+ </div>
45
+
46
+ <dl class="diff">
47
+ <dt><%= t('i18n_feedback.dashboard.current', default: 'Current') %></dt>
48
+ <dd><span class="old" dir="auto"><%= suggestion.old_value.presence || '—' %></span></dd>
49
+ <dt><%= t('i18n_feedback.dashboard.suggested', default: 'Suggested') %></dt>
50
+ <dd><span class="new" dir="auto"><%= suggestion.proposed_value %></span></dd>
51
+ </dl>
52
+
53
+ <% if suggestion.comment.present? %>
54
+ <div class="comment" dir="auto"><%= suggestion.comment %></div>
55
+ <% end %>
56
+ </article>
57
+ <% end %>
58
+
59
+ <nav class="pager">
60
+ <span>
61
+ <% if @page > 1 %>
62
+ <%= link_to t('i18n_feedback.dashboard.newer', default: '← Newer'),
63
+ suggestions_path(status: @status, locale: @locale, page: @page - 1) %>
64
+ <% end %>
65
+ </span>
66
+ <span>
67
+ <% if @more %>
68
+ <%= link_to t('i18n_feedback.dashboard.older', default: 'Older →'),
69
+ suggestions_path(status: @status, locale: @locale, page: @page + 1) %>
70
+ <% end %>
71
+ </span>
72
+ </nav>
73
+ <% else %>
74
+ <div class="empty"><%= t('i18n_feedback.dashboard.empty', default: 'Nothing here yet.') %></div>
75
+ <% end %>
@@ -0,0 +1,95 @@
1
+ <!DOCTYPE html>
2
+ <html lang="<%= I18n.locale %>">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title><%= t('i18n_feedback.dashboard.title', default: 'Translation suggestions') %></title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7
+ <%= csrf_meta_tags %>
8
+ <style>
9
+ :root {
10
+ color-scheme: light dark;
11
+ --bg: #f6f7f9; --surface: #fff; --text: #1c2024; --muted: #6b7280;
12
+ --border: #e5e7eb; --accent: #2563eb; --accent-text: #fff; --code: #f0f1f3;
13
+ --pending: #d97706; --applied: #16a34a; --rejected: #6b7280;
14
+ }
15
+ @media (prefers-color-scheme: dark) {
16
+ :root {
17
+ --bg: #111418; --surface: #1a1f26; --text: #e6e8ea; --muted: #9aa2ab;
18
+ --border: #2a313a; --accent: #3b82f6; --code: #232a33;
19
+ }
20
+ }
21
+ * { box-sizing: border-box; }
22
+ body {
23
+ margin: 0; background: var(--bg); color: var(--text);
24
+ font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
25
+ }
26
+ a { color: var(--accent); text-decoration: none; }
27
+ a:hover { text-decoration: underline; }
28
+ .container { max-width: 960px; margin: 0 auto; padding: 24px 16px 64px; }
29
+ h1 { font-size: 22px; margin: 0; }
30
+ .lede { color: var(--muted); margin: 4px 0 0; }
31
+ header.page { margin-bottom: 20px; }
32
+
33
+ .tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 16px; flex-wrap: wrap; }
34
+ .tabs a { padding: 8px 14px; border-radius: 8px 8px 0 0; color: var(--muted); }
35
+ .tabs a.active {
36
+ color: var(--text); font-weight: 600; border: 1px solid var(--border);
37
+ border-bottom: 2px solid var(--surface); background: var(--surface); margin-bottom: -1px;
38
+ }
39
+ .tabs a:hover { text-decoration: none; color: var(--text); }
40
+ .count {
41
+ display: inline-block; min-width: 20px; padding: 0 6px; margin-left: 4px; border-radius: 999px;
42
+ background: var(--border); font-size: 12px; text-align: center; font-variant-numeric: tabular-nums;
43
+ }
44
+
45
+ .filters { margin-bottom: 16px; display: flex; gap: 8px; align-items: center; }
46
+ .filters select {
47
+ padding: 6px 8px; border: 1px solid var(--border); border-radius: 8px;
48
+ background: var(--surface); color: var(--text); font: inherit;
49
+ }
50
+
51
+ .card {
52
+ background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
53
+ padding: 14px 16px; margin-bottom: 12px;
54
+ }
55
+ .row-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
56
+ code.key {
57
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
58
+ background: var(--code); padding: 2px 7px; border-radius: 6px; overflow-wrap: anywhere;
59
+ }
60
+ .badge {
61
+ display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600;
62
+ color: #fff; white-space: nowrap;
63
+ }
64
+ .badge.status-pending { background: var(--pending); }
65
+ .badge.status-applied { background: var(--applied); }
66
+ .badge.status-rejected { background: var(--rejected); }
67
+ .badge.locale { background: transparent; color: var(--muted); border: 1px solid var(--border); }
68
+ .spacer { flex: 1; }
69
+ .meta { color: var(--muted); font-size: 13px; }
70
+
71
+ dl.diff { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; }
72
+ dl.diff dt { color: var(--muted); font-size: 13px; }
73
+ dl.diff dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
74
+ dd .old { color: var(--muted); }
75
+ dd .new { font-weight: 600; }
76
+ .comment { margin-top: 10px; padding: 8px 10px; background: var(--code); border-radius: 8px; font-size: 14px; overflow-wrap: anywhere; }
77
+
78
+ button {
79
+ padding: 7px 13px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer;
80
+ background: var(--surface); color: var(--text); font: inherit; font-size: 14px; font-weight: 600;
81
+ }
82
+ button:hover { border-color: var(--muted); }
83
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
84
+
85
+ .empty { padding: 48px 16px; text-align: center; color: var(--muted); border: 1px dashed var(--border); border-radius: 12px; }
86
+ .pager { margin-top: 16px; display: flex; justify-content: space-between; }
87
+ .pager .muted { color: var(--muted); }
88
+ </style>
89
+ </head>
90
+ <body>
91
+ <div class="container">
92
+ <%= yield %>
93
+ </div>
94
+ </body>
95
+ </html>
@@ -14,3 +14,21 @@ en:
14
14
  save: "Send suggestion"
15
15
  error_blank: "Please enter a suggestion."
16
16
  error_save: "Could not save the suggestion."
17
+ # Triage dashboard. English-only by default (an admin-facing surface); every
18
+ # string is rendered through I18n with these as fallbacks, so a host can
19
+ # translate or reword any of them under its own `i18n_feedback.*` scope.
20
+ statuses:
21
+ pending: "Pending"
22
+ applied: "Applied"
23
+ rejected: "Rejected"
24
+ dashboard:
25
+ title: "Translation suggestions"
26
+ subtitle: "Review what reviewers proposed, then apply it to your locale files."
27
+ filter: "Filter by locale"
28
+ all_locales: "All locales"
29
+ current: "Current"
30
+ suggested: "Suggested"
31
+ ago: "ago"
32
+ empty: "Nothing here yet."
33
+ newer: "← Newer"
34
+ older: "Older →"
data/config/routes.rb CHANGED
@@ -1,5 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  I18nFeedback::Engine.routes.draw do
4
- resources :suggestions, only: %i[index create]
4
+ # index is the read-only admin dashboard; create + the `context` collection
5
+ # route are the widget's public API (see SuggestionsController). There is
6
+ # deliberately no update/destroy — the tool never mutates suggestions from the
7
+ # UI, since it can't write to the host's locale files.
8
+ resources :suggestions, only: %i[index create] do
9
+ get :context, on: :collection
10
+ end
11
+
12
+ root to: 'suggestions#index'
5
13
  end
@@ -10,6 +10,13 @@ I18nFeedback.configure do |config|
10
10
  #
11
11
  # config.enabled = ->(request) { true }
12
12
 
13
+ # Who may open the triage dashboard (mounted at your `mount_path`) to review,
14
+ # apply, and delete suggestions. Independent of the checks above — the widget
15
+ # is dev/staging-only, but you may want to triage from production. Defaults to
16
+ # development only; wire it to your own admin check to open it elsewhere.
17
+ #
18
+ # config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
19
+
13
20
  # Attribute a suggestion to a user (optional). Return an object responding to
14
21
  # #id (ideally #email too), or nil. Receives the Rack::Request. You resolve the
15
22
  # user however your app does — session, Warden, a token, etc.
@@ -14,6 +14,13 @@ module I18nFeedback
14
14
  # feature flag, an allowlist, etc.
15
15
  attr_accessor :enabled
16
16
 
17
+ # Per-request gate for the triage dashboard (browse suggestions, change their
18
+ # status, delete). Independent of `enabled` and `enabled_environments`: the
19
+ # widget is dev/staging-only, but a maintainer may want to triage from
20
+ # production. Defaults to development only, so a fresh install never exposes
21
+ # the dashboard until you wire it to your own admin check.
22
+ attr_accessor :authorize_admin
23
+
17
24
  # Resolve the current user for attribution (optional). Return an object
18
25
  # responding to #id, or nil. Receives the Rack::Request.
19
26
  attr_accessor :current_user
@@ -60,6 +67,7 @@ module I18nFeedback
60
67
  def initialize
61
68
  @enabled_environments = %w[development staging]
62
69
  @enabled = ->(_request) { true }
70
+ @authorize_admin = ->(_request) { Rails.env.development? }
63
71
  @current_user = ->(_request) {}
64
72
  @author_label = ->(user) { user.respond_to?(:email) ? user.email : user&.to_s }
65
73
  @available_locales = -> { I18n.available_locales.map(&:to_s) }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module I18nFeedback
4
- VERSION = '0.7.1'
4
+ VERSION = '0.8.1'
5
5
  end
@@ -221,7 +221,7 @@
221
221
 
222
222
  function loadPrior(key) {
223
223
  var params = new URLSearchParams({ key: key, locale: config.locale });
224
- fetch(config.endpoint + "?" + params.toString(), { headers: { Accept: "application/json" } })
224
+ fetch(config.endpoint + "/context?" + params.toString(), { headers: { Accept: "application/json" } })
225
225
  .then(function (response) {
226
226
  return response.ok ? response.json() : [];
227
227
  })
data/lib/i18n_feedback.rb CHANGED
@@ -26,5 +26,12 @@ module I18nFeedback
26
26
  def available?(request)
27
27
  config.environment_enabled? && !!config.enabled.call(request)
28
28
  end
29
+
30
+ # May this request browse and triage the dashboard? Independent of
31
+ # `available?` — the dashboard has its own gate so maintainers can review
32
+ # suggestions from production even where the widget itself is off.
33
+ def admin?(request)
34
+ !!config.authorize_admin.call(request)
35
+ end
29
36
  end
30
37
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: i18n_feedback
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov
@@ -43,6 +43,8 @@ files:
43
43
  - app/helpers/i18n_feedback/tag_helper.rb
44
44
  - app/models/i18n_feedback/application_record.rb
45
45
  - app/models/i18n_feedback/suggestion.rb
46
+ - app/views/i18n_feedback/suggestions/index.html.erb
47
+ - app/views/layouts/i18n_feedback/application.html.erb
46
48
  - config/locales/i18n_feedback.ar.yml
47
49
  - config/locales/i18n_feedback.bg.yml
48
50
  - config/locales/i18n_feedback.bn.yml