i18n_feedback 0.7.0 → 0.8.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/README.md +26 -1
  4. data/app/controllers/i18n_feedback/application_controller.rb +13 -4
  5. data/app/controllers/i18n_feedback/suggestions_controller.rb +45 -1
  6. data/app/views/i18n_feedback/suggestions/index.html.erb +93 -0
  7. data/app/views/layouts/i18n_feedback/application.html.erb +99 -0
  8. data/config/locales/i18n_feedback.ar.yml +1 -1
  9. data/config/locales/i18n_feedback.bg.yml +1 -1
  10. data/config/locales/i18n_feedback.bn.yml +1 -1
  11. data/config/locales/i18n_feedback.de.yml +1 -1
  12. data/config/locales/i18n_feedback.el.yml +1 -1
  13. data/config/locales/i18n_feedback.en.yml +24 -1
  14. data/config/locales/i18n_feedback.es.yml +1 -1
  15. data/config/locales/i18n_feedback.fr.yml +1 -1
  16. data/config/locales/i18n_feedback.hi.yml +1 -1
  17. data/config/locales/i18n_feedback.hr.yml +1 -1
  18. data/config/locales/i18n_feedback.id.yml +1 -1
  19. data/config/locales/i18n_feedback.it.yml +1 -1
  20. data/config/locales/i18n_feedback.ja.yml +1 -1
  21. data/config/locales/i18n_feedback.ko.yml +1 -1
  22. data/config/locales/i18n_feedback.lb.yml +1 -1
  23. data/config/locales/i18n_feedback.nl.yml +1 -1
  24. data/config/locales/i18n_feedback.pl.yml +1 -1
  25. data/config/locales/i18n_feedback.pt.yml +1 -1
  26. data/config/locales/i18n_feedback.ro.yml +1 -1
  27. data/config/locales/i18n_feedback.ru.yml +1 -1
  28. data/config/locales/i18n_feedback.th.yml +1 -1
  29. data/config/locales/i18n_feedback.tr.yml +1 -1
  30. data/config/locales/i18n_feedback.uk.yml +1 -1
  31. data/config/locales/i18n_feedback.ur.yml +1 -1
  32. data/config/locales/i18n_feedback.vi.yml +1 -1
  33. data/config/locales/i18n_feedback.zh-CN.yml +1 -1
  34. data/config/routes.rb +7 -1
  35. data/lib/generators/i18n_feedback/install/templates/initializer.rb +7 -0
  36. data/lib/i18n_feedback/configuration.rb +8 -0
  37. data/lib/i18n_feedback/version.rb +1 -1
  38. data/{app/assets → lib}/i18n_feedback/widget.js +1 -1
  39. data/lib/i18n_feedback/widget.rb +5 -1
  40. data/lib/i18n_feedback.rb +7 -0
  41. metadata +4 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0b9c4bbb6fda616942ad7f4dc48a123af7513f78a010b2a2973e51b915a07dd9
4
- data.tar.gz: d7978beed1d4ac0a5fcf511ba015f2a05838113600c4fa0923b58afac76012b6
3
+ metadata.gz: 675e56ff8164412361ecd15886a56ef5eb7a97fdda9a766246b0d690c602d2e1
4
+ data.tar.gz: 36c511550f1b042de8052e995afeab43c27645410d0dea5246a4ffde9e9d598f
5
5
  SHA512:
6
- metadata.gz: 68b168418366db60e9786a4428ef41b98b6a6fe0c8679e3a7553097973204b2660bb06af7fd71e03dff34327be0bc69d20f0fd1376267718d19851a50339083a
7
- data.tar.gz: 7eeb924a5338c69cedf36070f2d7b74ca79697964f8b6e10e858eade585ed32f45105fe9d95358fdbeb0a614949733c2177853e8a155aac95b92c816dd1d3c77
6
+ metadata.gz: fc37ff253e157cc2c2b619e8007bdbe698a1669a96c19c5243b5e326db8185344ee1fa49bdaf40f355240fb6322d90d84bc2e7944bef84b19c77bc381bdcf859
7
+ data.tar.gz: f9136776bb4abeb8bb1a6271e8f924178486fad4ab3923eed3e9826110daa56501ca8773962ffc8caee9b340c9b294c6747b500c38b989ed6e412ada137d6237
data/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.8.0]
6
+
7
+ - Add a built-in **triage dashboard**, mounted at the engine root (your
8
+ `mount_path`, default `/i18n_feedback`): pending / applied / rejected tabs
9
+ with counts, a per-locale filter, each suggestion shown current-vs-proposed,
10
+ and one-click Apply / Reject / Reopen / Delete. Plain server-rendered HTML
11
+ with its own self-contained styling (light + dark via `prefers-color-scheme`)
12
+ — no host assets or JS framework required.
13
+ - Every dashboard string renders through Rails I18n under `i18n_feedback.dashboard.*`
14
+ and `i18n_feedback.statuses.*`, with English fallbacks, so a host can translate
15
+ or reword any of it from its own locale files.
16
+ - Add `config.authorize_admin` — the dashboard's gate, **defaulting to
17
+ development only** so a fresh install never exposes it in production. It is
18
+ independent of `enabled` / `enabled_environments`, so a maintainer can triage
19
+ from production even where the widget is off.
20
+ - The widget's "already suggested" context now loads from
21
+ `GET {mount_path}/suggestions/context` (was the collection index, now the
22
+ dashboard). Internal to the gem; the bundled widget was updated in lockstep.
23
+
24
+ ## [0.7.1]
25
+
26
+ - The `i18n_feedback.stop` toggle label now reads "Stop suggesting (Esc)" in
27
+ every language — Esc already exits suggest mode, so the control says so.
28
+ - The widget JavaScript moved from `app/assets/` to `lib/` (it was always
29
+ inlined server-side, never served as an asset). Rails auto-registers every
30
+ engine's `app/assets/*` directory with the host's asset pipeline, so
31
+ Propshaft hosts were ingesting the file into their asset namespace under
32
+ the bare logical name `widget.js` — colliding with any other gem or host
33
+ file of the same name — and needlessly digesting a public copy at
34
+ precompile. The gem is now invisible to Sprockets/Propshaft entirely.
35
+ No behavior change.
36
+
5
37
  ## [0.7.0]
6
38
 
7
39
  - Reword the `i18n_feedback.start` toggle label from "Suggest edits" to
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,28 @@ blue accent stays the same in both.
233
237
 
234
238
  ## Reviewing suggestions
235
239
 
236
- Suggestions are ordinary records:
240
+ ### Triage dashboard
241
+
242
+ Mounted at your `mount_path` (default `/i18n_feedback`), the engine root is a
243
+ built-in triage dashboard: pending / applied / rejected tabs with counts, a
244
+ per-locale filter, each suggestion shown as current-vs-proposed, and one-click
245
+ **Apply** / **Reject** / **Reopen** / **Delete**. It's plain server-rendered
246
+ HTML with its own styling — no host assets or JS framework needed.
247
+
248
+ It has its own gate, `config.authorize_admin`, **defaulting to development
249
+ only** — so a fresh install never exposes it in production. Point it at your own
250
+ admin check to open it elsewhere:
251
+
252
+ ```ruby
253
+ config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
254
+ ```
255
+
256
+ The gate is independent of `enabled` / `enabled_environments`: the widget can be
257
+ dev/staging-only while a maintainer still triages from production.
258
+
259
+ ### From the console
260
+
261
+ Suggestions are also ordinary records:
237
262
 
238
263
  ```ruby
239
264
  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,15 @@
2
2
 
3
3
  module I18nFeedback
4
4
  class SuggestionsController < ApplicationController
5
+ PER_PAGE = 50
6
+
7
+ # Public widget API is available-gated; the triage dashboard is admin-gated.
8
+ before_action :require_available, only: %i[context create]
9
+ before_action :require_admin, only: %i[index update destroy]
10
+ before_action :set_suggestion, only: %i[update destroy]
11
+
12
+ layout 'i18n_feedback/application', only: :index
13
+
5
14
  # Throttle the public submission endpoint per IP so one user or bot can't
6
15
  # flood the table. Uses the rate limiter built into Rails 7.2+ (backed by
7
16
  # Rails.cache); a no-op on Rails 7.1. Tune or disable via config.rate_limit —
@@ -15,9 +24,40 @@ module I18nFeedback
15
24
  })
16
25
  end
17
26
 
27
+ # --- triage dashboard (admin) --------------------------------------------
28
+
29
+ def index
30
+ @status = Suggestion::STATUSES.include?(params[:status]) ? params[:status] : 'pending'
31
+ @locale = params[:locale].presence
32
+ @counts = Suggestion.group(:status).count
33
+ @locales = Suggestion.distinct.pluck(:locale).compact.sort
34
+
35
+ scope = Suggestion.where(status: @status)
36
+ scope = scope.where(locale: @locale) if @locale
37
+ @page = [params[:page].to_i, 1].max
38
+ rows = scope.newest_first.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
39
+ @more = rows.size > PER_PAGE
40
+ @suggestions = rows.first(PER_PAGE)
41
+ end
42
+
43
+ def update
44
+ # status arrives from a dashboard button; ignore anything not a real state
45
+ # (an AR enum would otherwise raise on an unknown value).
46
+ status = params[:status].to_s
47
+ @suggestion.update!(status: status) if Suggestion::STATUSES.include?(status)
48
+ redirect_back fallback_location: root_path, status: :see_other
49
+ end
50
+
51
+ def destroy
52
+ @suggestion.destroy!
53
+ redirect_back fallback_location: root_path, status: :see_other
54
+ end
55
+
56
+ # --- widget API (public) -------------------------------------------------
57
+
18
58
  # Pending suggestions for one key/locale, shown as read-only context when the
19
59
  # proofreader reopens the popover for a string someone already flagged.
20
- def index
60
+ def context
21
61
  suggestions = Suggestion
22
62
  .where(translation_key: params[:key], locale: params[:locale])
23
63
  .status_pending
@@ -41,6 +81,10 @@ module I18nFeedback
41
81
 
42
82
  private
43
83
 
84
+ def set_suggestion
85
+ @suggestion = Suggestion.find(params[:id])
86
+ end
87
+
44
88
  def attribute_author(suggestion)
45
89
  author = current_author
46
90
  return unless author
@@ -0,0 +1,93 @@
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
+
57
+ <div class="actions">
58
+ <% unless suggestion.status_applied? %>
59
+ <%= button_to t('i18n_feedback.dashboard.apply', default: 'Apply'),
60
+ suggestion_path(suggestion, status: 'applied'), method: :patch, class: 'primary' %>
61
+ <% end %>
62
+ <% unless suggestion.status_rejected? %>
63
+ <%= button_to t('i18n_feedback.dashboard.reject', default: 'Reject'),
64
+ suggestion_path(suggestion, status: 'rejected'), method: :patch %>
65
+ <% end %>
66
+ <% unless suggestion.status_pending? %>
67
+ <%= button_to t('i18n_feedback.dashboard.reopen', default: 'Reopen'),
68
+ suggestion_path(suggestion, status: 'pending'), method: :patch %>
69
+ <% end %>
70
+ <%= button_to t('i18n_feedback.dashboard.delete', default: 'Delete'),
71
+ suggestion_path(suggestion), method: :delete, class: 'danger',
72
+ form: { data: { turbo_confirm: t('i18n_feedback.dashboard.delete_confirm', default: 'Delete this suggestion?') } } %>
73
+ </div>
74
+ </article>
75
+ <% end %>
76
+
77
+ <nav class="pager">
78
+ <span>
79
+ <% if @page > 1 %>
80
+ <%= link_to t('i18n_feedback.dashboard.newer', default: '← Newer'),
81
+ suggestions_path(status: @status, locale: @locale, page: @page - 1) %>
82
+ <% end %>
83
+ </span>
84
+ <span>
85
+ <% if @more %>
86
+ <%= link_to t('i18n_feedback.dashboard.older', default: 'Older →'),
87
+ suggestions_path(status: @status, locale: @locale, page: @page + 1) %>
88
+ <% end %>
89
+ </span>
90
+ </nav>
91
+ <% else %>
92
+ <div class="empty"><%= t('i18n_feedback.dashboard.empty', default: 'Nothing here yet.') %></div>
93
+ <% end %>
@@ -0,0 +1,99 @@
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
+ .actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 14px; }
79
+ .actions form { display: inline; margin: 0; }
80
+ button {
81
+ padding: 7px 13px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer;
82
+ background: var(--surface); color: var(--text); font: inherit; font-size: 14px; font-weight: 600;
83
+ }
84
+ button:hover { border-color: var(--muted); }
85
+ button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
86
+ button.danger { color: var(--rejected); border-color: transparent; }
87
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
88
+
89
+ .empty { padding: 48px 16px; text-align: center; color: var(--muted); border: 1px dashed var(--border); border-radius: 12px; }
90
+ .pager { margin-top: 16px; display: flex; justify-content: space-between; }
91
+ .pager .muted { color: var(--muted); }
92
+ </style>
93
+ </head>
94
+ <body>
95
+ <div class="container">
96
+ <%= yield %>
97
+ </div>
98
+ </body>
99
+ </html>
@@ -2,7 +2,7 @@ ar:
2
2
  i18n_feedback:
3
3
  pill: "اقترح تعديلات"
4
4
  start: "تحسين الترجمة"
5
- stop: "إيقاف الاقتراح"
5
+ stop: "إيقاف الاقتراح (Esc)"
6
6
  pill_active: "إيقاف الاقتراح (Esc)"
7
7
  title: "اقترح تصحيحًا للترجمة"
8
8
  current_text: "النص الحالي"
@@ -3,7 +3,7 @@ bg:
3
3
  pill: "Предложи промени"
4
4
  pill_active: "Спиране на предложенията (Esc)"
5
5
  start: "Подобряване на превода"
6
- stop: "Спиране на предложенията"
6
+ stop: "Спиране на предложенията (Esc)"
7
7
  title: "Предложете корекция на превода"
8
8
  current_text: "Текущ текст"
9
9
  suggested_text: "Предложен текст"
@@ -2,7 +2,7 @@ bn:
2
2
  i18n_feedback:
3
3
  pill: "সম্পাদনা প্রস্তাব করুন"
4
4
  start: "অনুবাদ উন্নত করুন"
5
- stop: "প্রস্তাব দেওয়া বন্ধ করুন"
5
+ stop: "প্রস্তাব দেওয়া বন্ধ করুন (Esc)"
6
6
  pill_active: "প্রস্তাব দেওয়া বন্ধ করুন (Esc)"
7
7
  title: "অনুবাদ সংশোধন প্রস্তাব করুন"
8
8
  current_text: "বর্তমান লেখা"
@@ -2,7 +2,7 @@ de:
2
2
  i18n_feedback:
3
3
  pill: "Änderungen vorschlagen"
4
4
  start: "Übersetzung verbessern"
5
- stop: "Vorschläge beenden"
5
+ stop: "Vorschläge beenden (Esc)"
6
6
  pill_active: "Vorschläge beenden (Esc)"
7
7
  title: "Übersetzungskorrektur vorschlagen"
8
8
  current_text: "Aktueller Text"
@@ -3,7 +3,7 @@ el:
3
3
  pill: "Προτείνετε αλλαγές"
4
4
  pill_active: "Διακοπή προτάσεων (Esc)"
5
5
  start: "Βελτίωση μετάφρασης"
6
- stop: "Διακοπή προτάσεων"
6
+ stop: "Διακοπή προτάσεων (Esc)"
7
7
  title: "Προτείνετε διόρθωση μετάφρασης"
8
8
  current_text: "Τρέχον κείμενο"
9
9
  suggested_text: "Προτεινόμενο κείμενο"
@@ -2,7 +2,7 @@ en:
2
2
  i18n_feedback:
3
3
  pill: "Suggest edits"
4
4
  start: "Improve translation"
5
- stop: "Stop suggesting"
5
+ stop: "Stop suggesting (Esc)"
6
6
  pill_active: "Stop suggesting (Esc)"
7
7
  title: "Suggest a translation fix"
8
8
  current_text: "Current text"
@@ -14,3 +14,26 @@ 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
+ apply: "Apply"
33
+ reject: "Reject"
34
+ reopen: "Reopen"
35
+ delete: "Delete"
36
+ delete_confirm: "Delete this suggestion?"
37
+ empty: "Nothing here yet."
38
+ newer: "← Newer"
39
+ older: "Older →"
@@ -2,7 +2,7 @@ es:
2
2
  i18n_feedback:
3
3
  pill: "Sugerir cambios"
4
4
  start: "Mejorar traducción"
5
- stop: "Dejar de sugerir"
5
+ stop: "Dejar de sugerir (Esc)"
6
6
  pill_active: "Dejar de sugerir (Esc)"
7
7
  title: "Sugerir una corrección de traducción"
8
8
  current_text: "Texto actual"
@@ -2,7 +2,7 @@ fr:
2
2
  i18n_feedback:
3
3
  pill: "Proposer des corrections"
4
4
  start: "Améliorer la traduction"
5
- stop: "Arrêter de proposer"
5
+ stop: "Arrêter de proposer (Esc)"
6
6
  pill_active: "Arrêter de proposer (Échap)"
7
7
  title: "Proposer une correction de traduction"
8
8
  current_text: "Texte actuel"
@@ -2,7 +2,7 @@ hi:
2
2
  i18n_feedback:
3
3
  pill: "बदलाव सुझाएँ"
4
4
  start: "अनुवाद सुधारें"
5
- stop: "सुझाव देना बंद करें"
5
+ stop: "सुझाव देना बंद करें (Esc)"
6
6
  pill_active: "सुझाव देना बंद करें (Esc)"
7
7
  title: "अनुवाद सुधार सुझाएँ"
8
8
  current_text: "वर्तमान टेक्स्ट"
@@ -3,7 +3,7 @@ hr:
3
3
  pill: "Predloži izmjene"
4
4
  pill_active: "Prestani predlagati (Esc)"
5
5
  start: "Poboljšaj prijevod"
6
- stop: "Prestani predlagati"
6
+ stop: "Prestani predlagati (Esc)"
7
7
  title: "Predložite ispravak prijevoda"
8
8
  current_text: "Trenutni tekst"
9
9
  suggested_text: "Predloženi tekst"
@@ -2,7 +2,7 @@ id:
2
2
  i18n_feedback:
3
3
  pill: "Sarankan perubahan"
4
4
  start: "Perbaiki terjemahan"
5
- stop: "Berhenti menyarankan"
5
+ stop: "Berhenti menyarankan (Esc)"
6
6
  pill_active: "Berhenti menyarankan (Esc)"
7
7
  title: "Sarankan perbaikan terjemahan"
8
8
  current_text: "Teks saat ini"
@@ -2,7 +2,7 @@ it:
2
2
  i18n_feedback:
3
3
  pill: "Suggerisci modifiche"
4
4
  start: "Migliora la traduzione"
5
- stop: "Interrompi i suggerimenti"
5
+ stop: "Interrompi i suggerimenti (Esc)"
6
6
  pill_active: "Interrompi i suggerimenti (Esc)"
7
7
  title: "Suggerisci una correzione di traduzione"
8
8
  current_text: "Testo attuale"
@@ -2,7 +2,7 @@ ja:
2
2
  i18n_feedback:
3
3
  pill: "修正を提案"
4
4
  start: "翻訳を改善"
5
- stop: "提案を終了"
5
+ stop: "提案を終了 (Esc)"
6
6
  pill_active: "提案を終了(Esc)"
7
7
  title: "翻訳の修正を提案"
8
8
  current_text: "現在のテキスト"
@@ -2,7 +2,7 @@ ko:
2
2
  i18n_feedback:
3
3
  pill: "수정 제안"
4
4
  start: "번역 개선"
5
- stop: "제안 중지"
5
+ stop: "제안 중지 (Esc)"
6
6
  pill_active: "제안 중지 (Esc)"
7
7
  title: "번역 수정 제안"
8
8
  current_text: "현재 텍스트"
@@ -3,7 +3,7 @@ lb:
3
3
  pill: "Ännerunge virschloen"
4
4
  pill_active: "Virschléi stoppen (Esc)"
5
5
  start: "Iwwersetzung verbesseren"
6
- stop: "Virschléi stoppen"
6
+ stop: "Virschléi stoppen (Esc)"
7
7
  title: "Eng Iwwersetzungskorrektur virschloen"
8
8
  current_text: "Aktuellen Text"
9
9
  suggested_text: "Virgeschloenen Text"
@@ -2,7 +2,7 @@ nl:
2
2
  i18n_feedback:
3
3
  pill: "Wijzigingen voorstellen"
4
4
  start: "Vertaling verbeteren"
5
- stop: "Stoppen met voorstellen"
5
+ stop: "Stoppen met voorstellen (Esc)"
6
6
  pill_active: "Stoppen met voorstellen (Esc)"
7
7
  title: "Een vertaalcorrectie voorstellen"
8
8
  current_text: "Huidige tekst"
@@ -2,7 +2,7 @@ pl:
2
2
  i18n_feedback:
3
3
  pill: "Zaproponuj zmiany"
4
4
  start: "Popraw tłumaczenie"
5
- stop: "Zakończ sugerowanie"
5
+ stop: "Zakończ sugerowanie (Esc)"
6
6
  pill_active: "Zakończ sugerowanie (Esc)"
7
7
  title: "Zaproponuj poprawkę tłumaczenia"
8
8
  current_text: "Bieżący tekst"
@@ -2,7 +2,7 @@ pt:
2
2
  i18n_feedback:
3
3
  pill: "Sugerir edições"
4
4
  start: "Melhorar tradução"
5
- stop: "Parar de sugerir"
5
+ stop: "Parar de sugerir (Esc)"
6
6
  pill_active: "Parar de sugerir (Esc)"
7
7
  title: "Sugerir uma correção de tradução"
8
8
  current_text: "Texto atual"
@@ -3,7 +3,7 @@ ro:
3
3
  pill: "Sugerează modificări"
4
4
  pill_active: "Oprește sugestiile (Esc)"
5
5
  start: "Îmbunătățește traducerea"
6
- stop: "Oprește sugestiile"
6
+ stop: "Oprește sugestiile (Esc)"
7
7
  title: "Sugerează o corectură de traducere"
8
8
  current_text: "Text curent"
9
9
  suggested_text: "Text sugerat"
@@ -2,7 +2,7 @@ ru:
2
2
  i18n_feedback:
3
3
  pill: "Предложить правки"
4
4
  start: "Улучшить перевод"
5
- stop: "Остановить предложения"
5
+ stop: "Остановить предложения (Esc)"
6
6
  pill_active: "Остановить предложения (Esc)"
7
7
  title: "Предложить исправление перевода"
8
8
  current_text: "Текущий текст"
@@ -2,7 +2,7 @@ th:
2
2
  i18n_feedback:
3
3
  pill: "แนะนำการแก้ไข"
4
4
  start: "ปรับปรุงคำแปล"
5
- stop: "หยุดแนะนำ"
5
+ stop: "หยุดแนะนำ (Esc)"
6
6
  pill_active: "หยุดแนะนำ (Esc)"
7
7
  title: "แนะนำการแก้ไขคำแปล"
8
8
  current_text: "ข้อความปัจจุบัน"
@@ -2,7 +2,7 @@ tr:
2
2
  i18n_feedback:
3
3
  pill: "Düzenleme öner"
4
4
  start: "Çeviriyi iyileştir"
5
- stop: "Önermeyi durdur"
5
+ stop: "Önermeyi durdur (Esc)"
6
6
  pill_active: "Önermeyi durdur (Esc)"
7
7
  title: "Çeviri düzeltmesi öner"
8
8
  current_text: "Mevcut metin"
@@ -2,7 +2,7 @@ uk:
2
2
  i18n_feedback:
3
3
  pill: "Запропонувати зміни"
4
4
  start: "Покращити переклад"
5
- stop: "Зупинити пропозиції"
5
+ stop: "Зупинити пропозиції (Esc)"
6
6
  pill_active: "Зупинити пропозиції (Esc)"
7
7
  title: "Запропонувати виправлення перекладу"
8
8
  current_text: "Поточний текст"
@@ -2,7 +2,7 @@ ur:
2
2
  i18n_feedback:
3
3
  pill: "ترامیم تجویز کریں"
4
4
  start: "ترجمہ بہتر بنائیں"
5
- stop: "تجویز کرنا بند کریں"
5
+ stop: "تجویز کرنا بند کریں (Esc)"
6
6
  pill_active: "تجویز کرنا بند کریں (Esc)"
7
7
  title: "ترجمے کی اصلاح تجویز کریں"
8
8
  current_text: "موجودہ متن"
@@ -2,7 +2,7 @@ vi:
2
2
  i18n_feedback:
3
3
  pill: "Đề xuất chỉnh sửa"
4
4
  start: "Cải thiện bản dịch"
5
- stop: "Dừng đề xuất"
5
+ stop: "Dừng đề xuất (Esc)"
6
6
  pill_active: "Dừng đề xuất (Esc)"
7
7
  title: "Đề xuất sửa bản dịch"
8
8
  current_text: "Văn bản hiện tại"
@@ -2,7 +2,7 @@ zh-CN:
2
2
  i18n_feedback:
3
3
  pill: "建议修改"
4
4
  start: "改进翻译"
5
- stop: "停止建议"
5
+ stop: "停止建议 (Esc)"
6
6
  pill_active: "停止建议(Esc)"
7
7
  title: "建议翻译修正"
8
8
  current_text: "当前文本"
data/config/routes.rb CHANGED
@@ -1,5 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  I18nFeedback::Engine.routes.draw do
4
- resources :suggestions, only: %i[index create]
4
+ # index/update/destroy are the admin triage dashboard; create + the `context`
5
+ # collection route are the widget's public API (see SuggestionsController).
6
+ resources :suggestions, only: %i[index create update destroy] do
7
+ get :context, on: :collection
8
+ end
9
+
10
+ root to: 'suggestions#index'
5
11
  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.0'
4
+ VERSION = '0.8.0'
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
  })
@@ -9,7 +9,11 @@ module I18nFeedback
9
9
  # served as a separate asset to avoid any dependency on the host's asset
10
10
  # pipeline.
11
11
  module Widget
12
- SOURCE = File.expand_path('../../app/assets/i18n_feedback/widget.js', __dir__)
12
+ # Lives under lib/ (not app/assets/), so a host that runs an asset
13
+ # pipeline never ingests it: Rails auto-registers every engine's
14
+ # app/assets/* directory, which would put this file in the host's asset
15
+ # namespace as a bare "widget.js" and into its precompiled output.
16
+ SOURCE = File.expand_path('widget.js', __dir__)
13
17
 
14
18
  # Right-to-left scripts, so the popover renders mirrored for those locales.
15
19
  # Matched on the language subtag, so region variants ("ar-EG") count too.
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.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov
@@ -38,12 +38,13 @@ files:
38
38
  - MIT-LICENSE
39
39
  - README.md
40
40
  - Rakefile
41
- - app/assets/i18n_feedback/widget.js
42
41
  - app/controllers/i18n_feedback/application_controller.rb
43
42
  - app/controllers/i18n_feedback/suggestions_controller.rb
44
43
  - app/helpers/i18n_feedback/tag_helper.rb
45
44
  - app/models/i18n_feedback/application_record.rb
46
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
47
48
  - config/locales/i18n_feedback.ar.yml
48
49
  - config/locales/i18n_feedback.bg.yml
49
50
  - config/locales/i18n_feedback.bn.yml
@@ -80,6 +81,7 @@ files:
80
81
  - lib/i18n_feedback/marking.rb
81
82
  - lib/i18n_feedback/middleware.rb
82
83
  - lib/i18n_feedback/version.rb
84
+ - lib/i18n_feedback/widget.js
83
85
  - lib/i18n_feedback/widget.rb
84
86
  homepage: https://github.com/yshmarov/i18n-feedback
85
87
  licenses: