i18n_proofreading 0.10.2 → 0.10.4
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 +18 -0
- data/app/controllers/i18n_proofreading/suggestions_controller.rb +11 -2
- data/app/controllers/i18n_proofreading/widgets_controller.rb +14 -4
- data/app/views/i18n_proofreading/suggestions/_suggestion_panel.html.erb +42 -0
- data/app/views/i18n_proofreading/suggestions/index.html.erb +89 -67
- data/app/views/i18n_proofreading/suggestions/show.html.erb +8 -0
- data/app/views/layouts/i18n_proofreading/application.html.erb +4 -83
- data/config/routes.rb +2 -1
- data/lib/generators/i18n_proofreading/install/install_generator.rb +1 -1
- data/lib/generators/i18n_proofreading/install/templates/initializer.rb +2 -1
- data/lib/i18n_proofreading/dashboard.css +161 -0
- data/lib/i18n_proofreading/engine.rb +9 -0
- data/lib/i18n_proofreading/version.rb +1 -1
- data/lib/i18n_proofreading/widget.rb +9 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3546b2ddcbb15b380075959eee5ea843e5c0e5cf52748101b0a1e57774365699
|
|
4
|
+
data.tar.gz: cb3bb010786b1d7f307c8cd850f372a8f9b5424765c26840d472f68ffe815eb8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5e4b71924974509622d7c9d8d139e922fdd116ffded96f64ec6da94797cf09c96797328e6a76d12b169a99128a6de39f7daa48d67662bb513ee096ca4d865931
|
|
7
|
+
data.tar.gz: c729fef9100594e0fa53a7fcd81f5678eff7c2d8bc4b3cf56de1196e32f45997081e5e3f3080e065af3f8545388f4fe62d5f0cefbd43011f1103244bed678efe
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.10.4]
|
|
6
|
+
|
|
7
|
+
- Redesigned the proofreading admin dashboard into a two-column review layout
|
|
8
|
+
with status filters, a selected-suggestion pane, and refreshed before/after
|
|
9
|
+
screenshots.
|
|
10
|
+
- Added independent suggestion show pages while keeping long detail content
|
|
11
|
+
scrollable on narrow screens.
|
|
12
|
+
|
|
13
|
+
## [0.10.3]
|
|
14
|
+
|
|
15
|
+
- Added `mount_i18n_proofreading at: "/i18n_proofreading"` as the install-time
|
|
16
|
+
route helper, keeping `config.mount_path` synchronized with the mounted
|
|
17
|
+
engine path while preserving manual `mount I18nProofreading::Engine`
|
|
18
|
+
compatibility.
|
|
19
|
+
- Extracted the dashboard stylesheet into a same-origin, fingerprinted
|
|
20
|
+
`/dashboard.css` endpoint and added CSP meta tags to the dashboard layout.
|
|
21
|
+
The public widget remains pipeline-free and controller-served.
|
|
22
|
+
|
|
5
23
|
## [0.10.2]
|
|
6
24
|
|
|
7
25
|
- Added the `I18nProofreading` dashboard title to every shipped locale, matching
|
|
@@ -6,9 +6,10 @@ module I18nProofreading
|
|
|
6
6
|
|
|
7
7
|
# Public widget API is available-gated; the read-only dashboard is admin-gated.
|
|
8
8
|
before_action :require_available, only: %i[context create]
|
|
9
|
-
before_action :require_admin, only:
|
|
9
|
+
before_action :require_admin, only: %i[index show]
|
|
10
|
+
before_action :set_suggestion, only: :show
|
|
10
11
|
|
|
11
|
-
layout 'i18n_proofreading/application', only:
|
|
12
|
+
layout 'i18n_proofreading/application', only: %i[index show]
|
|
12
13
|
|
|
13
14
|
# Throttle the public submission endpoint per IP so one user or bot can't
|
|
14
15
|
# flood the table. Uses the rate limiter built into Rails 7.2+ (backed by
|
|
@@ -37,8 +38,12 @@ module I18nProofreading
|
|
|
37
38
|
rows = scope.newest_first.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
|
|
38
39
|
@more = rows.size > PER_PAGE
|
|
39
40
|
@suggestions = rows.first(PER_PAGE)
|
|
41
|
+
|
|
42
|
+
@selected_suggestion = Suggestion.find_by(id: params[:suggestion_id]) if params[:suggestion_id].present?
|
|
40
43
|
end
|
|
41
44
|
|
|
45
|
+
def show; end
|
|
46
|
+
|
|
42
47
|
# --- widget API (public) -------------------------------------------------
|
|
43
48
|
|
|
44
49
|
# Pending suggestions for one key/locale, shown as read-only context when the
|
|
@@ -67,6 +72,10 @@ module I18nProofreading
|
|
|
67
72
|
|
|
68
73
|
private
|
|
69
74
|
|
|
75
|
+
def set_suggestion
|
|
76
|
+
@suggestion = Suggestion.find(params[:id])
|
|
77
|
+
end
|
|
78
|
+
|
|
70
79
|
def attribute_author(suggestion)
|
|
71
80
|
author = current_author
|
|
72
81
|
return unless author
|
|
@@ -7,7 +7,7 @@ module I18nProofreading
|
|
|
7
7
|
# Drive swaps the <body> and re-runs body scripts under the *original*
|
|
8
8
|
# page's CSP header, where a freshly minted inline nonce would be refused.
|
|
9
9
|
class WidgetsController < ApplicationController
|
|
10
|
-
#
|
|
10
|
+
# These assets are static and carry no user data; without this, Rails'
|
|
11
11
|
# cross-origin JavaScript guard refuses to serve it to a plain
|
|
12
12
|
# <script src> request.
|
|
13
13
|
skip_forgery_protection
|
|
@@ -17,10 +17,20 @@ module I18nProofreading
|
|
|
17
17
|
# fingerprinted URL is immutable and gets long-lived caching; anything
|
|
18
18
|
# else only ETag-revalidates.
|
|
19
19
|
def show
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
serve(Widget.javascript, Widget.fingerprint, 'text/javascript')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def dashboard_stylesheet
|
|
24
|
+
serve(Widget.dashboard_stylesheet, Widget.dashboard_stylesheet_fingerprint, 'text/css')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def serve(source, fingerprint, content_type)
|
|
30
|
+
expires_in 1.year, public: true if params[:v] == fingerprint
|
|
31
|
+
return unless stale?(etag: [I18nProofreading::VERSION, fingerprint])
|
|
22
32
|
|
|
23
|
-
render plain:
|
|
33
|
+
render plain: source, content_type: content_type
|
|
24
34
|
end
|
|
25
35
|
end
|
|
26
36
|
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<section class="suggestion-panel">
|
|
2
|
+
<div class="panel-head">
|
|
3
|
+
<h1>
|
|
4
|
+
<code class="key"><%= suggestion.translation_key %></code>
|
|
5
|
+
<span class="badge locale"><%= suggestion.locale %></span>
|
|
6
|
+
<span class="badge status-<%= suggestion.status %>">
|
|
7
|
+
<%= t("i18n_proofreading.statuses.#{suggestion.status}", default: suggestion.status.humanize) %>
|
|
8
|
+
</span>
|
|
9
|
+
<span class="case-id">#<%= suggestion.id %></span>
|
|
10
|
+
</h1>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<div class="detail-scroll">
|
|
14
|
+
<article class="card pad">
|
|
15
|
+
<dl class="meta-list">
|
|
16
|
+
<% if suggestion.author_label.present? || suggestion.author_id.present? %>
|
|
17
|
+
<dt><%= t('i18n_proofreading.dashboard.from', default: 'From') %></dt>
|
|
18
|
+
<dd><%= suggestion.author_label.presence || "##{suggestion.author_id}" %></dd>
|
|
19
|
+
<% end %>
|
|
20
|
+
<% if suggestion.page_url.present? %>
|
|
21
|
+
<dt><%= t('i18n_proofreading.dashboard.page', default: 'Page') %></dt>
|
|
22
|
+
<dd><%= link_to suggestion.page_url, suggestion.page_url, target: '_blank', rel: 'noopener' %></dd>
|
|
23
|
+
<% end %>
|
|
24
|
+
<dt><%= t('i18n_proofreading.dashboard.received', default: 'Received') %></dt>
|
|
25
|
+
<dd><%= suggestion.created_at %></dd>
|
|
26
|
+
</dl>
|
|
27
|
+
</article>
|
|
28
|
+
|
|
29
|
+
<article class="card pad">
|
|
30
|
+
<dl class="diff">
|
|
31
|
+
<dt><%= t('i18n_proofreading.dashboard.current', default: 'Current') %></dt>
|
|
32
|
+
<dd><span class="old" dir="auto"><%= suggestion.old_value.presence || '—' %></span></dd>
|
|
33
|
+
<dt><%= t('i18n_proofreading.dashboard.suggested', default: 'Suggested') %></dt>
|
|
34
|
+
<dd><span class="new" dir="auto"><%= suggestion.proposed_value %></span></dd>
|
|
35
|
+
</dl>
|
|
36
|
+
|
|
37
|
+
<% if suggestion.comment.present? %>
|
|
38
|
+
<div class="comment" dir="auto"><%= suggestion.comment %></div>
|
|
39
|
+
<% end %>
|
|
40
|
+
</article>
|
|
41
|
+
</div>
|
|
42
|
+
</section>
|
|
@@ -1,77 +1,99 @@
|
|
|
1
|
+
<% content_for :body_class, 'ip-index' %>
|
|
2
|
+
|
|
1
3
|
<header class="page">
|
|
2
4
|
<h1><%= t('i18n_proofreading.dashboard.title', default: 'I18nProofreading') %></h1>
|
|
3
5
|
<p class="lede"><%= t('i18n_proofreading.dashboard.subtitle', default: 'Review what reviewers proposed, then apply it to your locale files.') %></p>
|
|
4
6
|
</header>
|
|
5
7
|
|
|
6
|
-
<
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
<% if @locales.many? %>
|
|
17
|
-
<form class="filters" method="get">
|
|
18
|
-
<input type="hidden" name="status" value="<%= @status %>">
|
|
19
|
-
<label for="i18np-locale"><%= t('i18n_proofreading.dashboard.locale', default: 'Locale') %></label>
|
|
20
|
-
<select id="i18np-locale" name="locale" onchange="this.form.submit()">
|
|
21
|
-
<option value=""><%= t('i18n_proofreading.dashboard.all_locales', default: 'All locales') %></option>
|
|
22
|
-
<% @locales.each do |locale| %>
|
|
23
|
-
<option value="<%= locale %>" <%= 'selected' if locale == @locale %>><%= locale %></option>
|
|
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>
|
|
16
|
+
<% end %>
|
|
24
17
|
<% end %>
|
|
25
|
-
</
|
|
26
|
-
<%# Inline onchange auto-submits where allowed; this button is the reliable
|
|
27
|
-
path where a strict CSP blocks inline handlers, or with JS off. %>
|
|
28
|
-
<button><%= t('i18n_proofreading.dashboard.filter', default: 'Filter') %></button>
|
|
29
|
-
</form>
|
|
30
|
-
<% end %>
|
|
18
|
+
</nav>
|
|
31
19
|
|
|
32
|
-
<% if @
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
<dl class="diff">
|
|
49
|
-
<dt><%= t('i18n_proofreading.dashboard.current', default: 'Current') %></dt>
|
|
50
|
-
<dd><span class="old" dir="auto"><%= suggestion.old_value.presence || '—' %></span></dd>
|
|
51
|
-
<dt><%= t('i18n_proofreading.dashboard.suggested', default: 'Suggested') %></dt>
|
|
52
|
-
<dd><span class="new" dir="auto"><%= suggestion.proposed_value %></span></dd>
|
|
53
|
-
</dl>
|
|
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 %>
|
|
54
35
|
|
|
55
|
-
|
|
56
|
-
|
|
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') %>
|
|
48
|
+
</span>
|
|
49
|
+
</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 %>
|
|
56
|
+
</span>
|
|
57
|
+
</span>
|
|
58
|
+
<span class="badge status-<%= suggestion.status %>">
|
|
59
|
+
<%= t("i18n_proofreading.statuses.#{suggestion.status}", default: suggestion.status.humanize) %>
|
|
60
|
+
</span>
|
|
61
|
+
<% end %>
|
|
62
|
+
<% end %>
|
|
63
|
+
<% else %>
|
|
64
|
+
<div class="empty"><%= t('i18n_proofreading.dashboard.empty', default: 'Nothing here yet.') %></div>
|
|
57
65
|
<% end %>
|
|
58
|
-
</
|
|
59
|
-
<% end %>
|
|
66
|
+
</div>
|
|
60
67
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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>
|
|
83
|
+
|
|
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>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<% content_for :body_class, 'ip-show' %>
|
|
2
|
+
|
|
3
|
+
<p class="breadcrumb">
|
|
4
|
+
<%= link_to t('i18n_proofreading.dashboard.title', default: 'I18nProofreading'), suggestions_path %>
|
|
5
|
+
/ #<%= @suggestion.id %>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<%= render 'suggestion_panel', suggestion: @suggestion %>
|
|
@@ -5,90 +5,11 @@
|
|
|
5
5
|
<title><%= t('i18n_proofreading.dashboard.title', default: 'I18nProofreading') %></title>
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
7
|
<%= csrf_meta_tags %>
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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 label { color: var(--muted); font-size: 13px; }
|
|
47
|
-
.filters select {
|
|
48
|
-
padding: 6px 8px; border: 1px solid var(--border); border-radius: 8px;
|
|
49
|
-
background: var(--surface); color: var(--text); font: inherit;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
.card {
|
|
53
|
-
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
|
54
|
-
padding: 14px 16px; margin-bottom: 12px;
|
|
55
|
-
}
|
|
56
|
-
.row-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
|
|
57
|
-
code.key {
|
|
58
|
-
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
|
|
59
|
-
background: var(--code); padding: 2px 7px; border-radius: 6px; overflow-wrap: anywhere;
|
|
60
|
-
}
|
|
61
|
-
.badge {
|
|
62
|
-
display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600;
|
|
63
|
-
color: #fff; white-space: nowrap;
|
|
64
|
-
}
|
|
65
|
-
.badge.status-pending { background: var(--pending); }
|
|
66
|
-
.badge.status-applied { background: var(--applied); }
|
|
67
|
-
.badge.status-rejected { background: var(--rejected); }
|
|
68
|
-
.badge.locale { background: transparent; color: var(--muted); border: 1px solid var(--border); }
|
|
69
|
-
.spacer { flex: 1; }
|
|
70
|
-
.meta { color: var(--muted); font-size: 13px; }
|
|
71
|
-
|
|
72
|
-
dl.diff { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; }
|
|
73
|
-
dl.diff dt { color: var(--muted); font-size: 13px; }
|
|
74
|
-
dl.diff dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
|
|
75
|
-
dd .old { color: var(--muted); }
|
|
76
|
-
dd .new { font-weight: 600; }
|
|
77
|
-
.comment { margin-top: 10px; padding: 8px 10px; background: var(--code); border-radius: 8px; font-size: 14px; overflow-wrap: anywhere; }
|
|
78
|
-
|
|
79
|
-
button {
|
|
80
|
-
padding: 7px 13px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer;
|
|
81
|
-
background: var(--surface); color: var(--text); font: inherit; font-size: 14px; font-weight: 600;
|
|
82
|
-
}
|
|
83
|
-
button:hover { border-color: var(--muted); }
|
|
84
|
-
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
85
|
-
|
|
86
|
-
.empty { padding: 48px 16px; text-align: center; color: var(--muted); border: 1px dashed var(--border); border-radius: 12px; }
|
|
87
|
-
.pager { margin-top: 16px; display: flex; justify-content: space-between; }
|
|
88
|
-
.pager .muted { color: var(--muted); }
|
|
89
|
-
</style>
|
|
8
|
+
<%= csp_meta_tag %>
|
|
9
|
+
<%= stylesheet_link_tag "#{dashboard_stylesheet_path}?v=#{I18nProofreading::Widget.dashboard_stylesheet_fingerprint}",
|
|
10
|
+
'data-turbo-track': 'reload' %>
|
|
90
11
|
</head>
|
|
91
|
-
<body>
|
|
12
|
+
<body class="<%= content_for?(:body_class) ? yield(:body_class) : '' %>">
|
|
92
13
|
<div class="container">
|
|
93
14
|
<%= yield %>
|
|
94
15
|
</div>
|
data/config/routes.rb
CHANGED
|
@@ -4,12 +4,13 @@ I18nProofreading::Engine.routes.draw do
|
|
|
4
4
|
# The widget code, served same-origin so it runs under a `'self'`/nonce-based
|
|
5
5
|
# CSP even across Turbo body swaps (see WidgetsController).
|
|
6
6
|
get 'widget.js', to: 'widgets#show', as: :widget
|
|
7
|
+
get 'dashboard.css', to: 'widgets#dashboard_stylesheet', as: :dashboard_stylesheet
|
|
7
8
|
|
|
8
9
|
# index is the read-only admin dashboard; create + the `context` collection
|
|
9
10
|
# route are the widget's public API (see SuggestionsController). There is
|
|
10
11
|
# deliberately no update/destroy — the tool never mutates suggestions from the
|
|
11
12
|
# UI, since it can't write to the host's locale files.
|
|
12
|
-
resources :suggestions, only: %i[index create] do
|
|
13
|
+
resources :suggestions, only: %i[index show create] do
|
|
13
14
|
get :context, on: :collection
|
|
14
15
|
end
|
|
15
16
|
|
|
@@ -47,7 +47,8 @@ I18nProofreading.configure do |config|
|
|
|
47
47
|
# config.show_pill = true
|
|
48
48
|
# config.toggle_param = "i18n_proofreading"
|
|
49
49
|
|
|
50
|
-
#
|
|
50
|
+
# Default mount path used by `mount_i18n_proofreading`.
|
|
51
|
+
# Override only if you mount the engine manually.
|
|
51
52
|
#
|
|
52
53
|
# config.mount_path = "/i18n_proofreading"
|
|
53
54
|
|
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
}
|
|
43
|
+
|
|
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
|
+
|
|
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; }
|
|
91
|
+
|
|
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; }
|
|
135
|
+
|
|
136
|
+
/* Standalone show pages keep normal page scrolling. */
|
|
137
|
+
.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
|
+
}
|
|
@@ -22,5 +22,14 @@ module I18nProofreading
|
|
|
22
22
|
include I18nProofreading::TagHelper
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
|
+
|
|
26
|
+
initializer 'i18n_proofreading.routing' do
|
|
27
|
+
ActionDispatch::Routing::Mapper.include(Module.new do
|
|
28
|
+
def mount_i18n_proofreading(at: I18nProofreading.config.mount_path, **options)
|
|
29
|
+
I18nProofreading.config.mount_path = at
|
|
30
|
+
mount I18nProofreading::Engine, at:, **options
|
|
31
|
+
end
|
|
32
|
+
end)
|
|
33
|
+
end
|
|
25
34
|
end
|
|
26
35
|
end
|
|
@@ -15,6 +15,7 @@ module I18nProofreading
|
|
|
15
15
|
# app/assets/* directory, which would put this file in the host's asset
|
|
16
16
|
# namespace as a bare "widget.js" and into its precompiled output.
|
|
17
17
|
SOURCE = File.expand_path('widget.js', __dir__)
|
|
18
|
+
DASHBOARD_STYLESHEET_SOURCE = File.expand_path('dashboard.css', __dir__)
|
|
18
19
|
|
|
19
20
|
# Right-to-left scripts, so the popover renders mirrored for those locales.
|
|
20
21
|
# Matched on the language subtag, so region variants ("ar-EG") count too.
|
|
@@ -25,6 +26,10 @@ module I18nProofreading
|
|
|
25
26
|
@javascript ||= File.read(SOURCE)
|
|
26
27
|
end
|
|
27
28
|
|
|
29
|
+
def dashboard_stylesheet
|
|
30
|
+
@dashboard_stylesheet ||= File.read(DASHBOARD_STYLESHEET_SOURCE)
|
|
31
|
+
end
|
|
32
|
+
|
|
28
33
|
# Content fingerprint for the cache-busting script URL: a changed file is
|
|
29
34
|
# a changed URL, so no browser can ever run stale widget code — Safari
|
|
30
35
|
# has been caught ignoring must-revalidate on same-URL scripts.
|
|
@@ -32,6 +37,10 @@ module I18nProofreading
|
|
|
32
37
|
@fingerprint ||= Digest::MD5.hexdigest(javascript)
|
|
33
38
|
end
|
|
34
39
|
|
|
40
|
+
def dashboard_stylesheet_fingerprint
|
|
41
|
+
@dashboard_stylesheet_fingerprint ||= Digest::MD5.hexdigest(dashboard_stylesheet)
|
|
42
|
+
end
|
|
43
|
+
|
|
35
44
|
# The two <script> tags to place before </body>.
|
|
36
45
|
#
|
|
37
46
|
# The config rides in a `type="application/json"` block: it is *data*, not
|
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.
|
|
4
|
+
version: 0.10.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yaroslav Shmarov
|
|
@@ -44,7 +44,9 @@ files:
|
|
|
44
44
|
- app/helpers/i18n_proofreading/tag_helper.rb
|
|
45
45
|
- app/models/i18n_proofreading/application_record.rb
|
|
46
46
|
- app/models/i18n_proofreading/suggestion.rb
|
|
47
|
+
- app/views/i18n_proofreading/suggestions/_suggestion_panel.html.erb
|
|
47
48
|
- app/views/i18n_proofreading/suggestions/index.html.erb
|
|
49
|
+
- app/views/i18n_proofreading/suggestions/show.html.erb
|
|
48
50
|
- app/views/layouts/i18n_proofreading/application.html.erb
|
|
49
51
|
- config/locales/i18n_proofreading.ar.yml
|
|
50
52
|
- config/locales/i18n_proofreading.bg.yml
|
|
@@ -78,6 +80,7 @@ files:
|
|
|
78
80
|
- lib/generators/i18n_proofreading/install/templates/initializer.rb
|
|
79
81
|
- lib/i18n_proofreading.rb
|
|
80
82
|
- lib/i18n_proofreading/configuration.rb
|
|
83
|
+
- lib/i18n_proofreading/dashboard.css
|
|
81
84
|
- lib/i18n_proofreading/engine.rb
|
|
82
85
|
- lib/i18n_proofreading/marking.rb
|
|
83
86
|
- lib/i18n_proofreading/middleware.rb
|