ideasbugs 0.5.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 +7 -0
- data/CHANGELOG.md +114 -0
- data/MIT-LICENSE +20 -0
- data/README.md +339 -0
- data/Rakefile +19 -0
- data/app/controllers/ideasbugs/application_controller.rb +15 -0
- data/app/controllers/ideasbugs/feedbacks_controller.rb +156 -0
- data/app/controllers/ideasbugs/screenshots_controller.rb +27 -0
- data/app/helpers/ideasbugs/widget_helper.rb +18 -0
- data/app/models/ideasbugs/application_record.rb +7 -0
- data/app/models/ideasbugs/feedback.rb +30 -0
- data/app/views/ideasbugs/feedbacks/index.html.erb +78 -0
- data/app/views/ideasbugs/feedbacks/show.html.erb +63 -0
- data/app/views/layouts/ideasbugs/application.html.erb +76 -0
- data/config/locales/ideasbugs.ar.yml +24 -0
- data/config/locales/ideasbugs.bg.yml +24 -0
- data/config/locales/ideasbugs.bn.yml +24 -0
- data/config/locales/ideasbugs.de.yml +24 -0
- data/config/locales/ideasbugs.el.yml +24 -0
- data/config/locales/ideasbugs.en.yml +47 -0
- data/config/locales/ideasbugs.es.yml +24 -0
- data/config/locales/ideasbugs.fr.yml +24 -0
- data/config/locales/ideasbugs.hi.yml +24 -0
- data/config/locales/ideasbugs.hr.yml +24 -0
- data/config/locales/ideasbugs.id.yml +24 -0
- data/config/locales/ideasbugs.it.yml +24 -0
- data/config/locales/ideasbugs.ja.yml +24 -0
- data/config/locales/ideasbugs.ko.yml +24 -0
- data/config/locales/ideasbugs.lb.yml +24 -0
- data/config/locales/ideasbugs.nl.yml +24 -0
- data/config/locales/ideasbugs.pl.yml +24 -0
- data/config/locales/ideasbugs.pt.yml +24 -0
- data/config/locales/ideasbugs.ro.yml +24 -0
- data/config/locales/ideasbugs.ru.yml +24 -0
- data/config/locales/ideasbugs.th.yml +24 -0
- data/config/locales/ideasbugs.tr.yml +24 -0
- data/config/locales/ideasbugs.uk.yml +24 -0
- data/config/locales/ideasbugs.ur.yml +24 -0
- data/config/locales/ideasbugs.vi.yml +24 -0
- data/config/locales/ideasbugs.zh-CN.yml +24 -0
- data/config/routes.rb +12 -0
- data/lib/generators/ideasbugs/install/install_generator.rb +41 -0
- data/lib/generators/ideasbugs/install/templates/create_ideasbugs_feedbacks.rb.tt +21 -0
- data/lib/generators/ideasbugs/install/templates/initializer.rb +47 -0
- data/lib/ideasbugs/configuration.rb +93 -0
- data/lib/ideasbugs/engine.rb +13 -0
- data/lib/ideasbugs/version.rb +5 -0
- data/lib/ideasbugs/widget.js +387 -0
- data/lib/ideasbugs/widget.rb +109 -0
- data/lib/ideasbugs.rb +34 -0
- metadata +113 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ideasbugs widget — self-contained, no framework, no build step.
|
|
3
|
+
*
|
|
4
|
+
* Reads its config from the <script type="application/json"
|
|
5
|
+
* data-ideasbugs-config> the server renders — re-read on every render so
|
|
6
|
+
* a Turbo visit always reflects the current page's config.
|
|
7
|
+
*
|
|
8
|
+
* A floating button (or any host element carrying `data-ideasbugs-open`)
|
|
9
|
+
* opens a small modal form: type, optional section, message, optional
|
|
10
|
+
* screenshots. The form POSTs multipart form data to the mounted engine with
|
|
11
|
+
* the page's CSRF token. Esc or the backdrop closes it.
|
|
12
|
+
*/
|
|
13
|
+
(function () {
|
|
14
|
+
"use strict";
|
|
15
|
+
|
|
16
|
+
var config = readConfig();
|
|
17
|
+
if (!config || window.__ideasbugsLoaded) return;
|
|
18
|
+
window.__ideasbugsLoaded = true;
|
|
19
|
+
|
|
20
|
+
var Z = 2147483000;
|
|
21
|
+
var overlay = null;
|
|
22
|
+
var lastFocused = null;
|
|
23
|
+
|
|
24
|
+
function ready(fn) {
|
|
25
|
+
if (document.readyState === "loading") {
|
|
26
|
+
document.addEventListener("DOMContentLoaded", fn);
|
|
27
|
+
} else {
|
|
28
|
+
fn();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
ready(function () {
|
|
33
|
+
// Document-level listeners survive Turbo navigations; register them once.
|
|
34
|
+
document.addEventListener("keydown", handleKeydown);
|
|
35
|
+
document.addEventListener("click", handleOpenerClick, true);
|
|
36
|
+
|
|
37
|
+
// The button lives in <body>, which Turbo replaces on every visit, so
|
|
38
|
+
// re-run the per-page setup on each visit. render() also runs now for the
|
|
39
|
+
// initial (or non-Turbo) load.
|
|
40
|
+
render();
|
|
41
|
+
document.addEventListener("turbo:load", render);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function readConfig() {
|
|
45
|
+
var el = document.querySelector("script[data-ideasbugs-config]");
|
|
46
|
+
if (!el) return null;
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(el.textContent);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function render() {
|
|
55
|
+
// Re-read on each visit: the config block is data (not an executed
|
|
56
|
+
// script), so it reflects the page Turbo just rendered.
|
|
57
|
+
config = readConfig() || config;
|
|
58
|
+
injectStyles();
|
|
59
|
+
if (config.showButton !== false) buildButton();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function handleOpenerClick(event) {
|
|
63
|
+
var opener = event.target && event.target.closest
|
|
64
|
+
? event.target.closest("[data-ideasbugs-open]")
|
|
65
|
+
: null;
|
|
66
|
+
if (!opener) return;
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
event.stopPropagation();
|
|
69
|
+
openForm();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function handleKeydown(event) {
|
|
73
|
+
if (event.key === "Escape" && overlay) closeForm();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- floating button --------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
function buildButton() {
|
|
79
|
+
if (document.getElementById("idb-button")) return;
|
|
80
|
+
var button = document.createElement("button");
|
|
81
|
+
button.id = "idb-button";
|
|
82
|
+
button.type = "button";
|
|
83
|
+
button.setAttribute("data-ideasbugs-open", "");
|
|
84
|
+
button.textContent = config.buttonLabel || config.labels.button;
|
|
85
|
+
document.body.appendChild(button);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- form -------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
function openForm() {
|
|
91
|
+
if (overlay) return;
|
|
92
|
+
lastFocused = document.activeElement;
|
|
93
|
+
|
|
94
|
+
overlay = document.createElement("div");
|
|
95
|
+
overlay.id = "idb-overlay";
|
|
96
|
+
overlay.addEventListener("mousedown", function (event) {
|
|
97
|
+
if (event.target === overlay) closeForm();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
var dialog = document.createElement("div");
|
|
101
|
+
dialog.id = "idb-dialog";
|
|
102
|
+
dialog.setAttribute("role", "dialog");
|
|
103
|
+
dialog.setAttribute("aria-modal", "true");
|
|
104
|
+
dialog.setAttribute("aria-labelledby", "idb-title");
|
|
105
|
+
if (config.rtl) dialog.setAttribute("dir", "rtl");
|
|
106
|
+
|
|
107
|
+
dialog.appendChild(header());
|
|
108
|
+
dialog.appendChild(form());
|
|
109
|
+
overlay.appendChild(dialog);
|
|
110
|
+
document.body.appendChild(overlay);
|
|
111
|
+
|
|
112
|
+
// Keep Tab (and Shift+Tab) cycling inside the dialog while it is open.
|
|
113
|
+
overlay.addEventListener("keydown", trapFocus);
|
|
114
|
+
|
|
115
|
+
var first = dialog.querySelector("select, textarea, input");
|
|
116
|
+
if (first) first.focus();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function closeForm() {
|
|
120
|
+
if (!overlay) return;
|
|
121
|
+
overlay.remove();
|
|
122
|
+
overlay = null;
|
|
123
|
+
if (lastFocused && lastFocused.focus) lastFocused.focus();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function trapFocus(event) {
|
|
127
|
+
if (event.key !== "Tab" || !overlay) return;
|
|
128
|
+
var focusable = overlay.querySelectorAll("button, select, textarea, input, a[href]");
|
|
129
|
+
if (!focusable.length) return;
|
|
130
|
+
var first = focusable[0];
|
|
131
|
+
var last = focusable[focusable.length - 1];
|
|
132
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
last.focus();
|
|
135
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
first.focus();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function header() {
|
|
142
|
+
var head = document.createElement("div");
|
|
143
|
+
head.className = "idb-head";
|
|
144
|
+
|
|
145
|
+
var title = document.createElement("h2");
|
|
146
|
+
title.id = "idb-title";
|
|
147
|
+
title.textContent = config.labels.title;
|
|
148
|
+
|
|
149
|
+
var close = document.createElement("button");
|
|
150
|
+
close.type = "button";
|
|
151
|
+
close.className = "idb-x";
|
|
152
|
+
close.setAttribute("aria-label", config.labels.close);
|
|
153
|
+
close.textContent = "×";
|
|
154
|
+
close.addEventListener("click", closeForm);
|
|
155
|
+
|
|
156
|
+
head.appendChild(title);
|
|
157
|
+
head.appendChild(close);
|
|
158
|
+
return head;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function form() {
|
|
162
|
+
var form = document.createElement("form");
|
|
163
|
+
form.addEventListener("submit", function (event) {
|
|
164
|
+
event.preventDefault();
|
|
165
|
+
submit(form);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (config.kinds.length > 1) form.appendChild(kindField());
|
|
169
|
+
if (config.sections.length > 0) form.appendChild(sectionField());
|
|
170
|
+
form.appendChild(messageField());
|
|
171
|
+
if (config.screenshots.enabled) form.appendChild(screenshotsField());
|
|
172
|
+
|
|
173
|
+
var error = document.createElement("p");
|
|
174
|
+
error.className = "idb-error";
|
|
175
|
+
error.hidden = true;
|
|
176
|
+
form.appendChild(error);
|
|
177
|
+
|
|
178
|
+
var actions = document.createElement("div");
|
|
179
|
+
actions.className = "idb-actions";
|
|
180
|
+
|
|
181
|
+
var cancel = document.createElement("button");
|
|
182
|
+
cancel.type = "button";
|
|
183
|
+
cancel.className = "idb-secondary";
|
|
184
|
+
cancel.textContent = config.labels.cancel;
|
|
185
|
+
cancel.addEventListener("click", closeForm);
|
|
186
|
+
|
|
187
|
+
var save = document.createElement("button");
|
|
188
|
+
save.type = "submit";
|
|
189
|
+
save.className = "idb-primary";
|
|
190
|
+
save.textContent = config.labels.submit;
|
|
191
|
+
|
|
192
|
+
actions.appendChild(cancel);
|
|
193
|
+
actions.appendChild(save);
|
|
194
|
+
form.appendChild(actions);
|
|
195
|
+
return form;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function field(labelText, control) {
|
|
199
|
+
var wrap = document.createElement("label");
|
|
200
|
+
wrap.className = "idb-field";
|
|
201
|
+
var caption = document.createElement("span");
|
|
202
|
+
caption.textContent = labelText;
|
|
203
|
+
wrap.appendChild(caption);
|
|
204
|
+
wrap.appendChild(control);
|
|
205
|
+
return wrap;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function kindField() {
|
|
209
|
+
var select = document.createElement("select");
|
|
210
|
+
select.name = "kind";
|
|
211
|
+
config.kinds.forEach(function (kind) {
|
|
212
|
+
var option = document.createElement("option");
|
|
213
|
+
option.value = kind.value;
|
|
214
|
+
option.textContent = kind.label;
|
|
215
|
+
select.appendChild(option);
|
|
216
|
+
});
|
|
217
|
+
return field(config.labels.kind, select);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function sectionField() {
|
|
221
|
+
var select = document.createElement("select");
|
|
222
|
+
select.name = "section";
|
|
223
|
+
var blank = document.createElement("option");
|
|
224
|
+
blank.value = "";
|
|
225
|
+
blank.textContent = config.labels.sectionAny;
|
|
226
|
+
select.appendChild(blank);
|
|
227
|
+
config.sections.forEach(function (section) {
|
|
228
|
+
var option = document.createElement("option");
|
|
229
|
+
option.value = section;
|
|
230
|
+
option.textContent = section;
|
|
231
|
+
select.appendChild(option);
|
|
232
|
+
});
|
|
233
|
+
return field(config.labels.section, select);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function messageField() {
|
|
237
|
+
var textarea = document.createElement("textarea");
|
|
238
|
+
textarea.name = "message";
|
|
239
|
+
textarea.rows = 5;
|
|
240
|
+
textarea.placeholder = config.labels.messagePlaceholder;
|
|
241
|
+
return field(config.labels.message, textarea);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function screenshotsField() {
|
|
245
|
+
var input = document.createElement("input");
|
|
246
|
+
input.type = "file";
|
|
247
|
+
input.name = "screenshots";
|
|
248
|
+
input.multiple = true;
|
|
249
|
+
input.accept = "image/*";
|
|
250
|
+
var wrap = field(config.labels.screenshots, input);
|
|
251
|
+
var hint = document.createElement("span");
|
|
252
|
+
hint.className = "idb-hint";
|
|
253
|
+
hint.textContent = config.labels.screenshotsHint;
|
|
254
|
+
wrap.appendChild(hint);
|
|
255
|
+
return wrap;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- submit -------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
function submit(form) {
|
|
261
|
+
var message = form.querySelector("textarea[name=message]").value.trim();
|
|
262
|
+
if (!message) return showError(form, config.labels.errorBlank);
|
|
263
|
+
|
|
264
|
+
var files = fileList(form);
|
|
265
|
+
if (files.length > config.screenshots.max) {
|
|
266
|
+
return showError(form, config.labels.errorTooMany);
|
|
267
|
+
}
|
|
268
|
+
for (var i = 0; i < files.length; i++) {
|
|
269
|
+
if (files[i].size > config.screenshots.maxSize) {
|
|
270
|
+
return showError(form, config.labels.errorTooLarge);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
var data = new FormData();
|
|
275
|
+
var kindSelect = form.querySelector("select[name=kind]");
|
|
276
|
+
data.append("feedback[kind]", kindSelect ? kindSelect.value : config.kinds[0].value);
|
|
277
|
+
var sectionSelect = form.querySelector("select[name=section]");
|
|
278
|
+
if (sectionSelect && sectionSelect.value) data.append("feedback[section]", sectionSelect.value);
|
|
279
|
+
data.append("feedback[message]", message);
|
|
280
|
+
data.append("feedback[page_url]", window.location.href);
|
|
281
|
+
files.forEach(function (file) {
|
|
282
|
+
data.append("feedback[screenshots][]", file);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
var save = form.querySelector(".idb-primary");
|
|
286
|
+
save.disabled = true;
|
|
287
|
+
|
|
288
|
+
fetch(config.endpoint, {
|
|
289
|
+
method: "POST",
|
|
290
|
+
headers: csrfHeaders(),
|
|
291
|
+
body: data,
|
|
292
|
+
credentials: "same-origin"
|
|
293
|
+
})
|
|
294
|
+
.then(function (response) {
|
|
295
|
+
if (response.ok) return thanks();
|
|
296
|
+
return response
|
|
297
|
+
.json()
|
|
298
|
+
.catch(function () { return {}; })
|
|
299
|
+
.then(function (body) {
|
|
300
|
+
var messages = body && body.errors;
|
|
301
|
+
showError(form, (messages && messages[0]) || config.labels.errorSave);
|
|
302
|
+
});
|
|
303
|
+
})
|
|
304
|
+
.catch(function () {
|
|
305
|
+
showError(form, config.labels.errorSave);
|
|
306
|
+
})
|
|
307
|
+
.finally(function () {
|
|
308
|
+
save.disabled = false;
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function fileList(form) {
|
|
313
|
+
var input = form.querySelector("input[type=file]");
|
|
314
|
+
return input ? Array.prototype.slice.call(input.files) : [];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function csrfHeaders() {
|
|
318
|
+
var meta = document.querySelector('meta[name="csrf-token"]');
|
|
319
|
+
return meta ? { "X-CSRF-Token": meta.content } : {};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function showError(form, text) {
|
|
323
|
+
var error = form.querySelector(".idb-error");
|
|
324
|
+
error.textContent = text;
|
|
325
|
+
error.hidden = false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function thanks() {
|
|
329
|
+
if (!overlay) return;
|
|
330
|
+
var dialog = overlay.querySelector("#idb-dialog");
|
|
331
|
+
dialog.textContent = "";
|
|
332
|
+
var note = document.createElement("p");
|
|
333
|
+
note.className = "idb-thanks";
|
|
334
|
+
note.textContent = config.labels.thanks;
|
|
335
|
+
dialog.appendChild(note);
|
|
336
|
+
setTimeout(closeForm, 1600);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// --- styles -------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
function injectStyles() {
|
|
342
|
+
if (document.getElementById("idb-styles")) return;
|
|
343
|
+
var css = [
|
|
344
|
+
"#idb-button{position:fixed;bottom:16px;right:16px;z-index:" + Z + ";",
|
|
345
|
+
"padding:10px 16px;border:0;border-radius:999px;cursor:pointer;",
|
|
346
|
+
"background:#2563eb;color:#fff;font:600 14px/1 system-ui,-apple-system,sans-serif;",
|
|
347
|
+
"box-shadow:0 4px 14px rgba(0,0,0,.25)}",
|
|
348
|
+
"#idb-button:hover{background:#1d4ed8}",
|
|
349
|
+
"#idb-overlay{position:fixed;inset:0;z-index:" + (Z + 1) + ";background:rgba(0,0,0,.45);",
|
|
350
|
+
"display:flex;align-items:center;justify-content:center;padding:16px}",
|
|
351
|
+
"#idb-dialog{width:100%;max-width:420px;max-height:90vh;overflow:auto;",
|
|
352
|
+
"background:#fff;color:#1c2024;border-radius:14px;padding:20px;",
|
|
353
|
+
"font:14px/1.5 system-ui,-apple-system,sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.35)}",
|
|
354
|
+
"#idb-dialog .idb-head{display:flex;align-items:center;justify-content:space-between;margin:0 0 12px}",
|
|
355
|
+
"#idb-dialog h2{margin:0;font-size:17px}",
|
|
356
|
+
"#idb-dialog .idb-x{border:0;background:none;font-size:22px;line-height:1;cursor:pointer;color:inherit;padding:2px 6px}",
|
|
357
|
+
"#idb-dialog .idb-field{display:block;margin-bottom:12px}",
|
|
358
|
+
"#idb-dialog .idb-field>span{display:block;margin-bottom:4px;font-weight:600}",
|
|
359
|
+
"#idb-dialog select,#idb-dialog textarea,#idb-dialog input[type=file]{width:100%;box-sizing:border-box;",
|
|
360
|
+
"padding:8px;border:1px solid #d1d5db;border-radius:8px;background:inherit;color:inherit;font:inherit}",
|
|
361
|
+
"#idb-dialog input[type=file]{padding:6px;color:#6b7280;font-size:13px}",
|
|
362
|
+
"#idb-dialog input[type=file]::file-selector-button{margin-inline-end:10px;padding:6px 12px;",
|
|
363
|
+
"border:1px solid #d1d5db;border-radius:6px;background:none;color:#1c2024;font:inherit;cursor:pointer}",
|
|
364
|
+
"#idb-dialog textarea{resize:vertical}",
|
|
365
|
+
"#idb-dialog .idb-hint{display:block;margin-top:4px;font-size:12px;color:#6b7280;font-weight:400}",
|
|
366
|
+
"#idb-dialog .idb-error{color:#dc2626;margin:0 0 12px}",
|
|
367
|
+
"#idb-dialog .idb-actions{display:flex;justify-content:flex-end;gap:8px}",
|
|
368
|
+
"#idb-dialog button{padding:8px 14px;border-radius:8px;cursor:pointer;font:inherit}",
|
|
369
|
+
"#idb-dialog .idb-secondary{border:1px solid #d1d5db;background:none;color:inherit}",
|
|
370
|
+
"#idb-dialog .idb-primary{border:0;background:#2563eb;color:#fff;font-weight:600}",
|
|
371
|
+
"#idb-dialog .idb-primary:disabled{opacity:.6;cursor:default}",
|
|
372
|
+
"#idb-dialog .idb-thanks{margin:8px 0;text-align:center;font-size:15px}",
|
|
373
|
+
"@media (prefers-color-scheme:dark){",
|
|
374
|
+
"#idb-dialog{background:#1a1f26;color:#e6e8ea}",
|
|
375
|
+
"#idb-dialog select,#idb-dialog textarea,#idb-dialog input[type=file]{border-color:#2a313a}",
|
|
376
|
+
"#idb-dialog input[type=file]{color:#9aa2ab}",
|
|
377
|
+
"#idb-dialog input[type=file]::file-selector-button{border-color:#2a313a;color:#e6e8ea}",
|
|
378
|
+
"#idb-dialog .idb-secondary{border-color:#2a313a}",
|
|
379
|
+
"#idb-dialog .idb-hint{color:#9aa2ab}",
|
|
380
|
+
"}"
|
|
381
|
+
].join("");
|
|
382
|
+
var style = document.createElement("style");
|
|
383
|
+
style.id = "idb-styles";
|
|
384
|
+
style.textContent = css;
|
|
385
|
+
document.head.appendChild(style);
|
|
386
|
+
}
|
|
387
|
+
})();
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Ideasbugs
|
|
6
|
+
# Serves the self-contained browser widget. The JavaScript is plain ES (no
|
|
7
|
+
# framework, no build step) and styles itself inline, so it drops into any
|
|
8
|
+
# Rails app regardless of its CSS or JS setup. It is inlined into the page
|
|
9
|
+
# rather than served as a separate asset to avoid any dependency on the
|
|
10
|
+
# host's asset pipeline — and it lives under lib/ (not app/assets/) so a
|
|
11
|
+
# host that *does* run a pipeline never ingests it either: nothing lands in
|
|
12
|
+
# the host's asset namespace or precompiled output.
|
|
13
|
+
module Widget
|
|
14
|
+
SOURCE = File.expand_path('widget.js', __dir__)
|
|
15
|
+
|
|
16
|
+
# Right-to-left scripts, so the form renders mirrored for those locales.
|
|
17
|
+
# Matched on the language subtag, so region variants ("ar-EG") count too.
|
|
18
|
+
RTL_LANGUAGES = %w[ar arc ckb dv fa ha he ks ku ps sd ug ur yi].freeze
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def javascript
|
|
22
|
+
@javascript ||= File.read(SOURCE)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The two <script> tags the helper renders.
|
|
26
|
+
#
|
|
27
|
+
# The config rides in a `type="application/json"` block: it is *data*,
|
|
28
|
+
# not code, so the browser never executes it and Turbo never tries to
|
|
29
|
+
# re-run it on a soft visit — which means it needs no CSP nonce and the
|
|
30
|
+
# widget can re-read the *current* page's config on every `turbo:load`.
|
|
31
|
+
#
|
|
32
|
+
# `nonce:` stamps only the widget script (the code), so it runs under a
|
|
33
|
+
# nonce-based Content-Security-Policy; pass nil when the app has no nonce.
|
|
34
|
+
def snippet(endpoint:, locale:, nonce: nil)
|
|
35
|
+
config = {
|
|
36
|
+
endpoint: endpoint,
|
|
37
|
+
locale: locale.to_s,
|
|
38
|
+
kinds: kinds,
|
|
39
|
+
sections: Ideasbugs.config.sections.map(&:to_s),
|
|
40
|
+
screenshots: screenshots,
|
|
41
|
+
showButton: Ideasbugs.config.show_button ? true : false,
|
|
42
|
+
buttonLabel: Ideasbugs.config.button_label,
|
|
43
|
+
labels: labels,
|
|
44
|
+
rtl: rtl?(locale)
|
|
45
|
+
}
|
|
46
|
+
# Escape "</" so a value can't close the <script> block early.
|
|
47
|
+
json = config.to_json.gsub('</', '<\/')
|
|
48
|
+
nonce_attr = nonce ? %( nonce="#{nonce}") : ''
|
|
49
|
+
|
|
50
|
+
%(<script type="application/json" data-ideasbugs-config>#{json}</script>) +
|
|
51
|
+
%(<script data-ideasbugs-widget#{nonce_attr}>#{javascript}</script>)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def kinds
|
|
57
|
+
Ideasbugs.config.kinds.map do |kind|
|
|
58
|
+
{ value: kind.to_s, label: t("kinds.#{kind}", kind.to_s.humanize) }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def screenshots
|
|
63
|
+
{
|
|
64
|
+
enabled: Ideasbugs.config.screenshots_enabled?,
|
|
65
|
+
max: Ideasbugs.config.max_screenshots,
|
|
66
|
+
maxSize: Ideasbugs.config.max_screenshot_size
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Every user-facing string in the widget, resolved through Rails I18n so
|
|
71
|
+
# the form follows the app's current locale. Each lookup carries an
|
|
72
|
+
# English default, so the widget stays fully worded even when the host is
|
|
73
|
+
# missing a key for the active locale.
|
|
74
|
+
def labels
|
|
75
|
+
{
|
|
76
|
+
button: t(:button, 'Feedback'),
|
|
77
|
+
title: t(:title, 'Send feedback'),
|
|
78
|
+
kind: t(:kind, 'Type'),
|
|
79
|
+
section: t(:section, 'Section'),
|
|
80
|
+
sectionAny: t(:section_any, 'General'),
|
|
81
|
+
message: t(:message, 'Your message'),
|
|
82
|
+
messagePlaceholder: t(:message_placeholder, "Tell us what's on your mind…"),
|
|
83
|
+
screenshots: t(:screenshots, 'Screenshots'),
|
|
84
|
+
screenshotsHint: t(:screenshots_hint, 'optional, up to %{count} files, %{size} MB each',
|
|
85
|
+
count: Ideasbugs.config.max_screenshots,
|
|
86
|
+
size: Ideasbugs.config.max_screenshot_size / (1024 * 1024)),
|
|
87
|
+
submit: t(:submit, 'Send feedback'),
|
|
88
|
+
cancel: t(:cancel, 'Cancel'),
|
|
89
|
+
close: t(:close, 'Close'),
|
|
90
|
+
thanks: t(:thanks, 'Thanks for your feedback!'),
|
|
91
|
+
errorBlank: t(:error_blank, 'Please enter a message.'),
|
|
92
|
+
errorSave: t(:error_save, 'Could not send feedback. Please try again.'),
|
|
93
|
+
errorTooMany: t(:error_too_many, 'Too many screenshots (max %{count}).',
|
|
94
|
+
count: Ideasbugs.config.max_screenshots),
|
|
95
|
+
errorTooLarge: t(:error_too_large, 'A screenshot is too large (max %{size} MB).',
|
|
96
|
+
size: Ideasbugs.config.max_screenshot_size / (1024 * 1024))
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def t(key, default, **args)
|
|
101
|
+
I18n.t(key, scope: :ideasbugs, default: default, **args)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def rtl?(locale)
|
|
105
|
+
RTL_LANGUAGES.include?(locale.to_s.downcase.split(/[-_]/).first)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
data/lib/ideasbugs.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ideasbugs/version'
|
|
4
|
+
require 'ideasbugs/configuration'
|
|
5
|
+
require 'ideasbugs/widget'
|
|
6
|
+
require 'ideasbugs/engine'
|
|
7
|
+
|
|
8
|
+
# In-app product feedback collection for Rails. A drop-in widget lets users
|
|
9
|
+
# send bug reports, feature requests, and general feedback (with screenshots)
|
|
10
|
+
# from any page; submissions land in your own database, with a minimal built-in
|
|
11
|
+
# dashboard to browse and triage them.
|
|
12
|
+
module Ideasbugs
|
|
13
|
+
class << self
|
|
14
|
+
def config
|
|
15
|
+
@config ||= Configuration.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def configure
|
|
19
|
+
yield config
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Can this request send feedback? Checked on the server for the endpoint
|
|
23
|
+
# and by the helper before rendering the widget.
|
|
24
|
+
def enabled?(request)
|
|
25
|
+
!!config.enabled.call(request)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Can this request browse and triage feedback? Checked by every dashboard
|
|
29
|
+
# action.
|
|
30
|
+
def admin?(request)
|
|
31
|
+
!!config.authorize_admin.call(request)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ideasbugs
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.5.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yaroslav Shmarov
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.1'
|
|
26
|
+
description: |
|
|
27
|
+
A mountable Rails engine that adds a "Send feedback" widget to your app —
|
|
28
|
+
bug reports, feature requests, screenshots — and stores submissions in your
|
|
29
|
+
own database. Ships with a minimal built-in dashboard to browse and triage
|
|
30
|
+
what users send. Framework-agnostic: no CSS or JS framework required.
|
|
31
|
+
email:
|
|
32
|
+
- yaroslav.shmarov@clickfunnels.com
|
|
33
|
+
executables: []
|
|
34
|
+
extensions: []
|
|
35
|
+
extra_rdoc_files: []
|
|
36
|
+
files:
|
|
37
|
+
- CHANGELOG.md
|
|
38
|
+
- MIT-LICENSE
|
|
39
|
+
- README.md
|
|
40
|
+
- Rakefile
|
|
41
|
+
- app/controllers/ideasbugs/application_controller.rb
|
|
42
|
+
- app/controllers/ideasbugs/feedbacks_controller.rb
|
|
43
|
+
- app/controllers/ideasbugs/screenshots_controller.rb
|
|
44
|
+
- app/helpers/ideasbugs/widget_helper.rb
|
|
45
|
+
- app/models/ideasbugs/application_record.rb
|
|
46
|
+
- app/models/ideasbugs/feedback.rb
|
|
47
|
+
- app/views/ideasbugs/feedbacks/index.html.erb
|
|
48
|
+
- app/views/ideasbugs/feedbacks/show.html.erb
|
|
49
|
+
- app/views/layouts/ideasbugs/application.html.erb
|
|
50
|
+
- config/locales/ideasbugs.ar.yml
|
|
51
|
+
- config/locales/ideasbugs.bg.yml
|
|
52
|
+
- config/locales/ideasbugs.bn.yml
|
|
53
|
+
- config/locales/ideasbugs.de.yml
|
|
54
|
+
- config/locales/ideasbugs.el.yml
|
|
55
|
+
- config/locales/ideasbugs.en.yml
|
|
56
|
+
- config/locales/ideasbugs.es.yml
|
|
57
|
+
- config/locales/ideasbugs.fr.yml
|
|
58
|
+
- config/locales/ideasbugs.hi.yml
|
|
59
|
+
- config/locales/ideasbugs.hr.yml
|
|
60
|
+
- config/locales/ideasbugs.id.yml
|
|
61
|
+
- config/locales/ideasbugs.it.yml
|
|
62
|
+
- config/locales/ideasbugs.ja.yml
|
|
63
|
+
- config/locales/ideasbugs.ko.yml
|
|
64
|
+
- config/locales/ideasbugs.lb.yml
|
|
65
|
+
- config/locales/ideasbugs.nl.yml
|
|
66
|
+
- config/locales/ideasbugs.pl.yml
|
|
67
|
+
- config/locales/ideasbugs.pt.yml
|
|
68
|
+
- config/locales/ideasbugs.ro.yml
|
|
69
|
+
- config/locales/ideasbugs.ru.yml
|
|
70
|
+
- config/locales/ideasbugs.th.yml
|
|
71
|
+
- config/locales/ideasbugs.tr.yml
|
|
72
|
+
- config/locales/ideasbugs.uk.yml
|
|
73
|
+
- config/locales/ideasbugs.ur.yml
|
|
74
|
+
- config/locales/ideasbugs.vi.yml
|
|
75
|
+
- config/locales/ideasbugs.zh-CN.yml
|
|
76
|
+
- config/routes.rb
|
|
77
|
+
- lib/generators/ideasbugs/install/install_generator.rb
|
|
78
|
+
- lib/generators/ideasbugs/install/templates/create_ideasbugs_feedbacks.rb.tt
|
|
79
|
+
- lib/generators/ideasbugs/install/templates/initializer.rb
|
|
80
|
+
- lib/ideasbugs.rb
|
|
81
|
+
- lib/ideasbugs/configuration.rb
|
|
82
|
+
- lib/ideasbugs/engine.rb
|
|
83
|
+
- lib/ideasbugs/version.rb
|
|
84
|
+
- lib/ideasbugs/widget.js
|
|
85
|
+
- lib/ideasbugs/widget.rb
|
|
86
|
+
homepage: https://github.com/yshmarov/ideasbugs
|
|
87
|
+
licenses:
|
|
88
|
+
- MIT
|
|
89
|
+
metadata:
|
|
90
|
+
homepage_uri: https://github.com/yshmarov/ideasbugs
|
|
91
|
+
source_code_uri: https://github.com/yshmarov/ideasbugs
|
|
92
|
+
changelog_uri: https://github.com/yshmarov/ideasbugs/blob/main/CHANGELOG.md
|
|
93
|
+
bug_tracker_uri: https://github.com/yshmarov/ideasbugs/issues
|
|
94
|
+
rubygems_mfa_required: 'true'
|
|
95
|
+
rdoc_options: []
|
|
96
|
+
require_paths:
|
|
97
|
+
- lib
|
|
98
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '3.2'
|
|
103
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
104
|
+
requirements:
|
|
105
|
+
- - ">="
|
|
106
|
+
- !ruby/object:Gem::Version
|
|
107
|
+
version: '0'
|
|
108
|
+
requirements: []
|
|
109
|
+
rubygems_version: 4.0.6
|
|
110
|
+
specification_version: 4
|
|
111
|
+
summary: 'In-app product feedback collection for Rails: a drop-in widget and a built-in
|
|
112
|
+
triage dashboard.'
|
|
113
|
+
test_files: []
|