context_drop 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +21 -0
  3. data/README.md +337 -0
  4. data/Rakefile +20 -0
  5. data/SPEC.md +532 -0
  6. data/app/assets/javascripts/context_drop/context_drop.js +3508 -0
  7. data/app/assets/stylesheets/context_drop/application.css +2 -0
  8. data/app/controllers/context_drop/application_controller.rb +114 -0
  9. data/app/controllers/context_drop/attachments_controller.rb +31 -0
  10. data/app/controllers/context_drop/configs_controller.rb +20 -0
  11. data/app/controllers/context_drop/dashboard/feedbacks_controller.rb +107 -0
  12. data/app/controllers/context_drop/feedbacks_controller.rb +208 -0
  13. data/app/controllers/context_drop/health_controller.rb +9 -0
  14. data/app/controllers/context_drop/mcp_controller.rb +67 -0
  15. data/app/helpers/context_drop/widget_helper.rb +62 -0
  16. data/app/javascript/context_drop/__tests__/widget.test.ts +319 -0
  17. data/app/javascript/context_drop/api-client.ts +87 -0
  18. data/app/javascript/context_drop/element-picker.ts +1004 -0
  19. data/app/javascript/context_drop/icons.ts +35 -0
  20. data/app/javascript/context_drop/index.ts +11 -0
  21. data/app/javascript/context_drop/sanitizer.ts +454 -0
  22. data/app/javascript/context_drop/screenshot.ts +214 -0
  23. data/app/javascript/context_drop/styles.ts +690 -0
  24. data/app/javascript/context_drop/types.ts +110 -0
  25. data/app/javascript/context_drop/widget.ts +1120 -0
  26. data/app/models/context_drop/application_record.rb +7 -0
  27. data/app/models/context_drop/attachment.rb +30 -0
  28. data/app/models/context_drop/element_reference.rb +47 -0
  29. data/app/models/context_drop/feedback.rb +94 -0
  30. data/app/services/context_drop/metadata_sanitizer.rb +215 -0
  31. data/app/services/context_drop/upload_validator.rb +217 -0
  32. data/app/views/context_drop/dashboard/feedbacks/index.html.erb +86 -0
  33. data/app/views/context_drop/dashboard/feedbacks/show.html.erb +121 -0
  34. data/app/views/layouts/context_drop/application.html.erb +215 -0
  35. data/config/routes.rb +17 -0
  36. data/db/migrate/20261109000001_create_context_drop_feedbacks.rb +23 -0
  37. data/db/migrate/20261109000002_create_context_drop_element_references.rb +29 -0
  38. data/db/migrate/20261109000003_create_context_drop_attachments.rb +24 -0
  39. data/lib/context_drop/base64_util.rb +15 -0
  40. data/lib/context_drop/configuration.rb +155 -0
  41. data/lib/context_drop/engine.rb +32 -0
  42. data/lib/context_drop/mcp/dispatcher.rb +197 -0
  43. data/lib/context_drop/mcp/prompts/investigate_feedback.rb +83 -0
  44. data/lib/context_drop/mcp/server.rb +25 -0
  45. data/lib/context_drop/mcp/stdio_runner.rb +62 -0
  46. data/lib/context_drop/mcp/tools/get_feedback.rb +133 -0
  47. data/lib/context_drop/mcp/tools/get_feedback_attachment.rb +71 -0
  48. data/lib/context_drop/mcp/tools/list_feedbacks.rb +127 -0
  49. data/lib/context_drop/mcp/tools/update_feedback_status.rb +72 -0
  50. data/lib/context_drop/version.rb +5 -0
  51. data/lib/context_drop.rb +32 -0
  52. data/lib/generators/context_drop/install/install_generator.rb +52 -0
  53. data/lib/generators/context_drop/install/templates/initializer.rb.tt +55 -0
  54. data/lib/tasks/context_drop_tasks.rake +14 -0
  55. metadata +246 -0
@@ -0,0 +1,121 @@
1
+ <div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 1rem; margin-bottom: 1.25rem;">
2
+ <div style="display: flex; align-items: center; gap: 0.75rem;">
3
+ <a href="<%= dashboard_feedbacks_path %>" class="cd-btn">&larr; Back to List</a>
4
+ <h1 style="margin: 0; font-size: 1.25rem;">Feedback #<%= @feedback.id %></h1>
5
+ <span class="cd-badge cd-badge-<%= @feedback.status %>"><%= @feedback.status %></span>
6
+ </div>
7
+
8
+ <div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
9
+ <% [
10
+ ["reviewed", "Mark Reviewed"],
11
+ ["resolved", "Mark Resolved"],
12
+ ["dismissed", "Dismiss"]
13
+ ].each do |target_status, label| %>
14
+ <%= form_with url: dashboard_feedback_path(@feedback), method: :patch, local: true, style: "display: inline;" do %>
15
+ <input type="hidden" name="status" value="<%= target_status %>">
16
+ <button type="submit"
17
+ class="cd-btn <%= 'cd-btn-primary' if target_status == 'resolved' %>"
18
+ <%= "disabled" if @feedback.status == target_status %>>
19
+ <%= label %>
20
+ </button>
21
+ <% end %>
22
+ <% end %>
23
+ </div>
24
+ </div>
25
+
26
+ <div class="cd-card">
27
+ <h2 style="margin-top: 0; font-size: 1rem; color: var(--cd-muted);">Feedback Message</h2>
28
+ <p style="font-size: 1rem; white-space: pre-wrap; margin: 0 0 1.25rem 0;"><%= @feedback.message.presence || "(No message text provided)" %></p>
29
+
30
+ <dl class="cd-meta-list">
31
+ <dt>Page URL</dt>
32
+ <dd><code class="cd-mono"><%= @feedback.page_url %></code></dd>
33
+ <dt>Submitter</dt>
34
+ <dd><%= @feedback.submitter_label %></dd>
35
+ <dt>Submitted At</dt>
36
+ <dd><%= @feedback.created_at&.iso8601 %></dd>
37
+ <dt>User Agent</dt>
38
+ <dd><%= @feedback.user_agent.presence || "N/A" %></dd>
39
+ <dt>Metadata</dt>
40
+ <dd><code class="cd-mono"><%= JSON.generate(@feedback.metadata || {}) %></code></dd>
41
+ </dl>
42
+ </div>
43
+
44
+ <div class="cd-card">
45
+ <h2 style="margin-top: 0; font-size: 1rem;">
46
+ DOM Element References (<%= @feedback.element_references.size %>)
47
+ </h2>
48
+
49
+ <% if @feedback.element_references.empty? %>
50
+ <p style="color: var(--cd-muted); margin: 0;">No DOM elements were selected for this submission.</p>
51
+ <% else %>
52
+ <div class="cd-grid">
53
+ <% @feedback.element_references.each do |el| %>
54
+ <div style="border: 1px solid var(--cd-border); border-radius: 0.5rem; padding: 1rem;">
55
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
56
+ <code class="cd-mono">&lt;<%= el.tag_name %>&gt;</code>
57
+ <% if el.captured_at %>
58
+ <span style="font-size: 0.75rem; color: var(--cd-muted);"><%= el.captured_at.iso8601 %></span>
59
+ <% end %>
60
+ </div>
61
+ <dl class="cd-meta-list">
62
+ <dt>Selector</dt>
63
+ <dd><code class="cd-mono"><%= el.selector %></code></dd>
64
+ <dt>DOM Path</dt>
65
+ <dd><code class="cd-mono"><%= el.dom_path.presence || "N/A" %></code></dd>
66
+ <dt>Text Content</dt>
67
+ <dd><%= el.text_content.presence || "(empty)" %></dd>
68
+ <dt>Bounding Rect</dt>
69
+ <dd><code class="cd-mono"><%= JSON.generate(el.bounding_rect || {}) %></code></dd>
70
+ <dt>Viewport</dt>
71
+ <dd><code class="cd-mono"><%= JSON.generate(el.viewport || {}) %></code></dd>
72
+ <dt>Attributes</dt>
73
+ <dd><code class="cd-mono"><%= JSON.generate(el.attributes_snapshot || {}) %></code></dd>
74
+ </dl>
75
+ </div>
76
+ <% end %>
77
+ </div>
78
+ <% end %>
79
+ </div>
80
+
81
+ <div class="cd-card">
82
+ <h2 style="margin-top: 0; font-size: 1rem;">
83
+ Screenshots &amp; Attachments (<%= @feedback.attachments.size %>)
84
+ </h2>
85
+
86
+ <% if @feedback.attachments.empty? %>
87
+ <p style="color: var(--cd-muted); margin: 0;">No binary attachments or screenshots were included.</p>
88
+ <% else %>
89
+ <div class="cd-grid">
90
+ <% @feedback.attachments.each do |att| %>
91
+ <div style="border: 1px solid var(--cd-border); border-radius: 0.5rem; padding: 1rem;">
92
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
93
+ <span class="cd-badge cd-badge-new"><%= att.attachment_type %></span>
94
+ <% if att.element_reference %>
95
+ <span style="font-size: 0.75rem; color: var(--cd-muted);">
96
+ Element: <code class="cd-mono"><%= att.element_reference.selector %></code>
97
+ </span>
98
+ <% end %>
99
+ </div>
100
+
101
+ <% if att.respond_to?(:file) && att.file.attached? %>
102
+ <div style="margin: 0.5rem 0;">
103
+ <% if main_app.respond_to?(:url_for) %>
104
+ <img src="<%= main_app.url_for(att.file) %>"
105
+ alt="Attachment #<%= att.id %>"
106
+ style="max-width: 100%; border-radius: 0.375rem; border: 1px solid var(--cd-border);" />
107
+ <% end %>
108
+ </div>
109
+ <div style="font-size: 0.75rem; color: var(--cd-muted);">
110
+ <%= att.file.filename %> (<%= att.file.content_type %>, <%= att.file.byte_size %> bytes)
111
+ </div>
112
+ <% end %>
113
+
114
+ <div style="margin-top: 0.5rem;">
115
+ <code class="cd-mono"><%= JSON.generate(att.metadata || {}) %></code>
116
+ </div>
117
+ </div>
118
+ <% end %>
119
+ </div>
120
+ <% end %>
121
+ </div>
@@ -0,0 +1,215 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>ContextDrop — Feedback Triage Dashboard</title>
7
+ <%= csrf_meta_tags if respond_to?(:csrf_meta_tags) %>
8
+ <style>
9
+ :root {
10
+ --cd-bg: #f8fafc;
11
+ --cd-surface: #ffffff;
12
+ --cd-border: #e2e8f0;
13
+ --cd-text: #0f172a;
14
+ --cd-muted: #64748b;
15
+ --cd-primary: #2563eb;
16
+ --cd-primary-hover: #1d4ed8;
17
+ --cd-new-bg: #dbeafe;
18
+ --cd-new-text: #1e40af;
19
+ --cd-reviewed-bg: #fef3c7;
20
+ --cd-reviewed-text: #92400e;
21
+ --cd-resolved-bg: #dcfce7;
22
+ --cd-resolved-text: #166534;
23
+ --cd-dismissed-bg: #f1f5f9;
24
+ --cd-dismissed-text: #475569;
25
+ }
26
+ * { box-sizing: border-box; }
27
+ body {
28
+ margin: 0;
29
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
30
+ background: var(--cd-bg);
31
+ color: var(--cd-text);
32
+ line-height: 1.5;
33
+ }
34
+ .cd-header {
35
+ background: var(--cd-surface);
36
+ border-bottom: 1px solid var(--cd-border);
37
+ padding: 0.875rem 1.5rem;
38
+ display: flex;
39
+ align-items: center;
40
+ justify-content: space-between;
41
+ }
42
+ .cd-brand {
43
+ font-weight: 700;
44
+ font-size: 1.125rem;
45
+ color: var(--cd-text);
46
+ text-decoration: none;
47
+ display: flex;
48
+ align-items: center;
49
+ gap: 0.5rem;
50
+ }
51
+ .cd-brand-badge {
52
+ font-size: 0.75rem;
53
+ font-weight: 600;
54
+ padding: 0.125rem 0.5rem;
55
+ border-radius: 9999px;
56
+ background: var(--cd-new-bg);
57
+ color: var(--cd-new-text);
58
+ }
59
+ .cd-container {
60
+ max-width: 1200px;
61
+ margin: 1.5rem auto;
62
+ padding: 0 1.5rem;
63
+ }
64
+ .cd-flash {
65
+ padding: 0.75rem 1rem;
66
+ border-radius: 0.5rem;
67
+ margin-bottom: 1rem;
68
+ font-size: 0.875rem;
69
+ border: 1px solid var(--cd-border);
70
+ background: var(--cd-surface);
71
+ }
72
+ .cd-flash-notice { border-color: #86efac; background: #f0fdf4; color: #166534; }
73
+ .cd-flash-alert { border-color: #fca5a5; background: #fef2f2; color: #991b1b; }
74
+ .cd-card {
75
+ background: var(--cd-surface);
76
+ border: 1px solid var(--cd-border);
77
+ border-radius: 0.75rem;
78
+ padding: 1.25rem;
79
+ margin-bottom: 1.25rem;
80
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
81
+ }
82
+ .cd-tabs {
83
+ display: flex;
84
+ gap: 0.5rem;
85
+ flex-wrap: wrap;
86
+ margin-bottom: 1rem;
87
+ }
88
+ .cd-tab {
89
+ display: inline-flex;
90
+ align-items: center;
91
+ gap: 0.375rem;
92
+ padding: 0.4rem 0.85rem;
93
+ border-radius: 0.5rem;
94
+ font-size: 0.875rem;
95
+ font-weight: 500;
96
+ color: var(--cd-muted);
97
+ text-decoration: none;
98
+ border: 1px solid transparent;
99
+ }
100
+ .cd-tab:hover { background: #f1f5f9; color: var(--cd-text); }
101
+ .cd-tab.active {
102
+ background: var(--cd-surface);
103
+ color: var(--cd-primary);
104
+ border-color: var(--cd-border);
105
+ font-weight: 600;
106
+ }
107
+ .cd-filter-bar {
108
+ display: flex;
109
+ gap: 0.75rem;
110
+ flex-wrap: wrap;
111
+ margin-bottom: 1rem;
112
+ }
113
+ .cd-input {
114
+ padding: 0.5rem 0.75rem;
115
+ border: 1px solid var(--cd-border);
116
+ border-radius: 0.5rem;
117
+ font-size: 0.875rem;
118
+ min-width: 220px;
119
+ }
120
+ .cd-btn {
121
+ display: inline-flex;
122
+ align-items: center;
123
+ gap: 0.375rem;
124
+ padding: 0.5rem 0.9rem;
125
+ border-radius: 0.5rem;
126
+ font-size: 0.875rem;
127
+ font-weight: 500;
128
+ border: 1px solid var(--cd-border);
129
+ background: var(--cd-surface);
130
+ color: var(--cd-text);
131
+ cursor: pointer;
132
+ text-decoration: none;
133
+ }
134
+ .cd-btn:hover { background: #f8fafc; }
135
+ .cd-btn-primary {
136
+ background: var(--cd-primary);
137
+ border-color: var(--cd-primary);
138
+ color: #ffffff;
139
+ }
140
+ .cd-btn-primary:hover { background: var(--cd-primary-hover); }
141
+ .cd-table {
142
+ width: 100%;
143
+ border-collapse: collapse;
144
+ font-size: 0.875rem;
145
+ }
146
+ .cd-table th, .cd-table td {
147
+ text-align: left;
148
+ padding: 0.75rem 0.875rem;
149
+ border-bottom: 1px solid var(--cd-border);
150
+ vertical-align: top;
151
+ }
152
+ .cd-table th {
153
+ font-weight: 600;
154
+ color: var(--cd-muted);
155
+ font-size: 0.75rem;
156
+ text-transform: uppercase;
157
+ letter-spacing: 0.04em;
158
+ }
159
+ .cd-badge {
160
+ display: inline-flex;
161
+ align-items: center;
162
+ padding: 0.15rem 0.55rem;
163
+ border-radius: 9999px;
164
+ font-size: 0.75rem;
165
+ font-weight: 600;
166
+ text-transform: capitalize;
167
+ }
168
+ .cd-badge-new { background: var(--cd-new-bg); color: var(--cd-new-text); }
169
+ .cd-badge-reviewed { background: var(--cd-reviewed-bg); color: var(--cd-reviewed-text); }
170
+ .cd-badge-resolved { background: var(--cd-resolved-bg); color: var(--cd-resolved-text); }
171
+ .cd-badge-dismissed { background: var(--cd-dismissed-bg); color: var(--cd-dismissed-text); }
172
+ .cd-mono {
173
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
174
+ font-size: 0.8125rem;
175
+ background: #f1f5f9;
176
+ padding: 0.15rem 0.4rem;
177
+ border-radius: 0.25rem;
178
+ }
179
+ .cd-grid {
180
+ display: grid;
181
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
182
+ gap: 1rem;
183
+ }
184
+ .cd-meta-list {
185
+ margin: 0;
186
+ display: grid;
187
+ grid-template-columns: 140px 1fr;
188
+ row-gap: 0.5rem;
189
+ font-size: 0.875rem;
190
+ }
191
+ .cd-meta-list dt { color: var(--cd-muted); font-weight: 500; }
192
+ .cd-meta-list dd { margin: 0; word-break: break-all; }
193
+ </style>
194
+ </head>
195
+ <body>
196
+ <header class="cd-header">
197
+ <a class="cd-brand" href="<%= dashboard_feedbacks_path %>">
198
+ <span>ContextDrop</span>
199
+ <span class="cd-brand-badge">Feedback Triage</span>
200
+ </a>
201
+ <span style="font-size: 0.8125rem; color: var(--cd-muted);">v<%= ContextDrop::VERSION %></span>
202
+ </header>
203
+
204
+ <main class="cd-container">
205
+ <% if flash[:notice].present? %>
206
+ <div class="cd-flash cd-flash-notice"><%= flash[:notice] %></div>
207
+ <% end %>
208
+ <% if flash[:alert].present? %>
209
+ <div class="cd-flash cd-flash-alert"><%= flash[:alert] %></div>
210
+ <% end %>
211
+
212
+ <%= yield %>
213
+ </main>
214
+ </body>
215
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ ContextDrop::Engine.routes.draw do
4
+ get "config", to: "configs#show"
5
+ get "assets/context_drop.js", to: "configs#widget_bundle"
6
+ post "attachments", to: "attachments#create"
7
+ post "feedback", to: "feedbacks#create"
8
+ get "health", to: "health#show"
9
+ post "mcp", to: "mcp#create"
10
+
11
+ namespace :dashboard do
12
+ root to: "feedbacks#index"
13
+ resources :feedbacks, only: %i[index show update]
14
+ end
15
+
16
+ root to: "dashboard/feedbacks#index"
17
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateContextDropFeedbacks < ActiveRecord::Migration[7.1]
4
+ def change
5
+ json_type = connection.adapter_name.downcase.include?("postgres") ? :jsonb : :json
6
+
7
+ create_table :context_drop_feedbacks do |t|
8
+ t.references :submitter, polymorphic: true, null: true, index: { name: "idx_context_drop_feedbacks_on_submitter" }
9
+ t.string :session_id, null: true
10
+ t.text :message, null: true
11
+ t.string :page_url, null: false
12
+ t.string :user_agent, null: true
13
+ t.public_send(json_type, :metadata, null: false, default: {})
14
+ t.string :status, null: false, default: "new"
15
+
16
+ t.timestamps
17
+ end
18
+
19
+ add_index :context_drop_feedbacks, :status
20
+ add_index :context_drop_feedbacks, :created_at
21
+ add_index :context_drop_feedbacks, :page_url
22
+ end
23
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateContextDropElementReferences < ActiveRecord::Migration[7.1]
4
+ def change
5
+ json_type = connection.adapter_name.downcase.include?("postgres") ? :jsonb : :json
6
+
7
+ create_table :context_drop_element_references do |t|
8
+ t.references :feedback,
9
+ null: false,
10
+ foreign_key: { to_table: :context_drop_feedbacks, on_delete: :cascade },
11
+ index: { name: "idx_context_drop_element_refs_on_feedback_id" }
12
+ t.string :client_ref, null: true
13
+ t.string :tag_name, null: false
14
+ t.string :selector, null: false
15
+ t.string :dom_path, null: true
16
+ t.text :text_content, null: true
17
+ t.public_send(json_type, :attributes_snapshot, null: false, default: {})
18
+ t.public_send(json_type, :bounding_rect, null: false, default: {})
19
+ t.public_send(json_type, :viewport, null: false, default: {})
20
+ t.public_send(json_type, :metadata, null: false, default: {})
21
+ t.datetime :captured_at, null: true
22
+
23
+ t.timestamps
24
+ end
25
+
26
+ add_index :context_drop_element_references, :selector
27
+ add_index :context_drop_element_references, :tag_name
28
+ end
29
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateContextDropAttachments < ActiveRecord::Migration[7.1]
4
+ def change
5
+ json_type = connection.adapter_name.downcase.include?("postgres") ? :jsonb : :json
6
+
7
+ create_table :context_drop_attachments do |t|
8
+ t.references :feedback,
9
+ null: false,
10
+ foreign_key: { to_table: :context_drop_feedbacks, on_delete: :cascade },
11
+ index: { name: "idx_context_drop_attachments_on_feedback_id" }
12
+ t.references :element_reference,
13
+ null: true,
14
+ foreign_key: { to_table: :context_drop_element_references, on_delete: :nullify },
15
+ index: { name: "idx_context_drop_attachments_on_element_ref_id" }
16
+ t.string :attachment_type, null: false
17
+ t.public_send(json_type, :metadata, null: false, default: {})
18
+
19
+ t.timestamps
20
+ end
21
+
22
+ add_index :context_drop_attachments, :attachment_type
23
+ end
24
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContextDrop
4
+ module Base64Util
5
+ module_function
6
+
7
+ def strict_encode64(bin)
8
+ [bin.to_s.b].pack("m0")
9
+ end
10
+
11
+ def strict_decode64(str)
12
+ str.to_s.unpack1("m0")
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContextDrop
4
+ class Configuration
5
+ VALID_POSITIONS = %i[bottom_right bottom_left top_right top_left].freeze
6
+
7
+ MANDATORY_EXCLUDED_SELECTORS = [
8
+ "[data-feedback-ignore]",
9
+ "[data-feedback-sensitive]",
10
+ "input[type='password']",
11
+ "input[type='hidden']",
12
+ "[autocomplete^='cc-']"
13
+ ].freeze
14
+
15
+ DEFAULT_ALLOWED_CONTENT_TYPES = %w[
16
+ image/png
17
+ image/jpeg
18
+ image/webp
19
+ image/gif
20
+ ].freeze
21
+
22
+ attr_accessor :enabled,
23
+ :storage,
24
+ :active_storage_service,
25
+ :allow_anonymous,
26
+ :max_attachments,
27
+ :max_element_references,
28
+ :max_attachment_size,
29
+ :allowed_content_types,
30
+ :max_image_dimensions,
31
+ :element_selection,
32
+ :screenshot_capture,
33
+ :element_capture_padding,
34
+ :excluded_selectors,
35
+ :current_submitter,
36
+ :authorize_submission,
37
+ :authorize_dashboard,
38
+ :rate_limit,
39
+ :on_feedback_created,
40
+ :mcp_enabled,
41
+ :mcp_api_key,
42
+ :authorize_mcp
43
+
44
+ attr_reader :position
45
+
46
+ def initialize
47
+ @enabled = false
48
+ @position = :bottom_right
49
+ @storage = :active_storage
50
+ @active_storage_service = nil
51
+ @allow_anonymous = true
52
+
53
+ @max_attachments = 10
54
+ @max_element_references = 10
55
+ @max_attachment_size = 10 * 1024 * 1024 # 10 MB
56
+ @allowed_content_types = DEFAULT_ALLOWED_CONTENT_TYPES.dup
57
+ @max_image_dimensions = [4096, 4096]
58
+
59
+ @element_selection = true
60
+ @screenshot_capture = true
61
+ @element_capture_padding = 16
62
+
63
+ @excluded_selectors = MANDATORY_EXCLUDED_SELECTORS.dup
64
+
65
+ @current_submitter = lambda do |controller|
66
+ controller.respond_to?(:current_user, true) ? controller.send(:current_user) : nil
67
+ end
68
+
69
+ @authorize_submission = ->(_controller) { true }
70
+
71
+ @authorize_dashboard = lambda do |controller|
72
+ user = controller.respond_to?(:current_user, true) ? controller.send(:current_user) : nil
73
+ is_admin = user.respond_to?(:admin?) && user.admin?
74
+ is_dev = defined?(Rails) && Rails.respond_to?(:env) && Rails.env.development?
75
+ Boolean(is_admin || is_dev)
76
+ end
77
+
78
+ @rate_limit = { max_requests: 20, period: 3600 }
79
+ @on_feedback_created = ->(_feedback) {}
80
+
81
+ @mcp_enabled = false
82
+ @mcp_api_key = nil
83
+ @authorize_mcp = nil
84
+ end
85
+
86
+ def position=(value)
87
+ sym = value.to_s.strip.to_sym
88
+ unless VALID_POSITIONS.include?(sym)
89
+ raise ArgumentError, "Invalid ContextDrop position #{value.inspect}. Must be one of: #{VALID_POSITIONS.join(', ')}"
90
+ end
91
+
92
+ @position = sym
93
+ end
94
+
95
+ def effective_excluded_selectors
96
+ (MANDATORY_EXCLUDED_SELECTORS + Array(@excluded_selectors).map(&:to_s)).uniq
97
+ end
98
+
99
+ def resolve_submitter(controller)
100
+ return nil unless @current_submitter.respond_to?(:call)
101
+
102
+ @current_submitter.call(controller)
103
+ end
104
+
105
+ def submission_authorized?(controller)
106
+ submitter = resolve_submitter(controller)
107
+ return false if !@allow_anonymous && submitter.nil?
108
+ return true unless @authorize_submission.respond_to?(:call)
109
+
110
+ !!@authorize_submission.call(controller)
111
+ end
112
+
113
+ def dashboard_authorized?(controller)
114
+ return false unless @authorize_dashboard.respond_to?(:call)
115
+
116
+ !!@authorize_dashboard.call(controller)
117
+ end
118
+
119
+ def public_widget_config(overrides = {})
120
+ resolved_position = overrides[:position] ? normalize_position(overrides[:position]) : @position
121
+
122
+ {
123
+ enabled: !!@enabled,
124
+ position: resolved_position.to_s,
125
+ placeholder: (overrides[:placeholder] || "What would you like to tell us?").to_s,
126
+ features: {
127
+ element_selection: !!@element_selection,
128
+ screenshot_capture: !!@screenshot_capture,
129
+ element_capture_padding: @element_capture_padding.to_i
130
+ },
131
+ limits: {
132
+ max_attachments: @max_attachments.to_i,
133
+ max_element_references: @max_element_references.to_i,
134
+ max_attachment_size: @max_attachment_size.to_i,
135
+ allowed_content_types: Array(@allowed_content_types),
136
+ max_image_dimensions: Array(@max_image_dimensions)
137
+ },
138
+ privacy: {
139
+ excluded_selectors: effective_excluded_selectors
140
+ }
141
+ }
142
+ end
143
+
144
+ private
145
+
146
+ def normalize_position(pos)
147
+ sym = pos.to_s.strip.to_sym
148
+ VALID_POSITIONS.include?(sym) ? sym : @position
149
+ end
150
+
151
+ def Boolean(val) # rubocop:disable Naming/MethodName
152
+ !!val
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ContextDrop
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace ContextDrop
6
+
7
+ initializer "context_drop.assets" do |app|
8
+ if app.config.respond_to?(:assets) && app.config.assets.respond_to?(:paths)
9
+ app.config.assets.paths << root.join("app/assets/javascripts").to_s
10
+ app.config.assets.paths << root.join("app/assets/stylesheets").to_s
11
+ app.config.assets.paths << root.join("app/javascript").to_s
12
+
13
+ if app.config.assets.respond_to?(:precompile)
14
+ app.config.assets.precompile += %w[
15
+ context_drop/context_drop.js
16
+ context_drop/application.css
17
+ ]
18
+ end
19
+ end
20
+ end
21
+
22
+ initializer "context_drop.helpers" do
23
+ ActiveSupport.on_load(:action_view) do
24
+ include ContextDrop::WidgetHelper
25
+ end
26
+ end
27
+
28
+ rake_tasks do
29
+ load File.expand_path("../tasks/context_drop_tasks.rake", __dir__)
30
+ end
31
+ end
32
+ end