pinnable 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 (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +18 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +100 -0
  5. data/Rakefile +8 -0
  6. data/app/assets/javascripts/pinnable.js +308 -0
  7. data/app/assets/stylesheets/pinnable/application.css +83 -0
  8. data/app/controllers/pinnable/application_controller.rb +20 -0
  9. data/app/controllers/pinnable/comments_controller.rb +16 -0
  10. data/app/controllers/pinnable/markers_controller.rb +12 -0
  11. data/app/controllers/pinnable/pins_controller.rb +34 -0
  12. data/app/helpers/pinnable/application_helper.rb +4 -0
  13. data/app/helpers/pinnable/widget_helper.rb +11 -0
  14. data/app/jobs/pinnable/application_job.rb +4 -0
  15. data/app/mailers/pinnable/application_mailer.rb +6 -0
  16. data/app/models/pinnable/application_record.rb +5 -0
  17. data/app/models/pinnable/comment/encryption.rb +8 -0
  18. data/app/models/pinnable/comment/relationships.rb +8 -0
  19. data/app/models/pinnable/comment/validations.rb +7 -0
  20. data/app/models/pinnable/comment.rb +9 -0
  21. data/app/models/pinnable/pin/encryption.rb +9 -0
  22. data/app/models/pinnable/pin/relationships.rb +10 -0
  23. data/app/models/pinnable/pin/scopes.rb +8 -0
  24. data/app/models/pinnable/pin/transitions.rb +7 -0
  25. data/app/models/pinnable/pin/validations.rb +17 -0
  26. data/app/models/pinnable/pin.rb +12 -0
  27. data/app/serializers/pinnable/marker_serializer.rb +27 -0
  28. data/app/services/pinnable/add_comment.rb +22 -0
  29. data/app/services/pinnable/capture_pin.rb +28 -0
  30. data/app/services/pinnable/resolve_pin.rb +32 -0
  31. data/app/views/layouts/pinnable/application.html.erb +23 -0
  32. data/app/views/pinnable/_widget.html.erb +61 -0
  33. data/app/views/pinnable/pins/index.html.erb +66 -0
  34. data/config/importmap.rb +3 -0
  35. data/config/routes.rb +7 -0
  36. data/db/migrate/20260101000010_create_pinnable_pins.rb +37 -0
  37. data/db/migrate/20260101000030_create_pinnable_comments.rb +17 -0
  38. data/lib/generators/pinnable/install/install_generator.rb +27 -0
  39. data/lib/generators/pinnable/install/templates/pinnable.rb +23 -0
  40. data/lib/pinnable/configuration.rb +22 -0
  41. data/lib/pinnable/engine.rb +20 -0
  42. data/lib/pinnable/version.rb +3 -0
  43. data/lib/pinnable.rb +17 -0
  44. data/lib/tasks/pinnable_tasks.rake +4 -0
  45. metadata +113 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0b167cdfced85f0afd9739517d1590c0c6d3958c525f80bdd4e5b1c0c544f8bf
4
+ data.tar.gz: a63ab8d983cd4c8c8710712c6828b878c668930026d5c057968fd06b95790e6e
5
+ SHA512:
6
+ metadata.gz: 2c69c4ced059ab377c6d51844f640b38a34bab16af2c2d7ad5a33793dddec768a1251287cafd07eb1124dcb226a896300427203fed7a18d12daf4bea8bd6f9b3
7
+ data.tar.gz: d6b00982085b84adaf48eb3468c1c49a4c6dffe26c9445f3e608bc197fbdb1cb46bd66035413dab055e213f6ddbad8d86b4bc5c8d88987b7cafecbb83bf0ca57
data/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] — 2026-06-22
4
+
5
+ Initial release.
6
+
7
+ - Mountable Rails engine for in-app visual feedback — enable it for some users, toggle comment
8
+ mode, click any DOM element, leave a note.
9
+ - **Anchoring with zero JS dependencies**: each pin stores a CSS selector, XPath, and text-quote;
10
+ re-resolves in order so the pin survives DOM drift, with an "unanchored" tray when it can't.
11
+ - **Task management**: pins move open → resolved → won't-fix, stamped with resolver + timestamp.
12
+ - **Polished, self-contained inbox** (own layout + scoped CSS), with deep-link "open on page".
13
+ - **Reply threads** on a pin — on-page popover and inbox reply count.
14
+ - **Host-agnostic config seam**: `enabled_for`, `current_user`, `tenant_scope`, `user_label`,
15
+ `parent_controller`, `layout`, `audit`, `encrypt`.
16
+ - **Optional at-rest encryption** of `body` + `anchor` via Active Record encryption.
17
+ - **Any database** — portable schema, no JSONB.
18
+ - Hotwire/Stimulus, delivered via importmap merge. `pinnable:install` generator.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Vlad Mehakovic
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # Pinnable
2
+
3
+ Click any element, pin a comment, work it like a task list.
4
+
5
+ Pinnable is a mountable Rails engine for **in-app visual feedback**. Enable it for some users
6
+ (superadmins, your team, a client), they flip a toggle, click **any** element on **any** page, and
7
+ leave a note. Each note remembers *who*, *which page*, and *which element* — so it re-anchors on the
8
+ next visit and someone else can jump straight to it. Every note is a task: open → resolved, with who
9
+ completed it and when.
10
+
11
+ It's the BugHerd/Marker.io idea, self-hosted, with your data staying in your database.
12
+
13
+ - **Host-agnostic.** Your auth, your gate, your multitenancy, your audit sink — all injected through
14
+ one config object. The engine assumes none of it.
15
+ - **Any database.** Portable schema (no JSONB); proven on SQLite, works on MySQL/Postgres.
16
+ - **Hotwire/Stimulus, zero JS dependencies.** One self-contained Stimulus controller; element
17
+ anchoring (CSS selector → XPath → text-quote, tried in order) is hand-rolled, no vendored libs.
18
+ - **Test-first.** Model/controller/service tests plus a headless-Chrome system test of the full
19
+ click-to-pin flow.
20
+
21
+ ## Installation
22
+
23
+ ```ruby
24
+ # Gemfile
25
+ gem "pinnable"
26
+ ```
27
+
28
+ ```bash
29
+ bundle
30
+ bin/rails generate pinnable:install
31
+ bin/rails pinnable:install:migrations && bin/rails db:migrate
32
+ ```
33
+
34
+ Then add the widget to your layout, just before `</body>`:
35
+
36
+ ```erb
37
+ <%= pinnable_widget %>
38
+ ```
39
+
40
+ ## Configuration
41
+
42
+ `pinnable:install` writes `config/initializers/pinnable.rb`:
43
+
44
+ ```ruby
45
+ Pinnable.configure do |c|
46
+ # The gate. Return false → the widget never renders and every endpoint 404s.
47
+ c.enabled_for = ->(user) { user&.admin? }
48
+
49
+ # How to find the current user from a controller.
50
+ c.current_user = ->(controller) { controller.current_user }
51
+
52
+ # How a user is labelled in the inbox (no host User object is stored — only this label).
53
+ c.user_label = ->(user) { user.try(:email) || user.to_s }
54
+
55
+ # Engine controllers inherit this, picking up your auth, CSRF, and layout.
56
+ c.parent_controller = "ApplicationController"
57
+
58
+ # Optional: scope pins to a tenant (account/org). nil = single-tenant.
59
+ # c.tenant_scope = ->(controller) { controller.current_account }
60
+
61
+ # Optional: audit sink for status changes.
62
+ # c.audit = ->(pin, event, by) { Rails.logger.info("pinnable #{event} #{pin.public_id}") }
63
+
64
+ # Optional: encrypt body + anchor at rest (needs Active Record encryption configured).
65
+ # c.encrypt = true
66
+ end
67
+ ```
68
+
69
+ > **Turning encryption on later:** Active Record `encrypts` can't read rows written before it
70
+ > was enabled, so an existing `pinnable_pins` table with plaintext rows will raise on read. Either
71
+ > set `c.encrypt = true` from the start, or clear/re-encrypt existing rows before flipping it on
72
+ > (`Pinnable::Pin.delete_all` in dev). Fresh installs are unaffected.
73
+
74
+ ## How it works
75
+
76
+ - **Capture.** In comment mode a capture-phase click is intercepted (so the underlying control
77
+ doesn't fire) and the clicked element is recorded as three anchors — a CSS selector, an XPath, and
78
+ a `{ prefix, exact, suffix }` text quote — plus a percentage-relative click point.
79
+ - **Render.** On each visit the open pins for that path are fetched and re-resolved (css → xpath →
80
+ text-quote, first hit wins) and drawn as numbered markers. If all three miss, the note drops into
81
+ an "unanchored" tray instead of being lost.
82
+ - **Work it.** The inbox at `/pinnable` lists every pin, filterable; resolve/reopen stamps who and
83
+ when. A deep link (`/pinnable/pins/:id`) takes anyone to the page with that pin focused.
84
+
85
+ The widget is the only host touchpoint: `<%= pinnable_widget %>`. Pins are addressed by an opaque
86
+ `public_id`, never a raw database id.
87
+
88
+ ## Development
89
+
90
+ ```bash
91
+ bin/rails test # models, controllers, services, generator
92
+ bin/rails test:system # headless-Chrome flow (requires Chrome)
93
+ ```
94
+
95
+ The dummy host app under `test/dummy` shows a minimal integration (a `User`, an
96
+ `ApplicationController#current_user`, and the `Pinnable.configure` initializer).
97
+
98
+ ## License
99
+
100
+ MIT.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
@@ -0,0 +1,308 @@
1
+ import { Application, Controller } from "@hotwired/stimulus"
2
+
3
+ // ----------------------------------------------------------------------------
4
+ // Anchoring — no dependencies. Each pin stores three independent anchors and we
5
+ // re-resolve them in order (css -> xpath -> text quote); the redundancy is why a
6
+ // "dumb" structural selector is enough. See lib/specs/069 in the host app.
7
+ // ----------------------------------------------------------------------------
8
+
9
+ const MAX_QUOTE = 80
10
+
11
+ function cssPath(el) {
12
+ if (el.id) return `#${cssEscape(el.id)}`
13
+ const parts = []
14
+ let node = el
15
+ while (node && node.nodeType === 1 && node !== document.body) {
16
+ if (node.id) { parts.unshift(`#${cssEscape(node.id)}`); break }
17
+ const tag = node.tagName.toLowerCase()
18
+ const i = nthOfType(node)
19
+ parts.unshift(i ? `${tag}:nth-of-type(${i})` : tag)
20
+ node = node.parentElement
21
+ }
22
+ return parts.join(" > ")
23
+ }
24
+
25
+ function nthOfType(node) {
26
+ const siblings = Array.from(node.parentElement?.children || []).filter(n => n.tagName === node.tagName)
27
+ return siblings.length > 1 ? siblings.indexOf(node) + 1 : 0
28
+ }
29
+
30
+ function xpathOf(el) {
31
+ if (el.id) return `//*[@id=${xpathLiteral(el.id)}]`
32
+ const parts = []
33
+ let node = el
34
+ while (node && node.nodeType === 1 && node !== document.body) {
35
+ if (node.id) { parts.unshift(`*[@id=${xpathLiteral(node.id)}]`); break }
36
+ parts.unshift(`${node.tagName.toLowerCase()}[${sameTagIndex(node)}]`)
37
+ node = node.parentElement
38
+ }
39
+ return "//" + parts.join("/")
40
+ }
41
+
42
+ function sameTagIndex(node) {
43
+ let i = 1, sib = node
44
+ while ((sib = sib.previousElementSibling)) if (sib.tagName === node.tagName) i++
45
+ return i
46
+ }
47
+
48
+ function textQuote(el) {
49
+ return { exact: (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, MAX_QUOTE) }
50
+ }
51
+
52
+ function captureAnchor(el, event) {
53
+ const r = el.getBoundingClientRect()
54
+ return {
55
+ css: cssPath(el),
56
+ xpath: xpathOf(el),
57
+ ...textQuote(el),
58
+ tag: el.tagName.toLowerCase(),
59
+ x_pct: r.width ? round((event.clientX - r.left) / r.width) : 0,
60
+ y_pct: r.height ? round((event.clientY - r.top) / r.height) : 0
61
+ }
62
+ }
63
+
64
+ function resolveAnchor(anchor) {
65
+ let el = null
66
+ try { if (anchor.css) el = document.querySelector(anchor.css) } catch (_) {}
67
+ if (!el && anchor.xpath) el = byXPath(anchor.xpath)
68
+ if (!el && anchor.exact) el = byText(anchor.exact)
69
+ return el
70
+ }
71
+
72
+ function byXPath(xpath) {
73
+ try {
74
+ return document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
75
+ } catch (_) { return null }
76
+ }
77
+
78
+ function byText(exact) {
79
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT)
80
+ let node
81
+ while ((node = walker.nextNode())) {
82
+ if (node.closest(".pinnable")) continue
83
+ const text = (node.textContent || "").trim().replace(/\s+/g, " ")
84
+ if (node.children.length === 0 && text.includes(exact)) return node
85
+ }
86
+ return null
87
+ }
88
+
89
+ function cssEscape(s) { return window.CSS && CSS.escape ? CSS.escape(s) : s.replace(/([^\w-])/g, "\\$1") }
90
+ function xpathLiteral(s) { return s.includes("'") ? `concat('${s.split("'").join(`',"'",'`)}')` : `'${s}'` }
91
+ function round(n) { return Math.round(n * 1000) / 1000 }
92
+
93
+ // ----------------------------------------------------------------------------
94
+ // Controller — toggle comment mode, capture clicks, render + work pins.
95
+ // ----------------------------------------------------------------------------
96
+
97
+ class PinnableController extends Controller {
98
+ static values = { pinsUrl: String, markersUrl: String, currentUrl: String, focus: String, csrf: String }
99
+ static targets = ["toggle"]
100
+
101
+ connect() {
102
+ this.active = false
103
+ this.onClick = this.onClick.bind(this)
104
+ this.onMove = this.onMove.bind(this)
105
+ this.loadMarkers()
106
+ }
107
+
108
+ disconnect() { this.deactivate() }
109
+
110
+ toggle() { this.active ? this.deactivate() : this.activate() }
111
+
112
+ activate() {
113
+ this.active = true
114
+ this.toggleTarget.textContent = "💬 Comments: On"
115
+ this.toggleTarget.classList.add("pinnable-toggle--on")
116
+ document.addEventListener("click", this.onClick, true)
117
+ document.addEventListener("mousemove", this.onMove, true)
118
+ }
119
+
120
+ deactivate() {
121
+ this.active = false
122
+ if (this.hasToggleTarget) {
123
+ this.toggleTarget.textContent = "💬 Comments: Off"
124
+ this.toggleTarget.classList.remove("pinnable-toggle--on")
125
+ }
126
+ document.removeEventListener("click", this.onClick, true)
127
+ document.removeEventListener("mousemove", this.onMove, true)
128
+ this.clearHighlight()
129
+ }
130
+
131
+ onMove(event) {
132
+ const el = this.elementUnder(event)
133
+ if (el === this.highlighted) return
134
+ this.clearHighlight()
135
+ if (el) { this.highlighted = el; this.prevOutline = el.style.outline; el.style.outline = "2px solid #6366f1" }
136
+ }
137
+
138
+ clearHighlight() {
139
+ if (!this.highlighted) return
140
+ this.highlighted.style.outline = this.prevOutline || ""
141
+ this.highlighted = null
142
+ }
143
+
144
+ onClick(event) {
145
+ const el = this.elementUnder(event)
146
+ if (!el) return
147
+ event.preventDefault()
148
+ event.stopPropagation()
149
+ this.clearHighlight()
150
+ this.openComposer(el, event)
151
+ }
152
+
153
+ elementUnder(event) {
154
+ const el = event.target
155
+ if (!el || el.closest(".pinnable, .pinnable-composer, .pinnable-pop, .pinnable-tray, .pinnable-marker")) return null
156
+ return el
157
+ }
158
+
159
+ openComposer(el, event) {
160
+ this.closeComposer()
161
+ const anchor = captureAnchor(el, event)
162
+ const box = document.createElement("div")
163
+ box.className = "pinnable-composer"
164
+ box.style.left = `${event.pageX}px`
165
+ box.style.top = `${event.pageY}px`
166
+ box.innerHTML = `<textarea class="pinnable-composer__text" placeholder="Leave feedback…"></textarea>
167
+ <div class="pinnable-composer__actions">
168
+ <button type="button" class="pinnable-composer__cancel">Cancel</button>
169
+ <button type="button" class="pinnable-composer__save">Save</button>
170
+ </div>`
171
+ document.body.appendChild(box)
172
+ this.composer = box
173
+ box.querySelector(".pinnable-composer__text").focus()
174
+ box.querySelector(".pinnable-composer__cancel").addEventListener("click", () => this.closeComposer())
175
+ box.querySelector(".pinnable-composer__save").addEventListener("click", () => this.save(anchor, box))
176
+ }
177
+
178
+ closeComposer() { if (this.composer) { this.composer.remove(); this.composer = null } }
179
+
180
+ async save(anchor, box) {
181
+ const body = box.querySelector(".pinnable-composer__text").value.trim()
182
+ if (!body) return
183
+ const res = await this.post(this.pinsUrlValue, { pin: { url: this.currentUrlValue, body, anchor } })
184
+ if (!res.ok) return
185
+ const { public_id } = await res.json()
186
+ this.closeComposer()
187
+ this.addMarker({ public_id, body, anchor })
188
+ }
189
+
190
+ async loadMarkers() {
191
+ const res = await fetch(`${this.markersUrlValue}?url=${encodeURIComponent(this.currentUrlValue)}`, {
192
+ headers: { "Accept": "application/json" }
193
+ })
194
+ if (!res.ok) return
195
+ const pins = await res.json()
196
+ pins.forEach(pin => this.addMarker(pin))
197
+ if (this.focusValue) this.focusPin(this.focusValue)
198
+ }
199
+
200
+ addMarker(pin) {
201
+ const el = resolveAnchor(pin.anchor)
202
+ if (!el) return this.addUnanchored(pin)
203
+ const r = el.getBoundingClientRect()
204
+ const dot = document.createElement("button")
205
+ dot.type = "button"
206
+ dot.className = "pinnable-marker"
207
+ dot.dataset.pinId = pin.public_id
208
+ dot.style.left = `${window.scrollX + r.left + (pin.anchor.x_pct || 0) * r.width}px`
209
+ dot.style.top = `${window.scrollY + r.top + (pin.anchor.y_pct || 0) * r.height}px`
210
+ dot.textContent = "📌"
211
+ dot.title = pin.body
212
+ dot.addEventListener("click", (e) => { e.preventDefault(); this.openPopover(dot, pin) })
213
+ document.body.appendChild(dot)
214
+ }
215
+
216
+ addUnanchored(pin) {
217
+ let tray = document.querySelector(".pinnable-tray")
218
+ if (!tray) {
219
+ tray = document.createElement("div")
220
+ tray.className = "pinnable-tray"
221
+ tray.innerHTML = "<strong>Unanchored here</strong>"
222
+ document.body.appendChild(tray)
223
+ }
224
+ const item = document.createElement("button")
225
+ item.type = "button"
226
+ item.className = "pinnable-tray__item"
227
+ item.dataset.pinId = pin.public_id
228
+ item.textContent = pin.body
229
+ tray.appendChild(item)
230
+ }
231
+
232
+ openPopover(dot, pin) {
233
+ this.closePopover()
234
+ pin.comments = pin.comments || []
235
+ const pop = document.createElement("div")
236
+ pop.className = "pinnable-pop"
237
+ pop.style.left = dot.style.left
238
+ pop.style.top = `${parseFloat(dot.style.top) + 22}px`
239
+ pop.innerHTML = `
240
+ <p class="pinnable-pop__body"></p>
241
+ <div class="pinnable-pop__thread"></div>
242
+ <form class="pinnable-pop__reply">
243
+ <input type="text" class="pinnable-pop__input" placeholder="Reply…">
244
+ </form>
245
+ <button type="button" class="pinnable-pop__resolve">Resolve</button>`
246
+ pop.querySelector(".pinnable-pop__body").textContent = pin.body
247
+ document.body.appendChild(pop)
248
+ this.pop = pop
249
+ this.renderThread(pop, pin)
250
+ pop.querySelector(".pinnable-pop__resolve").addEventListener("click", () => this.resolve(pin, dot))
251
+ pop.querySelector(".pinnable-pop__reply").addEventListener("submit", (e) => { e.preventDefault(); this.reply(pin, pop) })
252
+ }
253
+
254
+ renderThread(pop, pin) {
255
+ const thread = pop.querySelector(".pinnable-pop__thread")
256
+ thread.innerHTML = ""
257
+ pin.comments.forEach((c) => {
258
+ const row = document.createElement("div")
259
+ row.className = "pinnable-pop__comment"
260
+ const who = document.createElement("span")
261
+ who.className = "pinnable-pop__author"
262
+ who.textContent = c.author_label
263
+ const text = document.createElement("span")
264
+ text.textContent = c.body
265
+ row.append(who, text)
266
+ thread.appendChild(row)
267
+ })
268
+ }
269
+
270
+ async reply(pin, pop) {
271
+ const input = pop.querySelector(".pinnable-pop__input")
272
+ const body = input.value.trim()
273
+ if (!body) return
274
+ const res = await this.post(`${this.pinsUrlValue}/${pin.public_id}/comments`, { comment: { body } })
275
+ if (!res.ok) return
276
+ const comment = await res.json()
277
+ pin.comments.push({ author_label: comment.author_label, body: comment.body })
278
+ this.renderThread(pop, pin)
279
+ input.value = ""
280
+ }
281
+
282
+ closePopover() { if (this.pop) { this.pop.remove(); this.pop = null } }
283
+
284
+ async resolve(pin, dot) {
285
+ const res = await this.post(`${this.pinsUrlValue}/${pin.public_id}`, { pin: { status: "resolved" } }, "PATCH")
286
+ if (!res.ok) return
287
+ this.closePopover()
288
+ dot.remove()
289
+ }
290
+
291
+ focusPin(publicId) {
292
+ const dot = document.querySelector(`.pinnable-marker[data-pin-id="${publicId}"]`)
293
+ if (!dot) return
294
+ dot.scrollIntoView({ block: "center", behavior: "smooth" })
295
+ dot.classList.add("pinnable-marker--flash")
296
+ }
297
+
298
+ post(url, payload, method = "POST") {
299
+ return fetch(url, {
300
+ method,
301
+ headers: { "Content-Type": "application/json", "Accept": "application/json", "X-CSRF-Token": this.csrfValue },
302
+ body: JSON.stringify(payload)
303
+ })
304
+ }
305
+ }
306
+
307
+ const application = Application.start()
308
+ application.register("pinnable", PinnableController)
@@ -0,0 +1,83 @@
1
+ /* Pinnable inbox — self-contained default styling. No framework, no host CSS.
2
+ Hosts override this stylesheet or the view to match their own brand. */
3
+
4
+ :root {
5
+ --pin-accent: #6366f1;
6
+ --pin-ink: #0f172a;
7
+ --pin-muted: #64748b;
8
+ --pin-line: #e2e8f0;
9
+ --pin-bg: #f8fafc;
10
+ }
11
+
12
+ .pinnable-body {
13
+ margin: 0;
14
+ background: var(--pin-bg);
15
+ color: var(--pin-ink);
16
+ font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
17
+ }
18
+
19
+ .pinnable-topbar {
20
+ display: flex;
21
+ align-items: center;
22
+ gap: 8px;
23
+ padding: 14px 24px;
24
+ background: #fff;
25
+ border-bottom: 1px solid var(--pin-line);
26
+ font-weight: 600;
27
+ }
28
+ .pinnable-topbar__dot { color: var(--pin-accent); }
29
+
30
+ .pinnable-wrap { max-width: 1080px; margin: 0 auto; padding: 32px 24px; }
31
+
32
+ .pinnable-head { margin-bottom: 20px; }
33
+ .pinnable-head h1 { margin: 0; font-size: 22px; font-weight: 700; }
34
+ .pinnable-head p { margin: 4px 0 0; color: var(--pin-muted); font-size: 13px; }
35
+ .pinnable-count {
36
+ display: inline-block; margin-left: 8px; padding: 1px 8px; border-radius: 999px;
37
+ background: #eef2ff; color: var(--pin-accent); font-size: 12px; font-weight: 600; vertical-align: middle;
38
+ }
39
+
40
+ .pinnable-flash {
41
+ margin-bottom: 16px; padding: 10px 14px; border-radius: 8px;
42
+ background: #dcfce7; color: #166534; font-size: 13px; font-weight: 500;
43
+ }
44
+
45
+ .pinnable-card {
46
+ background: #fff; border: 1px solid var(--pin-line); border-radius: 12px;
47
+ overflow: hidden; box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
48
+ }
49
+
50
+ .pinnable-table { width: 100%; border-collapse: collapse; }
51
+ .pinnable-table thead th {
52
+ text-align: left; padding: 11px 16px; font-size: 11px; font-weight: 600;
53
+ text-transform: uppercase; letter-spacing: .04em; color: var(--pin-muted);
54
+ background: #f8fafc; border-bottom: 1px solid var(--pin-line);
55
+ }
56
+ .pinnable-table tbody td { padding: 12px 16px; border-top: 1px solid #f1f5f9; vertical-align: middle; }
57
+ .pinnable-table tbody tr:first-child td { border-top: 0; }
58
+ .pinnable-table tbody tr.is-done { opacity: .6; }
59
+ .pinnable-cell-body { font-weight: 500; }
60
+ .pinnable-replies { margin-left: 6px; font-size: 11px; font-weight: 600; color: var(--pin-muted); }
61
+ .pinnable-cell-muted { color: var(--pin-muted); }
62
+ .pinnable-actions { display: flex; gap: 8px; justify-content: flex-end; }
63
+
64
+ .pinnable-status {
65
+ display: inline-block; padding: 2px 9px; border-radius: 999px;
66
+ font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .03em;
67
+ }
68
+ .pinnable-status--open { background: #fef3c7; color: #92400e; }
69
+ .pinnable-status--resolved { background: #dcfce7; color: #166534; }
70
+ .pinnable-status--wont_fix { background: #f1f5f9; color: #64748b; }
71
+
72
+ .pinnable-btn {
73
+ display: inline-block; padding: 5px 12px; border-radius: 7px; cursor: pointer;
74
+ font-size: 12px; font-weight: 600; text-decoration: none; border: 1px solid var(--pin-line);
75
+ background: #fff; color: #334155;
76
+ }
77
+ .pinnable-btn:hover { background: #f8fafc; }
78
+ .pinnable-btn--primary { background: var(--pin-accent); border-color: var(--pin-accent); color: #fff; }
79
+ .pinnable-btn--primary:hover { opacity: .92; background: var(--pin-accent); }
80
+ .pinnable-resolve { display: inline; margin: 0; }
81
+
82
+ .pinnable-empty { padding: 56px 24px; text-align: center; color: var(--pin-muted); }
83
+ .pinnable-empty strong { display: block; color: var(--pin-ink); font-size: 15px; margin-bottom: 4px; }
@@ -0,0 +1,20 @@
1
+ # Inherits the host's controller (via config) so it picks up the host's auth, CSRF, and
2
+ # layout. Every Pinnable request is gated by the host-supplied `enabled_for` predicate.
3
+ class Pinnable::ApplicationController < Pinnable.config.parent_controller.constantize
4
+ layout -> { Pinnable.config.layout }
5
+
6
+ before_action :require_pinnable_enabled
7
+
8
+ private
9
+
10
+ def pinnable_user = @pinnable_user ||= Pinnable.config.current_user.call(self)
11
+ def pinnable_tenant = @pinnable_tenant ||= Pinnable.config.tenant_scope.call(self)
12
+
13
+ # Every query starts here: pins are isolated to the host-supplied tenant (a nil tenant
14
+ # means single-tenant — its pins share a null scope and never leak across hosts).
15
+ def pinnable_pins = Pinnable::Pin.where(tenant: pinnable_tenant)
16
+
17
+ def require_pinnable_enabled
18
+ head :not_found unless Pinnable.config.enabled_for.call(pinnable_user)
19
+ end
20
+ end
@@ -0,0 +1,16 @@
1
+ module Pinnable
2
+ class CommentsController < ApplicationController
3
+ def create
4
+ render json: { public_id: comment.public_id, author_label: comment.author_label, body: comment.body }, status: :created
5
+ end
6
+
7
+ private
8
+
9
+ def comment
10
+ @comment ||= AddComment.new(pin:, author: pinnable_user, params: comment_params).call
11
+ end
12
+
13
+ def pin = pinnable_pins.find_by!(public_id: params[:pin_public_id])
14
+ def comment_params = params.require(:comment).permit(:body)
15
+ end
16
+ end
@@ -0,0 +1,12 @@
1
+ module Pinnable
2
+ class MarkersController < ApplicationController
3
+ def index
4
+ render json: markers
5
+ end
6
+
7
+ private
8
+
9
+ def markers = pins.map { |pin| MarkerSerializer.new(pin).call }
10
+ def pins = pinnable_pins.for_url(params[:url]).open.recent
11
+ end
12
+ end
@@ -0,0 +1,34 @@
1
+ module Pinnable
2
+ class PinsController < ApplicationController
3
+ def index
4
+ @pins = pinnable_pins.recent.includes(:comments)
5
+ end
6
+
7
+ def show
8
+ redirect_to "#{pin.url}?pinnable=#{pin.public_id}"
9
+ end
10
+
11
+ def create
12
+ render json: { public_id: captured_pin.public_id }, status: :created
13
+ end
14
+
15
+ def update
16
+ ResolvePin.new(pin:, by: pinnable_user, status: status_param).call
17
+ respond_to do |format|
18
+ format.json { head :no_content } # the on-page widget
19
+ format.html { redirect_to pins_path, notice: "Feedback updated." } # the inbox
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def captured_pin
26
+ @captured_pin ||= CapturePin.new(author: pinnable_user, tenant: pinnable_tenant, params: pin_params).call
27
+ end
28
+
29
+ def pin = @pin ||= pinnable_pins.find_by!(public_id: params[:public_id])
30
+
31
+ def pin_params = params.require(:pin).permit(:url, :body, anchor: {})
32
+ def status_param = params.require(:pin).permit(:status).fetch(:status)
33
+ end
34
+ end
@@ -0,0 +1,4 @@
1
+ module Pinnable
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,11 @@
1
+ module Pinnable
2
+ # The single host-facing entry point: drop `<%= pinnable_widget %>` in the layout.
3
+ # Renders nothing unless the host's gate says this user may give feedback.
4
+ module WidgetHelper
5
+ def pinnable_widget
6
+ return unless Pinnable.config.enabled_for.call(Pinnable.config.current_user.call(controller))
7
+
8
+ render "pinnable/widget"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,4 @@
1
+ module Pinnable
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module Pinnable
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module Pinnable
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end