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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +26 -1
- data/app/controllers/i18n_feedback/application_controller.rb +13 -4
- data/app/controllers/i18n_feedback/suggestions_controller.rb +45 -1
- data/app/views/i18n_feedback/suggestions/index.html.erb +93 -0
- data/app/views/layouts/i18n_feedback/application.html.erb +99 -0
- data/config/locales/i18n_feedback.ar.yml +1 -1
- data/config/locales/i18n_feedback.bg.yml +1 -1
- data/config/locales/i18n_feedback.bn.yml +1 -1
- data/config/locales/i18n_feedback.de.yml +1 -1
- data/config/locales/i18n_feedback.el.yml +1 -1
- data/config/locales/i18n_feedback.en.yml +24 -1
- data/config/locales/i18n_feedback.es.yml +1 -1
- data/config/locales/i18n_feedback.fr.yml +1 -1
- data/config/locales/i18n_feedback.hi.yml +1 -1
- data/config/locales/i18n_feedback.hr.yml +1 -1
- data/config/locales/i18n_feedback.id.yml +1 -1
- data/config/locales/i18n_feedback.it.yml +1 -1
- data/config/locales/i18n_feedback.ja.yml +1 -1
- data/config/locales/i18n_feedback.ko.yml +1 -1
- data/config/locales/i18n_feedback.lb.yml +1 -1
- data/config/locales/i18n_feedback.nl.yml +1 -1
- data/config/locales/i18n_feedback.pl.yml +1 -1
- data/config/locales/i18n_feedback.pt.yml +1 -1
- data/config/locales/i18n_feedback.ro.yml +1 -1
- data/config/locales/i18n_feedback.ru.yml +1 -1
- data/config/locales/i18n_feedback.th.yml +1 -1
- data/config/locales/i18n_feedback.tr.yml +1 -1
- data/config/locales/i18n_feedback.uk.yml +1 -1
- data/config/locales/i18n_feedback.ur.yml +1 -1
- data/config/locales/i18n_feedback.vi.yml +1 -1
- data/config/locales/i18n_feedback.zh-CN.yml +1 -1
- data/config/routes.rb +7 -1
- data/lib/generators/i18n_feedback/install/templates/initializer.rb +7 -0
- data/lib/i18n_feedback/configuration.rb +8 -0
- data/lib/i18n_feedback/version.rb +1 -1
- data/{app/assets → lib}/i18n_feedback/widget.js +1 -1
- data/lib/i18n_feedback/widget.rb +5 -1
- data/lib/i18n_feedback.rb +7 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 675e56ff8164412361ecd15886a56ef5eb7a97fdda9a766246b0d690c602d2e1
|
|
4
|
+
data.tar.gz: 36c511550f1b042de8052e995afeab43c27645410d0dea5246a4ffde9e9d598f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
#
|
|
12
|
-
#
|
|
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
|
|
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>
|
|
@@ -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 @@ 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"
|
|
@@ -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 @@ 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"
|
|
@@ -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"
|
|
@@ -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 @@ 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: "Поточний текст"
|
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
|
-
|
|
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) }
|
|
@@ -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/widget.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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:
|