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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 332c3bc0e3ee49fd7f316be88b3f4b12d929f646918cfde3d5219f6aefe2b13d
4
- data.tar.gz: d0728318f2e8250f10b4e36cbcde09e540cf81a51fb57db39abf1e66e8990a02
3
+ metadata.gz: 3546b2ddcbb15b380075959eee5ea843e5c0e5cf52748101b0a1e57774365699
4
+ data.tar.gz: cb3bb010786b1d7f307c8cd850f372a8f9b5424765c26840d472f68ffe815eb8
5
5
  SHA512:
6
- metadata.gz: a31a61e1e72ef9e8ad6037bea224e7116ed888f6d09af5fe2872bf45941a0e4da67f7587641dc54445b093e01097eab185115909664646ff3dd4e6d956edff52
7
- data.tar.gz: b12ee534fdf28b90cc8d123594a473c5d0c8b67daeefc66b34258235cb0516fc04cdcfef4eac5bf9b6998bb1efd1e4ff3e40c34cdc547a8d54e149148e7df5eb
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: :index
9
+ before_action :require_admin, only: %i[index show]
10
+ before_action :set_suggestion, only: :show
10
11
 
11
- layout 'i18n_proofreading/application', only: :index
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
- # The script is static and carries no user data; without this, Rails'
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
- expires_in 1.year, public: true if params[:v] == Widget.fingerprint
21
- return unless stale?(etag: [I18nProofreading::VERSION, Widget.fingerprint])
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: Widget.javascript, content_type: 'text/javascript'
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
- <nav class="tabs">
7
- <% I18nProofreading::Suggestion::STATUSES.each do |status| %>
8
- <%= link_to suggestions_path(status: status, locale: @locale),
9
- class: ('active' if status == @status) do %>
10
- <%= t("i18n_proofreading.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
- <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
- </select>
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 @suggestions.any? %>
33
- <% @suggestions.each do |suggestion| %>
34
- <article class="card">
35
- <div class="row-top">
36
- <code class="key"><%= suggestion.translation_key %></code>
37
- <span class="badge locale"><%= suggestion.locale %></span>
38
- <span class="badge status-<%= suggestion.status %>">
39
- <%= t("i18n_proofreading.statuses.#{suggestion.status}", default: suggestion.status.humanize) %>
40
- </span>
41
- <span class="spacer"></span>
42
- <span class="meta">
43
- <% if suggestion.author_label.present? %><%= suggestion.author_label %> · <% end %>
44
- <span title="<%= suggestion.created_at %>"><%= time_ago_in_words(suggestion.created_at) %> <%= t('i18n_proofreading.dashboard.ago', default: 'ago') %></span>
45
- </span>
46
- </div>
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
- <% if suggestion.comment.present? %>
56
- <div class="comment" dir="auto"><%= suggestion.comment %></div>
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
- </article>
59
- <% end %>
66
+ </div>
60
67
 
61
- <nav class="pager">
62
- <span>
63
- <% if @page > 1 %>
64
- <%= link_to t('i18n_proofreading.dashboard.newer', default: '← Newer'),
65
- suggestions_path(status: @status, locale: @locale, page: @page - 1) %>
66
- <% end %>
67
- </span>
68
- <span>
69
- <% if @more %>
70
- <%= link_to t('i18n_proofreading.dashboard.older', default: 'Older →'),
71
- suggestions_path(status: @status, locale: @locale, page: @page + 1) %>
72
- <% end %>
73
- </span>
74
- </nav>
75
- <% else %>
76
- <div class="empty"><%= t('i18n_proofreading.dashboard.empty', default: 'Nothing here yet.') %></div>
77
- <% end %>
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
- <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 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
 
@@ -22,7 +22,7 @@ module I18nProofreading
22
22
  end
23
23
 
24
24
  def mount_engine
25
- route %(mount I18nProofreading::Engine => "/i18n_proofreading")
25
+ route %(mount_i18n_proofreading at: "/i18n_proofreading")
26
26
  end
27
27
 
28
28
  def post_install
@@ -47,7 +47,8 @@ I18nProofreading.configure do |config|
47
47
  # config.show_pill = true
48
48
  # config.toggle_param = "i18n_proofreading"
49
49
 
50
- # Keep this in sync with the `mount` line in config/routes.rb.
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module I18nProofreading
4
- VERSION = '0.10.2'
4
+ VERSION = '0.10.4'
5
5
  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.2
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