ideasbugs 0.7.8 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9875f27cec0e4396ed1c2b269cea94335852c1a4776850fd0944bf9e52f5d154
4
- data.tar.gz: c2301f7df468b8659b99cafb8e4f52e118b0f23f70c983359a1d236368e75121
3
+ metadata.gz: 19b7014d62b42ad3b3a763edf8907a7e1e0d2a67ab7b5ae1001d1f3440cc0d67
4
+ data.tar.gz: 0f327125389c6ccc2d57f180b09d24607f383d03ac1d70ad13c9c250d3670842
5
5
  SHA512:
6
- metadata.gz: dd3615cbda1b4455a49a345ec5c0620a810547220a44ead6434c1a912f308fad963b4f022b5e9ea3c78140c72d33365420e43c893cb4330975d929ebd9ded36b
7
- data.tar.gz: b29d7052738b06accc692290f3973c88ef7537fa26cdd5d728da46036267bc581310012600b18d1f20fe9113abd162f733f4b1045867f793f0ae9452ed0276ce
6
+ metadata.gz: f1f27d97cda7c058c8d12c4498e30a57a9bc3899ce61aca61cb4cb5b542c4359b48e69d7a00c8867ddddc421cb561fb843039d6ac36bce4b164d9c140229e421
7
+ data.tar.gz: 9bca34a90ac4d1e4f15ddfb8263a0e8690aa61cb669f1519e83c43481e5e64c05133ec5317c4432b02484af66fdf2123f4574fbdb9710930bd1c0cdb94d3ed0f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0
4
+
5
+ - **`config.admin_layout` now works on its own.** The dashboard's stylesheet was
6
+ declared in the gem's layout, so replacing that layout dropped it and the
7
+ dashboard rendered unstyled. It moves into the views, so every layout gets it
8
+ with nothing asked of the host.
9
+ - **The dashboard stylesheet no longer claims selectors it does not own.** It
10
+ styled bare `*`, `body` and `a`, and its `.container`, `.card` and `.tabs` are
11
+ names other frameworks use too. Component rules now nest inside an
12
+ `.ib-dashboard` wrapper the views render, and every custom property is `--ib-`
13
+ prefixed — that collision ran both ways, so a host defining `--bg` recoloured
14
+ the dashboard just as easily.
15
+ - **Added `config.base_controller_class`.** Name the controller your own admin
16
+ inherits from and the dashboard adopts its layout, helpers, authentication and
17
+ request context. Default is unchanged.
18
+ - **`create` moved to `Ideasbugs::SubmissionsController`.** One controller served
19
+ both the widget's write endpoint and the triage actions, so
20
+ `base_controller_class` would have put staff authentication in front of every
21
+ bug report. `POST /feedbacks` now routes there; the URL is unchanged. If you
22
+ referenced `Ideasbugs::FeedbacksController#create`, that is the breaking change
23
+ in this release. The per-IP rate limiter moved with the action.
24
+ - **Migrations follow the host's `primary_key_type`,** the same
25
+ `Rails.configuration.generators` lookup Rails' own Active Storage migration
26
+ does. A uuid-keyed app has a uuid `active_storage_attachments.record_id`, so a
27
+ bigint table here could never hold a screenshot: `attach` raised
28
+ `NotNullViolation`. A host that set nothing gets an identical migration.
29
+ - A `BackboneTest` now fails the build on any of the above regressing.
30
+
3
31
  ## 0.7.8
4
32
 
5
33
  - Adds `AGENTS.md`: install and integration instructions written for coding
data/README.md CHANGED
@@ -102,6 +102,8 @@ Everything is optional — a fresh install works with zero config. In
102
102
  | Option | Default | What it does |
103
103
  | --------------------- | --------------------------- | --------------------------------------------------- |
104
104
  | `authorize_admin` | development only | **Who can read the dashboard.** Override before deploying |
105
+ | `base_controller_class` | `ActionController::Base` | Controller the dashboard inherits — name your admin's and it adopts its layout, helpers and auth |
106
+ | `admin_layout` | the gem's own | Just the shell, if you don't want the whole controller |
105
107
  | `enabled` | everyone | Who can send feedback. `false` hides the widget and rejects posts |
106
108
  | `current_user` | `nil` | Attribute a submission to a user. Receives the request |
107
109
  | `author_label` | the user's `email` | Short label stored and shown in the dashboard |
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ideasbugs
4
+ # Who is asking, which tenant they are in, and the gates that answer both.
5
+ #
6
+ # A concern rather than inherited behaviour because the engine has two
7
+ # controller roots: the public endpoints hang off ActionController::Base, and
8
+ # the dashboard hangs off whatever the host set as `base_controller_class`.
9
+ module RequestContext
10
+ extend ActiveSupport::Concern
11
+
12
+ private
13
+
14
+ def ideasbugs_admin_layout
15
+ Ideasbugs.config.admin_layout
16
+ end
17
+
18
+ def current_author
19
+ return @current_author if defined?(@current_author)
20
+
21
+ @current_author = Ideasbugs.config.current_user.call(request)
22
+ end
23
+
24
+ # The tenant for this request (nil = the single global board). Every read
25
+ # and write scopes to it, so a resolved-tenant admin only ever sees and
26
+ # writes their own tenant's feedback.
27
+ def current_tenant
28
+ return @current_tenant if defined?(@current_tenant)
29
+
30
+ @current_tenant = Ideasbugs.tenant(request)
31
+ end
32
+
33
+ def require_enabled
34
+ head :forbidden unless Ideasbugs.enabled?(request)
35
+ end
36
+
37
+ # Server-side gate for the dashboard. Default: development only.
38
+ def require_admin
39
+ return if Ideasbugs.admin?(request)
40
+
41
+ render plain: 'Forbidden. Set Ideasbugs.config.authorize_admin to grant access.',
42
+ status: :forbidden
43
+ end
44
+
45
+ # Every dashboard query starts here, so an admin can only ever load,
46
+ # triage, or delete feedback in their own tenant — a cross-tenant id 404s.
47
+ def tenant_scope
48
+ Feedback.for_tenant(current_tenant)
49
+ end
50
+ end
51
+ end
@@ -1,28 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ideasbugs
4
+ # Root of the engine's PUBLIC surface: widget.js and the submission endpoint.
5
+ # These stay on a plain ActionController::Base deliberately — someone filing a
6
+ # bug report must not be routed through a host's admin controller, which would
7
+ # demand a staff session for the widget.
8
+ #
9
+ # The dashboard's root is DashboardController, and that is where
10
+ # `config.base_controller_class` applies.
4
11
  class ApplicationController < ActionController::Base
5
- protect_from_forgery with: :exception
6
-
7
- private
8
-
9
- def ideasbugs_admin_layout
10
- Ideasbugs.config.admin_layout
11
- end
12
-
13
- def current_author
14
- return @current_author if defined?(@current_author)
12
+ include RequestContext
15
13
 
16
- @current_author = Ideasbugs.config.current_user.call(request)
17
- end
18
-
19
- # The tenant for this request (nil = the single global board). Every read
20
- # and write scopes to it, so a resolved-tenant admin only ever sees and
21
- # writes their own tenant's feedback.
22
- def current_tenant
23
- return @current_tenant if defined?(@current_tenant)
24
-
25
- @current_tenant = Ideasbugs.tenant(request)
26
- end
14
+ protect_from_forgery with: :exception
27
15
  end
28
16
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ideasbugs
4
+ # Root of the STAFF surface: the triage queue and the screenshot proxy.
5
+ #
6
+ # Inherits from `config.base_controller_class` — by default a plain
7
+ # ActionController::Base, which is why `authorize_admin` exists. Point it at
8
+ # the controller your own admin already inherits from and the dashboard picks
9
+ # up that stack wholesale: your layout, your helpers, your authentication, and
10
+ # whatever request context your before_actions establish.
11
+ #
12
+ # Only the dashboard hangs off it. The widget's endpoint stays on
13
+ # ApplicationController, so wiring an admin base controller here can never
14
+ # demand a staff session from someone filing a report.
15
+ class DashboardController < Ideasbugs.base_controller
16
+ include RequestContext
17
+
18
+ # A host base controller brings its own layout, and declaring one here would
19
+ # override it. So the gem only claims the layout when it owns the decision:
20
+ # no host base controller, or a host that named an `admin_layout` explicitly.
21
+ layout :ideasbugs_admin_layout unless superclass != ActionController::Base &&
22
+ Ideasbugs.config.admin_layout == Configuration::DEFAULT_ADMIN_LAYOUT
23
+
24
+ before_action :require_admin
25
+
26
+ # A host base controller has configured CSRF already; declaring it twice
27
+ # would run the check twice.
28
+ protect_from_forgery with: :exception if superclass == ActionController::Base
29
+ end
30
+ end
@@ -1,27 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ideasbugs
4
- class FeedbacksController < ApplicationController
4
+ class FeedbacksController < DashboardController
5
5
  PER_PAGE = 50
6
6
 
7
- layout :ideasbugs_admin_layout, except: :create
8
-
9
7
  # The widget posts here; everything else is the triage dashboard.
10
- before_action :require_enabled, only: :create
11
- before_action :require_admin, except: :create
12
8
  before_action :set_feedback, only: %i[show update destroy]
13
9
 
14
- # Throttle the public endpoint per IP so one user or bot can't flood the
15
- # table (each submission may carry megabytes of screenshots). Uses the
16
- # rate limiter built into Rails 7.2+ (backed by Rails.cache); on Rails 7.1
17
- # this is a no-op. Tune or disable via config.rate_limit — read once at
18
- # boot, after the host's initializer.
19
- if respond_to?(:rate_limit) && Ideasbugs.config.rate_limit
20
- rate_limit(**Ideasbugs.config.rate_limit,
21
- only: :create,
22
- with: -> { render json: { errors: [t_error(:error_rate_limited)] }, status: :too_many_requests })
23
- end
24
-
25
10
  def index
26
11
  @status = Feedback::STATUSES.include?(params[:status]) ? params[:status] : 'open'
27
12
  @kind = Ideasbugs.config.kinds.map(&:to_s).include?(params[:kind]) ? params[:kind] : nil
@@ -46,23 +31,6 @@ module Ideasbugs
46
31
 
47
32
  def show; end
48
33
 
49
- def create
50
- feedback = Feedback.new(feedback_params)
51
- feedback.user_agent = request.user_agent
52
- feedback.tenant = current_tenant
53
- attribute_author(feedback)
54
-
55
- error = attach_screenshots(feedback)
56
- return render json: { errors: [error] }, status: :unprocessable_entity if error
57
-
58
- if feedback.save
59
- notify_host(feedback)
60
- head :created
61
- else
62
- render json: { errors: feedback.errors.full_messages }, status: :unprocessable_entity
63
- end
64
- end
65
-
66
34
  def update
67
35
  @feedback.update!(params.require(:feedback).permit(:status))
68
36
  redirect_back fallback_location: feedback_path(@feedback), status: :see_other
@@ -75,28 +43,10 @@ module Ideasbugs
75
43
 
76
44
  private
77
45
 
78
- def require_enabled
79
- head :forbidden unless Ideasbugs.enabled?(request)
80
- end
81
-
82
- # Server-side gate for the dashboard. Default: development only.
83
- def require_admin
84
- return if Ideasbugs.admin?(request)
85
-
86
- render plain: 'Forbidden. Set Ideasbugs.config.authorize_admin to grant access.',
87
- status: :forbidden
88
- end
89
-
90
46
  def set_feedback
91
47
  @feedback = tenant_scope.find(params[:id])
92
48
  end
93
49
 
94
- # Every dashboard query starts here, so an admin can only ever load,
95
- # triage, or delete feedback in their own tenant — a cross-tenant id 404s.
96
- def tenant_scope
97
- Feedback.for_tenant(current_tenant)
98
- end
99
-
100
50
  # Case-insensitive match on the free-text columns. LOWER() keeps it
101
51
  # portable across SQLite/PostgreSQL/MySQL, and the explicit ESCAPE makes
102
52
  # the sanitized backslash escapes work on SQLite, which has no default
@@ -109,62 +59,5 @@ module Ideasbugs
109
59
  q: pattern
110
60
  )
111
61
  end
112
-
113
- # The host's hook must never turn a saved submission into a 500 — the
114
- # feedback is in the database; notification failures are the host's logs'
115
- # problem.
116
- def notify_host(feedback)
117
- Ideasbugs.config.on_submit.call(feedback)
118
- rescue StandardError => e
119
- Rails.logger.error("ideasbugs: on_submit hook raised #{e.class}: #{e.message}")
120
- end
121
-
122
- def feedback_params
123
- params.require(:feedback).permit(:kind, :section, :message, :page_url)
124
- end
125
-
126
- def attribute_author(feedback)
127
- author = current_author
128
- return unless author
129
-
130
- feedback.author_id = author.id.to_s if author.respond_to?(:id)
131
- feedback.author_label = Ideasbugs.config.author_label.call(author)
132
- end
133
-
134
- # Validates and attaches uploads. Returns an error message, or nil when
135
- # everything (including "no screenshots at all") is fine.
136
- def attach_screenshots(feedback)
137
- files = Array(params.dig(:feedback, :screenshots)).reject(&:blank?)
138
- return nil if files.empty?
139
- return t_error(:error_save) unless Ideasbugs.config.screenshots_enabled?
140
- return t_error(:error_too_many, count: Ideasbugs.config.max_screenshots) if too_many?(files)
141
- return t_error(:error_too_large, size: max_size_mb) if files.any? { |f| f.size > max_size }
142
- return t_error(:error_save) unless files.all? { |f| f.content_type.to_s.start_with?('image/') }
143
-
144
- feedback.screenshots.attach(files)
145
- nil
146
- end
147
-
148
- def too_many?(files)
149
- files.size > Ideasbugs.config.max_screenshots
150
- end
151
-
152
- def max_size
153
- Ideasbugs.config.max_screenshot_size
154
- end
155
-
156
- def max_size_mb
157
- max_size / (1024 * 1024)
158
- end
159
-
160
- def t_error(key, **args)
161
- defaults = {
162
- error_save: 'Could not send feedback. Please try again.',
163
- error_too_many: 'Too many screenshots (max %{count}).',
164
- error_too_large: 'A screenshot is too large (max %{size} MB).',
165
- error_rate_limited: 'Too many submissions. Please wait a moment and try again.'
166
- }
167
- I18n.t(key, scope: :ideasbugs, default: defaults[key], **args)
168
- end
169
62
  end
170
63
  end
@@ -6,9 +6,7 @@ module Ideasbugs
6
6
  # screenshots can contain anything a user's screen showed, so they must never
7
7
  # be reachable without passing the same gate as the dashboard — regardless of
8
8
  # how the host app configures (or doesn't configure) blob access.
9
- class ScreenshotsController < ApplicationController
10
- before_action :require_admin
11
-
9
+ class ScreenshotsController < DashboardController
12
10
  def show
13
11
  screenshot = Feedback.for_tenant(current_tenant)
14
12
  .find(params[:feedback_id]).screenshots.find(params[:id])
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ideasbugs
4
+ # The widget's write endpoint: POST /feedbacks.
5
+ #
6
+ # Public, so it stays on ApplicationController and never inherits a host's
7
+ # admin base controller — someone filing a bug report must not be asked for a
8
+ # staff session. The triage actions live in FeedbacksController, which does
9
+ # inherit it.
10
+ class SubmissionsController < ApplicationController
11
+ before_action :require_enabled
12
+
13
+ # Throttle the public endpoint per IP so one user or bot can't flood the
14
+ # table (each submission may carry megabytes of screenshots). Uses the
15
+ # rate limiter built into Rails 7.2+ (backed by Rails.cache); on Rails 7.1
16
+ # this is a no-op. Tune or disable via config.rate_limit — read once at
17
+ # boot, after the host's initializer.
18
+ if respond_to?(:rate_limit) && Ideasbugs.config.rate_limit
19
+ rate_limit(**Ideasbugs.config.rate_limit,
20
+ only: :create,
21
+ with: -> { render json: { errors: [t_error(:error_rate_limited)] }, status: :too_many_requests })
22
+ end
23
+
24
+ def create
25
+ feedback = Feedback.new(feedback_params)
26
+ feedback.user_agent = request.user_agent
27
+ feedback.tenant = current_tenant
28
+ attribute_author(feedback)
29
+
30
+ error = attach_screenshots(feedback)
31
+ return render json: { errors: [error] }, status: :unprocessable_entity if error
32
+
33
+ if feedback.save
34
+ notify_host(feedback)
35
+ head :created
36
+ else
37
+ render json: { errors: feedback.errors.full_messages }, status: :unprocessable_entity
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ # The host's hook must never turn a saved submission into a 500 — the
44
+ # feedback is in the database; notification failures are the host's logs'
45
+ # problem.
46
+ def notify_host(feedback)
47
+ Ideasbugs.config.on_submit.call(feedback)
48
+ rescue StandardError => e
49
+ Rails.logger.error("ideasbugs: on_submit hook raised #{e.class}: #{e.message}")
50
+ end
51
+
52
+ def feedback_params
53
+ params.require(:feedback).permit(:kind, :section, :message, :page_url)
54
+ end
55
+
56
+ def attribute_author(feedback)
57
+ author = current_author
58
+ return unless author
59
+
60
+ feedback.author_id = author.id.to_s if author.respond_to?(:id)
61
+ feedback.author_label = Ideasbugs.config.author_label.call(author)
62
+ end
63
+
64
+ # Validates and attaches uploads. Returns an error message, or nil when
65
+ # everything (including "no screenshots at all") is fine.
66
+ def attach_screenshots(feedback)
67
+ files = Array(params.dig(:feedback, :screenshots)).reject(&:blank?)
68
+ return nil if files.empty?
69
+ return t_error(:error_save) unless Ideasbugs.config.screenshots_enabled?
70
+ return t_error(:error_too_many, count: Ideasbugs.config.max_screenshots) if too_many?(files)
71
+ return t_error(:error_too_large, size: max_size_mb) if files.any? { |f| f.size > max_size }
72
+ return t_error(:error_save) unless files.all? { |f| f.content_type.to_s.start_with?('image/') }
73
+
74
+ feedback.screenshots.attach(files)
75
+ nil
76
+ end
77
+
78
+ def too_many?(files)
79
+ files.size > Ideasbugs.config.max_screenshots
80
+ end
81
+
82
+ def max_size_mb
83
+ max_size / (1024 * 1024)
84
+ end
85
+
86
+ def max_size
87
+ Ideasbugs.config.max_screenshot_size
88
+ end
89
+
90
+ def t_error(key, **args)
91
+ defaults = {
92
+ error_save: 'Could not send feedback. Please try again.',
93
+ error_too_many: 'Too many screenshots (max %{count}).',
94
+ error_too_large: 'A screenshot is too large (max %{size} MB).',
95
+ error_rate_limited: 'Too many submissions. Please wait a moment and try again.'
96
+ }
97
+ I18n.t(key, scope: :ideasbugs, default: defaults[key], **args)
98
+ end
99
+ end
100
+ end
@@ -1,99 +1,101 @@
1
1
  <% content_for :body_class, 'ib-index' %>
2
2
 
3
- <h1><%= t('ideasbugs.dashboard.title', default: 'IdeasBugs') %></h1>
3
+ <%= render layout: 'ideasbugs/shared/dashboard' do %>
4
+ <h1><%= t('ideasbugs.dashboard.title', default: 'IdeasBugs') %></h1>
4
5
 
5
- <div class="triage-shell <%= 'has-selected' if @selected_feedback %>">
6
- <aside class="triage-sidebar" aria-label="<%= t('ideasbugs.dashboard.feedback', default: 'Feedback') %>">
7
- <div class="tabs">
8
- <% Ideasbugs::Feedback::STATUSES.each do |status| %>
9
- <%= link_to feedbacks_path(status: status, kind: @kind, q: @query),
10
- class: ('active' if status == @status) do %>
11
- <%= t("ideasbugs.statuses.#{status}", default: status.humanize) %>
12
- <span class="count"><%= @counts.fetch(status, 0) %></span>
6
+ <div class="triage-shell <%= 'has-selected' if @selected_feedback %>">
7
+ <aside class="triage-sidebar" aria-label="<%= t('ideasbugs.dashboard.feedback', default: 'Feedback') %>">
8
+ <div class="tabs">
9
+ <% Ideasbugs::Feedback::STATUSES.each do |status| %>
10
+ <%= link_to feedbacks_path(status: status, kind: @kind, q: @query),
11
+ class: ('active' if status == @status) do %>
12
+ <%= t("ideasbugs.statuses.#{status}", default: status.humanize) %>
13
+ <span class="count"><%= @counts.fetch(status, 0) %></span>
14
+ <% end %>
13
15
  <% end %>
14
- <% end %>
15
- </div>
16
+ </div>
16
17
 
17
- <form class="filters" method="get">
18
- <input type="hidden" name="status" value="<%= @status %>">
19
- <select name="kind" onchange="this.form.submit()">
20
- <option value=""><%= t('ideasbugs.dashboard.all_kinds', default: 'All types') %></option>
21
- <% Ideasbugs.config.kinds.each do |kind| %>
22
- <option value="<%= kind %>" <%= 'selected' if kind.to_s == @kind %>>
23
- <%= t("ideasbugs.kinds.#{kind}", default: kind.to_s.humanize) %>
24
- </option>
25
- <% end %>
26
- </select>
27
- <input type="search" name="q" value="<%= @query %>"
28
- placeholder="<%= t('ideasbugs.dashboard.search_placeholder', default: 'Search message, author, section…') %>"
29
- aria-label="<%= t('ideasbugs.dashboard.search', default: 'Search') %>">
30
- <button><%= t('ideasbugs.dashboard.search', default: 'Search') %></button>
31
- </form>
18
+ <form class="filters" method="get">
19
+ <input type="hidden" name="status" value="<%= @status %>">
20
+ <select name="kind" onchange="this.form.submit()">
21
+ <option value=""><%= t('ideasbugs.dashboard.all_kinds', default: 'All types') %></option>
22
+ <% Ideasbugs.config.kinds.each do |kind| %>
23
+ <option value="<%= kind %>" <%= 'selected' if kind.to_s == @kind %>>
24
+ <%= t("ideasbugs.kinds.#{kind}", default: kind.to_s.humanize) %>
25
+ </option>
26
+ <% end %>
27
+ </select>
28
+ <input type="search" name="q" value="<%= @query %>"
29
+ placeholder="<%= t('ideasbugs.dashboard.search_placeholder', default: 'Search message, author, section…') %>"
30
+ aria-label="<%= t('ideasbugs.dashboard.search', default: 'Search') %>">
31
+ <button><%= t('ideasbugs.dashboard.search', default: 'Search') %></button>
32
+ </form>
32
33
 
33
- <div class="feedback-list">
34
- <% if @feedbacks.any? %>
35
- <% @feedbacks.each do |feedback| %>
36
- <%= link_to feedbacks_path(status: @status, kind: @kind, q: @query, page: @page,
37
- feedback_id: feedback.id),
38
- class: ['feedback-row', ('active' if @selected_feedback&.id == feedback.id)].compact do %>
39
- <span class="feedback-main">
40
- <span class="feedback-topline">
41
- <span class="badge kind-<%= feedback.kind.to_s.parameterize %>">
42
- <%= t("ideasbugs.kinds.#{feedback.kind}", default: feedback.kind.humanize) %>
34
+ <div class="feedback-list">
35
+ <% if @feedbacks.any? %>
36
+ <% @feedbacks.each do |feedback| %>
37
+ <%= link_to feedbacks_path(status: @status, kind: @kind, q: @query, page: @page,
38
+ feedback_id: feedback.id),
39
+ class: ['feedback-row', ('active' if @selected_feedback&.id == feedback.id)].compact do %>
40
+ <span class="feedback-main">
41
+ <span class="feedback-topline">
42
+ <span class="badge kind-<%= feedback.kind.to_s.parameterize %>">
43
+ <%= t("ideasbugs.kinds.#{feedback.kind}", default: feedback.kind.humanize) %>
44
+ </span>
45
+ <span class="badge status-<%= feedback.status %>">
46
+ <%= t("ideasbugs.statuses.#{feedback.status}", default: feedback.status.humanize) %>
47
+ </span>
48
+ <span class="muted feedback-time" title="<%= feedback.created_at %>">
49
+ <%= time_ago_in_words(feedback.created_at) %>
50
+ </span>
43
51
  </span>
44
- <span class="badge status-<%= feedback.status %>">
45
- <%= t("ideasbugs.statuses.#{feedback.status}", default: feedback.status.humanize) %>
52
+ <span class="feedback-message"><%= truncate(feedback.message, length: 120) %></span>
53
+ <span class="muted feedback-meta">
54
+ <span>#<%= feedback.id %></span>
55
+ <% if @show_section && feedback.section.present? %>
56
+ <span class="feedback-section"><%= feedback.section %></span>
57
+ <% end %>
58
+ <% if feedback.author_label.present? %>
59
+ <span><%= feedback.author_label %></span>
60
+ <% end %>
46
61
  </span>
47
- <span class="muted feedback-time" title="<%= feedback.created_at %>">
48
- <%= time_ago_in_words(feedback.created_at) %>
49
- </span>
50
- </span>
51
- <span class="feedback-message"><%= truncate(feedback.message, length: 120) %></span>
52
- <span class="muted feedback-meta">
53
- <span>#<%= feedback.id %></span>
54
- <% if @show_section && feedback.section.present? %>
55
- <span class="feedback-section"><%= feedback.section %></span>
56
- <% end %>
57
- <% if feedback.author_label.present? %>
58
- <span><%= feedback.author_label %></span>
59
- <% end %>
60
62
  </span>
61
- </span>
62
- <% if feedback.screenshots? %>
63
- <span class="muted attachment-count">📎 <%= feedback.screenshots.count %></span>
63
+ <% if feedback.screenshots? %>
64
+ <span class="muted attachment-count">📎 <%= feedback.screenshots.count %></span>
65
+ <% end %>
64
66
  <% end %>
65
67
  <% end %>
68
+ <% else %>
69
+ <div class="empty"><%= t('ideasbugs.dashboard.empty', default: 'Nothing here yet.') %></div>
66
70
  <% end %>
67
- <% else %>
68
- <div class="empty"><%= t('ideasbugs.dashboard.empty', default: 'Nothing here yet.') %></div>
69
- <% end %>
70
- </div>
71
-
72
- <div class="pager">
73
- <% if @page > 1 %>
74
- <%= link_to t('ideasbugs.dashboard.newer', default: '← Newer'),
75
- feedbacks_path(status: @status, kind: @kind, q: @query, page: @page - 1) %>
76
- <% end %>
77
- <% if @more %>
78
- <%= link_to t('ideasbugs.dashboard.older', default: 'Older →'),
79
- feedbacks_path(status: @status, kind: @kind, q: @query, page: @page + 1) %>
80
- <% end %>
81
- </div>
82
- </aside>
71
+ </div>
83
72
 
84
- <main class="triage-detail" aria-label="<%= t('ideasbugs.dashboard.current_feedback', default: 'Current feedback') %>">
85
- <% if @selected_feedback %>
86
- <p class="mobile-back">
87
- <%= link_to t('ideasbugs.dashboard.back', default: '← All feedback'),
88
- feedbacks_path(status: @status, kind: @kind, q: @query, page: @page) %>
89
- </p>
90
- <%= render 'feedback_panel', feedback: @selected_feedback %>
91
- <% else %>
92
- <div class="empty-state">
93
- <h2><%= t('ideasbugs.dashboard.select_feedback', default: 'Select feedback') %></h2>
94
- <p class="muted"><%= t('ideasbugs.dashboard.select_feedback_hint',
95
- default: 'Open an item from the list to triage it without leaving the queue.') %></p>
73
+ <div class="pager">
74
+ <% if @page > 1 %>
75
+ <%= link_to t('ideasbugs.dashboard.newer', default: '← Newer'),
76
+ feedbacks_path(status: @status, kind: @kind, q: @query, page: @page - 1) %>
77
+ <% end %>
78
+ <% if @more %>
79
+ <%= link_to t('ideasbugs.dashboard.older', default: 'Older →'),
80
+ feedbacks_path(status: @status, kind: @kind, q: @query, page: @page + 1) %>
81
+ <% end %>
96
82
  </div>
97
- <% end %>
98
- </main>
99
- </div>
83
+ </aside>
84
+
85
+ <main class="triage-detail" aria-label="<%= t('ideasbugs.dashboard.current_feedback', default: 'Current feedback') %>">
86
+ <% if @selected_feedback %>
87
+ <p class="mobile-back">
88
+ <%= link_to t('ideasbugs.dashboard.back', default: '← All feedback'),
89
+ feedbacks_path(status: @status, kind: @kind, q: @query, page: @page) %>
90
+ </p>
91
+ <%= render 'feedback_panel', feedback: @selected_feedback %>
92
+ <% else %>
93
+ <div class="empty-state">
94
+ <h2><%= t('ideasbugs.dashboard.select_feedback', default: 'Select feedback') %></h2>
95
+ <p class="muted"><%= t('ideasbugs.dashboard.select_feedback_hint',
96
+ default: 'Open an item from the list to triage it without leaving the queue.') %></p>
97
+ </div>
98
+ <% end %>
99
+ </main>
100
+ </div>
101
+ <% end %>
@@ -1,8 +1,10 @@
1
1
  <% content_for :body_class, 'ib-show' %>
2
2
 
3
- <div class="breadcrumb">
4
- <%= link_to t('ideasbugs.dashboard.title', default: 'IdeasBugs'), root_path %>
5
- / #<%= @feedback.id %>
6
- </div>
3
+ <%= render layout: 'ideasbugs/shared/dashboard' do %>
4
+ <div class="breadcrumb">
5
+ <%= link_to t('ideasbugs.dashboard.title', default: 'IdeasBugs'), root_path %>
6
+ / #<%= @feedback.id %>
7
+ </div>
7
8
 
8
- <%= render 'feedback_panel', feedback: @feedback %>
9
+ <%= render 'feedback_panel', feedback: @feedback %>
10
+ <% end %>
@@ -0,0 +1,13 @@
1
+ <%# The dashboard's own shell, rendered by the views rather than the layout.
2
+ That is deliberate: `config.admin_layout` and `config.base_controller_class`
3
+ both let a host replace the layout, and a stylesheet declared in a layout the
4
+ host replaces simply vanishes — the dashboard then renders unstyled.
5
+ Declaring it here means every layout works, host or gem.
6
+
7
+ The `ib-dashboard` wrapper is what scopes dashboard.css (see the comment at
8
+ the top of that file), so it has to enclose the content, not precede it. %>
9
+ <%= stylesheet_link_tag "#{dashboard_stylesheet_path}?v=#{Ideasbugs::Widget.dashboard_stylesheet_fingerprint}",
10
+ 'data-turbo-track': 'reload' %>
11
+ <div class="ib-dashboard">
12
+ <%= yield %>
13
+ </div>
@@ -5,12 +5,15 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <%= csrf_meta_tags %>
7
7
  <%= csp_meta_tag %>
8
- <%= stylesheet_link_tag "#{dashboard_stylesheet_path}?v=#{Ideasbugs::Widget.dashboard_stylesheet_fingerprint}",
9
- 'data-turbo-track': 'reload' %>
8
+ <%# The dashboard's stylesheet is declared by the views
9
+ (ideasbugs/shared/_dashboard), not here, so it survives a host replacing
10
+ this layout via config.admin_layout or config.base_controller_class. %>
10
11
  </head>
11
- <body class="<%= content_for?(:body_class) ? yield(:body_class) : '' %>">
12
- <div class="container">
13
- <%= yield %>
12
+ <body class="ib-page <%= content_for?(:body_class) ? yield(:body_class) : '' %>">
13
+ <div class="ib-dashboard">
14
+ <div class="container">
15
+ <%= yield %>
16
+ </div>
14
17
  </div>
15
18
  </body>
16
19
  </html>
data/config/routes.rb CHANGED
@@ -5,8 +5,12 @@ Ideasbugs::Engine.routes.draw do
5
5
  get 'widget.js', to: 'widgets#show', as: :widget
6
6
  get 'dashboard.css', to: 'widgets#dashboard_stylesheet', as: :dashboard_stylesheet
7
7
 
8
- # create is the public widget endpoint; the rest is the triage dashboard.
9
- resources :feedbacks, only: %i[create index show update destroy] do
8
+ # POST goes to SubmissionsController, not to this resource: the widget's write
9
+ # endpoint is public and the rest is staff-only, and only the staff half
10
+ # inherits config.base_controller_class. Sharing one controller would put a
11
+ # host's admin authentication in front of someone filing a report.
12
+ post 'feedbacks', to: 'submissions#create', as: :submissions
13
+ resources :feedbacks, only: %i[index show update destroy] do
10
14
  # Screenshots stream through the dashboard's own gate, never via public
11
15
  # Active Storage blob URLs.
12
16
  resources :screenshots, only: :show
@@ -2,11 +2,13 @@
2
2
 
3
3
  require 'rails/generators'
4
4
  require 'rails/generators/active_record'
5
+ require_relative '../migration_helpers'
5
6
 
6
7
  module Ideasbugs
7
8
  module Generators
8
9
  class InstallGenerator < Rails::Generators::Base
9
10
  include ActiveRecord::Generators::Migration
11
+ include MigrationHelpers
10
12
 
11
13
  source_root File.expand_path('templates', __dir__)
12
14
 
@@ -31,12 +33,6 @@ module Ideasbugs
31
33
  say 'Optional: run `bin/rails ideasbugs:seed_demo` for sample feedback.'
32
34
  say "Browse submissions at /feedback (development only until you set config.authorize_admin).\n"
33
35
  end
34
-
35
- private
36
-
37
- def migration_version
38
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
39
- end
40
36
  end
41
37
  end
42
38
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  class CreateIdeasbugsFeedbacks < ActiveRecord::Migration<%= migration_version %>
4
4
  def change
5
- create_table :ideasbugs_feedbacks do |t|
5
+ create_table :ideasbugs_feedbacks<%= primary_key_type_option %> do |t|
6
6
  t.string :kind, null: false, default: 'other'
7
7
  t.string :section
8
8
  t.text :message, null: false
@@ -11,6 +11,14 @@ Ideasbugs.configure do |config|
11
11
  #
12
12
  # Render the dashboard inside your app's admin layout. Default: the gem's
13
13
  # standalone dashboard layout.
14
+ # Two ways to put the dashboard inside an admin you already have.
15
+ #
16
+ # The whole stack — your layout, helpers, authentication and any request
17
+ # context your before_actions set up. Only the dashboard inherits it; the
18
+ # widget's endpoint stays public, so this cannot gate a bug report.
19
+ # config.base_controller_class = "Admin::BaseController"
20
+ #
21
+ # Or the layout alone:
14
22
  # config.admin_layout = "admin/application"
15
23
 
16
24
  # Attribute feedback to a user (optional). Return an object responding to
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ideasbugs
4
+ module Generators
5
+ # Shared bits every migration-writing generator needs.
6
+ module MigrationHelpers
7
+ private
8
+
9
+ def migration_version
10
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
11
+ end
12
+
13
+ # Follow the host's own key type instead of forcing bigint. An app that
14
+ # keys its models with uuids has a uuid
15
+ # `active_storage_attachments.record_id`, so a bigint table here could
16
+ # never take a screenshot: the foreign key has nowhere to point and
17
+ # `attach` raises NotNullViolation.
18
+ #
19
+ # Same lookup Rails' own Active Storage, Action Text and Action Mailbox
20
+ # migrations do, so a host that set it once gets consistent tables from
21
+ # all of them.
22
+ #
23
+ # Rendered as a `create_table` option rather than a bare value, because a
24
+ # template is expanded at generate time: emitting the method name would
25
+ # put `id: primary_key_type` in the migration, where nothing defines it.
26
+ def primary_key_type_option
27
+ config = Rails.configuration.generators
28
+ type = config.options[config.orm][:primary_key_type]
29
+ type ? ", id: :#{type}" : ''
30
+ end
31
+ end
32
+ end
33
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'rails/generators'
4
4
  require 'rails/generators/active_record'
5
+ require_relative '../migration_helpers'
5
6
 
6
7
  module Ideasbugs
7
8
  module Generators
@@ -14,6 +15,7 @@ module Ideasbugs
14
15
  # bin/rails generate ideasbugs:tenant && bin/rails db:migrate
15
16
  class TenantGenerator < Rails::Generators::Base
16
17
  include ActiveRecord::Generators::Migration
18
+ include MigrationHelpers
17
19
 
18
20
  source_root File.expand_path('templates', __dir__)
19
21
 
@@ -28,12 +30,6 @@ module Ideasbugs
28
30
  say "\ntenant column queued. Run `rails db:migrate`, then set", :green
29
31
  say 'config.tenant in config/initializers/ideasbugs.rb to scope per tenant.'
30
32
  end
31
-
32
- private
33
-
34
- def migration_version
35
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
36
- end
37
33
  end
38
34
  end
39
35
  end
@@ -5,6 +5,10 @@ module Ideasbugs
5
5
  # works with zero configuration; the hooks below let an app decide who can
6
6
  # send feedback, who can read it, and how submissions are attributed.
7
7
  class Configuration
8
+ # The gem's own dashboard layout. Compared against, so DashboardController
9
+ # can tell "the host left this alone" from "the host chose this".
10
+ DEFAULT_ADMIN_LAYOUT = 'ideasbugs/application'
11
+
8
12
  # Per-request gate for the widget and the submission endpoint. Return false
9
13
  # to hide the widget and reject submissions for this request. Receives the
10
14
  # request. Defaults to everyone — feedback collection is meant for real
@@ -20,6 +24,21 @@ module Ideasbugs
20
24
  # inside your app's admin shell, e.g. "admin/application".
21
25
  attr_accessor :admin_layout
22
26
 
27
+ # The controller the DASHBOARD inherits from, as a String so it resolves
28
+ # lazily rather than at config time. Default: a plain
29
+ # 'ActionController::Base', where `authorize_admin` is the only gate.
30
+ #
31
+ # Name the controller your own admin already inherits from and the dashboard
32
+ # adopts that whole stack — layout, helpers, authentication, and any request
33
+ # context your before_actions set up. `admin_layout` covers only the layout,
34
+ # which leaves a host layout calling its own helpers to raise NameError under
35
+ # the engine's isolated namespace.
36
+ #
37
+ # Only the dashboard uses it. The widget's endpoint stays on the engine's own
38
+ # public controller, so an admin base controller here can never demand a
39
+ # staff session from someone filing a report.
40
+ attr_accessor :base_controller_class
41
+
23
42
  # Resolve the current user for attribution (optional). Return an object
24
43
  # responding to #id, or nil. Receives the request.
25
44
  attr_accessor :current_user
@@ -83,7 +102,8 @@ module Ideasbugs
83
102
  def initialize
84
103
  @enabled = ->(_request) { true }
85
104
  @authorize_admin = ->(_request) { Rails.env.development? }
86
- @admin_layout = 'ideasbugs/application'
105
+ @admin_layout = DEFAULT_ADMIN_LAYOUT
106
+ @base_controller_class = 'ActionController::Base'
87
107
  @current_user = ->(_request) {}
88
108
  @tenant = ->(_request) {}
89
109
  @author_label = ->(user) { user.respond_to?(:email) ? user.email : user&.to_s }
@@ -1,143 +1,164 @@
1
- :root {
1
+ /* Dashboard styles.
2
+ *
3
+ * Three layers, and the split is what lets a host replace the layout:
4
+ *
5
+ * :root custom properties, all `--ib-` prefixed so they can
6
+ * neither overwrite a host's nor be overwritten by them.
7
+ * .ib-page the page frame (was bare html/body). Only the gem's own
8
+ * layout puts this class on <body>, so inside a host admin
9
+ * these rules simply do not apply.
10
+ * .ib-dashboard everything else, nested. Names like .container, .card
11
+ * and .tabs are confined to the dashboard's own markup, so
12
+ * loading this file inside a host cannot restyle its chrome.
13
+ *
14
+ * Nothing here styles a bare `body`, `a`, `table` or `*` at the top level.
15
+ */
16
+
17
+
18
+ :root {
2
19
  color-scheme: light dark;
3
- --bg: #f6f7f9; --surface: #fff; --text: #1c2024; --muted: #6b7280;
4
- --border: #e5e7eb; --accent: #2563eb; --accent-text: #fff;
5
- --bug: #dc2626; --feature: #16a34a; --other: #6b7280;
6
- --open: #2563eb; --in_review: #d97706; --resolved: #16a34a;
7
- }
8
- @media (prefers-color-scheme: dark) {
9
- :root {
10
- --bg: #111418; --surface: #1a1f26; --text: #e6e8ea; --muted: #9aa2ab;
11
- --border: #2a313a; --accent: #3b82f6;
12
- }
13
- }
14
- * { box-sizing: border-box; }
15
- body { margin: 0; background: var(--bg); color: var(--text);
16
- font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
17
- a { color: var(--accent); text-decoration: none; }
18
- a:hover { text-decoration: underline; }
19
- .container { max-width: 960px; margin: 0 auto; padding: 24px 16px 64px; }
20
- h1 { font-size: 22px; margin: 0 0 16px; }
21
- .tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 16px; flex-wrap: wrap; }
22
- .tabs a { padding: 8px 14px; border-radius: 8px 8px 0 0; color: var(--muted); }
23
- .tabs a.active { color: var(--text); font-weight: 600; border: 1px solid var(--border);
24
- border-bottom: 2px solid var(--surface); background: var(--surface); margin-bottom: -1px; }
25
- .tabs a:hover { text-decoration: none; color: var(--text); }
26
- .count { display: inline-block; min-width: 20px; padding: 0 6px; margin-left: 4px; border-radius: 999px;
27
- background: var(--border); font-size: 12px; text-align: center; }
28
- .filters { margin-bottom: 16px; display: flex; gap: 8px; align-items: center; }
29
- .filters select, .filters input[type=search] { padding: 6px 8px; border: 1px solid var(--border);
30
- border-radius: 8px; background: var(--surface); color: var(--text); font: inherit; }
31
- .filters input[type=search] { flex: 1; max-width: 320px; }
32
- .card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
33
- .card.pad { padding: 16px; }
34
- table { width: 100%; border-collapse: collapse; }
35
- th, td { padding: 10px 14px; text-align: left; vertical-align: top; }
36
- th { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted);
37
- border-bottom: 1px solid var(--border); }
38
- tr + tr td { border-top: 1px solid var(--border); }
39
- .badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600;
40
- color: #fff; white-space: nowrap; }
41
- .badge.kind-bug { background: var(--bug); }
42
- .badge.kind-feature { background: var(--feature); }
43
- .badge.kind-other, .badge.kind-default { background: var(--other); }
44
- .badge.status-open { background: var(--open); }
45
- .badge.status-in_review { background: var(--in_review); }
46
- .badge.status-resolved { background: var(--resolved); }
47
- .muted { color: var(--muted); font-size: 13px; }
48
- .pager { margin-top: 16px; display: flex; gap: 16px; }
49
- .actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 16px 0; }
50
- .actions form { display: inline; }
51
- button, .button { padding: 8px 14px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer;
52
- background: var(--surface); color: var(--text); font: inherit; }
53
- button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
54
- button.danger { color: var(--bug); }
55
- .message { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
56
- dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; }
57
- dt { color: var(--muted); }
58
- dd { margin: 0; overflow-wrap: anywhere; }
59
- .shots { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 8px; }
60
- .shots img { max-width: 280px; max-height: 200px; border: 1px solid var(--border); border-radius: 8px; }
61
- .empty { padding: 48px 16px; text-align: center; color: var(--muted); }
62
- .breadcrumb { margin-bottom: 12px; font-size: 13px; }
63
- .case-id { color: var(--muted); font-weight: 400; font-size: 15px; }
64
- .panel-head { flex: 0 0 auto; }
65
- .panel-head h1 { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
66
- .detail-scroll { display: flex; flex-direction: column; gap: 12px; min-height: 0; }
67
- .feedback-panel { min-width: 0; display: flex; flex-direction: column; }
68
- .feedback-panel .actions { flex: 0 0 auto; }
69
- .feedback-panel .detail-scroll > .actions { margin: 0; }
20
+ --ib-bg: #f6f7f9; --ib-surface: #fff; --ib-text: #1c2024; --ib-muted: #6b7280;
21
+ --ib-border: #e5e7eb; --ib-accent: #2563eb; --ib-accent-text: #fff;
22
+ --ib-bug: #dc2626; --ib-feature: #16a34a; --ib-other: #6b7280;
23
+ --ib-open: #2563eb; --in_review: #d97706; --ib-resolved: #16a34a;
24
+ }
25
+ @media (prefers-color-scheme: dark) {
26
+ :root {
27
+ --ib-bg: #111418; --ib-surface: #1a1f26; --ib-text: #e6e8ea; --ib-muted: #9aa2ab;
28
+ --ib-border: #2a313a; --ib-accent: #3b82f6;
29
+ }
30
+ }
31
+ @media (max-width: 760px) {
32
+ .ib-page { font-size: 14px; }
33
+ .ib-index, .ib-show { overflow-x: hidden; }
34
+ .ib-index { overflow-y: auto; overflow-x: hidden; }
35
+ .ib-index, .ib-index .container { min-height: 100vh; height: auto; }
36
+ .ib-index .container { padding-bottom: 10px; }
37
+ .ib-dashboard {
38
+ & .container { padding: 14px 10px 24px; }
39
+ & .feedback-panel,
40
+ & .detail-scroll,
41
+ & .card,
42
+ & dl,
43
+ & dd { min-width: 0; max-width: 100%; }
44
+ & .feedback-panel .card { width: calc(100vw - 20px); }
45
+ & .message,
46
+ & dd,
47
+ & .muted { max-width: calc(100vw - 54px); }
48
+ & .triage-shell { display: block; min-height: calc(100vh - 62px); }
49
+ & .triage-sidebar,
50
+ & .triage-detail { min-height: calc(100vh - 62px); }
51
+ & .triage-shell.has-selected .triage-sidebar { display: none; }
52
+ & .triage-shell:not(.has-selected) .triage-detail { display: none; }
53
+ & .feedback-list { overflow: visible; }
54
+ & .feedback-row { padding: 12px; }
55
+ & .feedback-meta { display: none; }
56
+ & .triage-detail .feedback-panel { min-height: calc(100vh - 64px); }
57
+ & .mobile-back { display: block; flex: 0 0 auto; margin: 10px 10px 0; font-size: 13px; }
58
+ & .panel-head h1 { font-size: 18px; }
59
+ & dl { grid-template-columns: 1fr; gap: 3px; }
60
+ }
61
+ }
70
62
 
71
- /* Desktop triage: list on the left, selected feedback on the right. */
72
- .ib-index, .ib-index .container { height: 100vh; height: 100dvh; }
73
- .ib-index { overflow: hidden; }
74
- .ib-index .container { max-width: 1280px; display: flex; flex-direction: column; padding-bottom: 16px; }
75
- .ib-index .container > h1 { flex: 0 0 auto; margin-bottom: 14px; }
76
- .triage-shell { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(320px, 400px) 1fr;
77
- gap: 16px; }
78
- .triage-sidebar { min-width: 0; min-height: 0; display: flex; flex-direction: column;
79
- background: var(--surface); border: 1px solid var(--border);
80
- border-radius: 8px; overflow: hidden; }
81
- .triage-sidebar .tabs { flex: 0 0 auto; margin: 12px 12px 0; padding: 0; background: transparent;
82
- border: 0; border-radius: 0; gap: 6px; flex-wrap: nowrap; }
83
- .triage-sidebar .tabs a { flex: 1 1 0; position: relative; display: flex; align-items: center;
84
- justify-content: center; gap: 6px; min-height: 34px; padding: 5px 10px 8px;
85
- border-radius: 8px; color: var(--muted); font-weight: 600; }
86
- .triage-sidebar .tabs a.active { color: var(--text); border: 0; background: transparent; box-shadow: none; margin: 0; }
87
- .triage-sidebar .tabs a.active::after { content: ""; position: absolute; left: 20px; right: 20px; bottom: 0;
88
- height: 2px; border-radius: 999px; background: var(--accent); }
89
- .triage-sidebar .tabs a:not(.active):hover { text-decoration: none; color: var(--text); background: var(--bg); }
90
- .triage-sidebar .tabs .count { margin-left: 0; background: color-mix(in srgb, var(--muted) 14%, transparent); }
91
- .triage-sidebar .tabs a.active .count { background: var(--border); }
92
- .triage-sidebar .filters { flex: 0 0 auto; display: grid; grid-template-columns: minmax(0, 1fr) auto;
93
- margin: 0; padding: 12px; border-bottom: 1px solid var(--border); }
94
- .triage-sidebar .filters select { grid-column: 1 / -1; width: 100%; }
95
- .triage-sidebar .filters input[type=search] { min-width: 0; max-width: none; }
96
- .feedback-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
97
- .feedback-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 13px 14px;
98
- color: var(--text); border-bottom: 1px solid var(--border); }
99
- .feedback-row:hover { text-decoration: none; background: var(--bg); }
100
- .feedback-row.active { background: color-mix(in srgb, var(--accent) 9%, var(--surface));
101
- box-shadow: inset 3px 0 0 var(--accent); }
102
- .feedback-main { min-width: 0; display: flex; flex-direction: column; gap: 5px; }
103
- .feedback-topline, .feedback-meta { display: flex; align-items: center; gap: 8px; min-width: 0; }
104
- .feedback-message, .feedback-meta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
105
- .feedback-message { display: block; font-weight: 600; }
106
- .feedback-time { margin-left: auto; white-space: nowrap; }
107
- .attachment-count { align-self: start; white-space: nowrap; }
108
- .triage-sidebar .pager { flex: 0 0 auto; margin: 0; padding: 10px 12px; border-top: 1px solid var(--border); }
109
- .triage-detail { min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
110
- .triage-detail .feedback-panel { flex: 1 1 auto; min-height: 0; }
111
- .triage-detail .detail-scroll { flex: 1 1 auto; overflow-y: auto; padding-right: 2px; }
112
- .triage-detail .actions { margin-bottom: 0; }
113
- .empty-state { margin: auto; max-width: 320px; padding: 24px; text-align: center; }
114
- .empty-state h2 { margin: 0 0 6px; font-size: 18px; }
115
- .empty-state p { margin: 0; }
116
- .mobile-back { display: none; }
63
+ /* Page frame the gem's own layout only. */
64
+ .ib-page { margin: 0; background: var(--ib-bg); color: var(--ib-text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
65
+ .ib-index { overflow: hidden; }
66
+ .ib-index .container { max-width: 1280px; display: flex; flex-direction: column; padding-bottom: 16px; }
67
+ .ib-index .container > h1 { flex: 0 0 auto; margin-bottom: 14px; }
68
+ .ib-show .feedback-panel { gap: 0; }
69
+ .ib-show .detail-scroll { overflow: visible; }
117
70
 
118
- /* Standalone show pages keep normal page scrolling. */
71
+ /* Dashboard components any layout. */
72
+ .ib-dashboard {
73
+ & * { box-sizing: border-box; }
74
+ & a { color: var(--ib-accent); text-decoration: none; }
75
+ & a:hover { text-decoration: underline; }
76
+ & .container { max-width: 960px; margin: 0 auto; padding: 24px 16px 64px; }
77
+ & h1 { font-size: 22px; margin: 0 0 16px; }
78
+ & .tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--ib-border); margin-bottom: 16px; flex-wrap: wrap; }
79
+ & .tabs a { padding: 8px 14px; border-radius: 8px 8px 0 0; color: var(--ib-muted); }
80
+ & .tabs a.active { color: var(--ib-text); font-weight: 600; border: 1px solid var(--ib-border); border-bottom: 2px solid var(--ib-surface); background: var(--ib-surface); margin-bottom: -1px; }
81
+ & .tabs a:hover { text-decoration: none; color: var(--ib-text); }
82
+ & .count { display: inline-block; min-width: 20px; padding: 0 6px; margin-left: 4px; border-radius: 999px; background: var(--ib-border); font-size: 12px; text-align: center; }
83
+ & .filters { margin-bottom: 16px; display: flex; gap: 8px; align-items: center; }
84
+ & .filters select,
85
+ & .filters input[type=search] { padding: 6px 8px; border: 1px solid var(--ib-border); border-radius: 8px; background: var(--ib-surface); color: var(--ib-text); font: inherit; }
86
+ & .filters input[type=search] { flex: 1; max-width: 320px; }
87
+ & .card { background: var(--ib-surface); border: 1px solid var(--ib-border); border-radius: 12px; overflow: hidden; }
88
+ & .card.pad { padding: 16px; }
89
+ & table { width: 100%; border-collapse: collapse; }
90
+ & th,
91
+ & td { padding: 10px 14px; text-align: left; vertical-align: top; }
92
+ & th { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--ib-muted); border-bottom: 1px solid var(--ib-border); }
93
+ & tr + tr td { border-top: 1px solid var(--ib-border); }
94
+ & .badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; color: #fff; white-space: nowrap; }
95
+ & .badge.kind-bug { background: var(--ib-bug); }
96
+ & .badge.kind-feature { background: var(--ib-feature); }
97
+ & .badge.kind-other,
98
+ & .badge.kind-default { background: var(--ib-other); }
99
+ & .badge.status-open { background: var(--ib-open); }
100
+ & .badge.status-in_review { background: var(--in_review); }
101
+ & .badge.status-resolved { background: var(--ib-resolved); }
102
+ & .muted { color: var(--ib-muted); font-size: 13px; }
103
+ & .pager { margin-top: 16px; display: flex; gap: 16px; }
104
+ & .actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 16px 0; }
105
+ & .actions form { display: inline; }
106
+ & button,
107
+ & .button { padding: 8px 14px; border: 1px solid var(--ib-border); border-radius: 8px; cursor: pointer; background: var(--ib-surface); color: var(--ib-text); font: inherit; }
108
+ & button.primary { background: var(--ib-accent); border-color: var(--ib-accent); color: var(--ib-accent-text); }
109
+ & button.danger { color: var(--ib-bug); }
110
+ & .message { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
111
+ & dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; }
112
+ & dt { color: var(--ib-muted); }
113
+ & dd { margin: 0; overflow-wrap: anywhere; }
114
+ & .shots { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 8px; }
115
+ & .shots img { max-width: 280px; max-height: 200px; border: 1px solid var(--ib-border); border-radius: 8px; }
116
+ & .empty { padding: 48px 16px; text-align: center; color: var(--ib-muted); }
117
+ & .breadcrumb { margin-bottom: 12px; font-size: 13px; }
118
+ & .case-id { color: var(--ib-muted); font-weight: 400; font-size: 15px; }
119
+ & .panel-head { flex: 0 0 auto; }
120
+ & .panel-head h1 { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
121
+ & .detail-scroll { display: flex; flex-direction: column; gap: 12px; min-height: 0; }
122
+ & .feedback-panel { min-width: 0; display: flex; flex-direction: column; }
123
+ & .feedback-panel .actions { flex: 0 0 auto; }
124
+ & .feedback-panel .detail-scroll > .actions { margin: 0; }
125
+ & /* Desktop triage: list on the left,
126
+ & selected feedback on the right. */
127
+ .ib-index,
128
+ & .ib-index .container { height: 100vh; height: 100dvh; }
129
+ & .triage-shell { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(320px, 400px) 1fr; gap: 16px; }
130
+ & .triage-sidebar { min-width: 0; min-height: 0; display: flex; flex-direction: column; background: var(--ib-surface); border: 1px solid var(--ib-border); border-radius: 8px; overflow: hidden; }
131
+ & .triage-sidebar .tabs { flex: 0 0 auto; margin: 12px 12px 0; padding: 0; background: transparent; border: 0; border-radius: 0; gap: 6px; flex-wrap: nowrap; }
132
+ & .triage-sidebar .tabs a { flex: 1 1 0; position: relative; display: flex; align-items: center; justify-content: center; gap: 6px; min-height: 34px; padding: 5px 10px 8px; border-radius: 8px; color: var(--ib-muted); font-weight: 600; }
133
+ & .triage-sidebar .tabs a.active { color: var(--ib-text); border: 0; background: transparent; box-shadow: none; margin: 0; }
134
+ & .triage-sidebar .tabs a.active::after { content: ""; position: absolute; left: 20px; right: 20px; bottom: 0; height: 2px; border-radius: 999px; background: var(--ib-accent); }
135
+ & .triage-sidebar .tabs a:not(.active):hover { text-decoration: none; color: var(--ib-text); background: var(--ib-bg); }
136
+ & .triage-sidebar .tabs .count { margin-left: 0; background: color-mix(in srgb, var(--ib-muted) 14%, transparent); }
137
+ & .triage-sidebar .tabs a.active .count { background: var(--ib-border); }
138
+ & .triage-sidebar .filters { flex: 0 0 auto; display: grid; grid-template-columns: minmax(0, 1fr) auto; margin: 0; padding: 12px; border-bottom: 1px solid var(--ib-border); }
139
+ & .triage-sidebar .filters select { grid-column: 1 / -1; width: 100%; }
140
+ & .triage-sidebar .filters input[type=search] { min-width: 0; max-width: none; }
141
+ & .feedback-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
142
+ & .feedback-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 13px 14px; color: var(--ib-text); border-bottom: 1px solid var(--ib-border); }
143
+ & .feedback-row:hover { text-decoration: none; background: var(--ib-bg); }
144
+ & .feedback-row.active { background: color-mix(in srgb, var(--ib-accent) 9%, var(--ib-surface)); box-shadow: inset 3px 0 0 var(--ib-accent); }
145
+ & .feedback-main { min-width: 0; display: flex; flex-direction: column; gap: 5px; }
146
+ & .feedback-topline,
147
+ & .feedback-meta { display: flex; align-items: center; gap: 8px; min-width: 0; }
148
+ & .feedback-message,
149
+ & .feedback-meta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
150
+ & .feedback-message { display: block; font-weight: 600; }
151
+ & .feedback-time { margin-left: auto; white-space: nowrap; }
152
+ & .attachment-count { align-self: start; white-space: nowrap; }
153
+ & .triage-sidebar .pager { flex: 0 0 auto; margin: 0; padding: 10px 12px; border-top: 1px solid var(--ib-border); }
154
+ & .triage-detail { min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
155
+ & .triage-detail .feedback-panel { flex: 1 1 auto; min-height: 0; }
156
+ & .triage-detail .detail-scroll { flex: 1 1 auto; overflow-y: auto; padding-right: 2px; }
157
+ & .triage-detail .actions { margin-bottom: 0; }
158
+ & .empty-state { margin: auto; max-width: 320px; padding: 24px; text-align: center; }
159
+ & .empty-state h2 { margin: 0 0 6px; font-size: 18px; }
160
+ & .empty-state p { margin: 0; }
161
+ & .mobile-back { display: none; }
162
+ & /* Standalone show pages keep normal page scrolling. */
119
163
  .ib-show { min-height: 100vh; overflow: auto; }
120
- .ib-show .feedback-panel { gap: 0; }
121
- .ib-show .detail-scroll { overflow: visible; }
122
- @media (max-width: 760px) {
123
- body { font-size: 14px; }
124
- .container { padding: 14px 10px 24px; }
125
- .ib-index, .ib-show { overflow-x: hidden; }
126
- .feedback-panel, .detail-scroll, .card, dl, dd { min-width: 0; max-width: 100%; }
127
- .feedback-panel .card { width: calc(100vw - 20px); }
128
- .message, dd, .muted { max-width: calc(100vw - 54px); }
129
- .ib-index { overflow-y: auto; overflow-x: hidden; }
130
- .ib-index, .ib-index .container { min-height: 100vh; height: auto; }
131
- .ib-index .container { padding-bottom: 10px; }
132
- .triage-shell { display: block; min-height: calc(100vh - 62px); }
133
- .triage-sidebar, .triage-detail { min-height: calc(100vh - 62px); }
134
- .triage-shell.has-selected .triage-sidebar { display: none; }
135
- .triage-shell:not(.has-selected) .triage-detail { display: none; }
136
- .feedback-list { overflow: visible; }
137
- .feedback-row { padding: 12px; }
138
- .feedback-meta { display: none; }
139
- .triage-detail .feedback-panel { min-height: calc(100vh - 64px); }
140
- .mobile-back { display: block; flex: 0 0 auto; margin: 10px 10px 0; font-size: 13px; }
141
- .panel-head h1 { font-size: 18px; }
142
- dl { grid-template-columns: 1fr; gap: 3px; }
143
- }
164
+ }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ideasbugs
4
- VERSION = '0.7.8'
4
+ VERSION = '0.8.0'
5
5
  end
data/lib/ideasbugs.rb CHANGED
@@ -29,6 +29,13 @@ module Ideasbugs
29
29
 
30
30
  # Can this request browse and triage feedback? Checked by every dashboard
31
31
  # action.
32
+ # The class Ideasbugs::DashboardController inherits from. Resolved on every
33
+ # call rather than memoized, so a host that reassigns base_controller_class
34
+ # in a reloadable initializer is not pinned to a stale, unloaded constant.
35
+ def base_controller
36
+ config.base_controller_class.to_s.constantize
37
+ end
38
+
32
39
  def admin?(request)
33
40
  !!config.authorize_admin.call(request)
34
41
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ideasbugs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.8
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov
@@ -39,9 +39,12 @@ files:
39
39
  - MIT-LICENSE
40
40
  - README.md
41
41
  - Rakefile
42
+ - app/controllers/concerns/ideasbugs/request_context.rb
42
43
  - app/controllers/ideasbugs/application_controller.rb
44
+ - app/controllers/ideasbugs/dashboard_controller.rb
43
45
  - app/controllers/ideasbugs/feedbacks_controller.rb
44
46
  - app/controllers/ideasbugs/screenshots_controller.rb
47
+ - app/controllers/ideasbugs/submissions_controller.rb
45
48
  - app/controllers/ideasbugs/widgets_controller.rb
46
49
  - app/helpers/ideasbugs/widget_helper.rb
47
50
  - app/models/ideasbugs/application_record.rb
@@ -49,6 +52,7 @@ files:
49
52
  - app/views/ideasbugs/feedbacks/_feedback_panel.html.erb
50
53
  - app/views/ideasbugs/feedbacks/index.html.erb
51
54
  - app/views/ideasbugs/feedbacks/show.html.erb
55
+ - app/views/ideasbugs/shared/_dashboard.html.erb
52
56
  - app/views/layouts/ideasbugs/application.html.erb
53
57
  - config/locales/ideasbugs.ar.yml
54
58
  - config/locales/ideasbugs.bg.yml
@@ -80,6 +84,7 @@ files:
80
84
  - lib/generators/ideasbugs/install/install_generator.rb
81
85
  - lib/generators/ideasbugs/install/templates/create_ideasbugs_feedbacks.rb.tt
82
86
  - lib/generators/ideasbugs/install/templates/initializer.rb
87
+ - lib/generators/ideasbugs/migration_helpers.rb
83
88
  - lib/generators/ideasbugs/tenant/templates/add_tenant_to_ideasbugs_feedbacks.rb.tt
84
89
  - lib/generators/ideasbugs/tenant/tenant_generator.rb
85
90
  - lib/ideasbugs.rb