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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +18 -0
- data/MIT-LICENSE +20 -0
- data/README.md +100 -0
- data/Rakefile +8 -0
- data/app/assets/javascripts/pinnable.js +308 -0
- data/app/assets/stylesheets/pinnable/application.css +83 -0
- data/app/controllers/pinnable/application_controller.rb +20 -0
- data/app/controllers/pinnable/comments_controller.rb +16 -0
- data/app/controllers/pinnable/markers_controller.rb +12 -0
- data/app/controllers/pinnable/pins_controller.rb +34 -0
- data/app/helpers/pinnable/application_helper.rb +4 -0
- data/app/helpers/pinnable/widget_helper.rb +11 -0
- data/app/jobs/pinnable/application_job.rb +4 -0
- data/app/mailers/pinnable/application_mailer.rb +6 -0
- data/app/models/pinnable/application_record.rb +5 -0
- data/app/models/pinnable/comment/encryption.rb +8 -0
- data/app/models/pinnable/comment/relationships.rb +8 -0
- data/app/models/pinnable/comment/validations.rb +7 -0
- data/app/models/pinnable/comment.rb +9 -0
- data/app/models/pinnable/pin/encryption.rb +9 -0
- data/app/models/pinnable/pin/relationships.rb +10 -0
- data/app/models/pinnable/pin/scopes.rb +8 -0
- data/app/models/pinnable/pin/transitions.rb +7 -0
- data/app/models/pinnable/pin/validations.rb +17 -0
- data/app/models/pinnable/pin.rb +12 -0
- data/app/serializers/pinnable/marker_serializer.rb +27 -0
- data/app/services/pinnable/add_comment.rb +22 -0
- data/app/services/pinnable/capture_pin.rb +28 -0
- data/app/services/pinnable/resolve_pin.rb +32 -0
- data/app/views/layouts/pinnable/application.html.erb +23 -0
- data/app/views/pinnable/_widget.html.erb +61 -0
- data/app/views/pinnable/pins/index.html.erb +66 -0
- data/config/importmap.rb +3 -0
- data/config/routes.rb +7 -0
- data/db/migrate/20260101000010_create_pinnable_pins.rb +37 -0
- data/db/migrate/20260101000030_create_pinnable_comments.rb +17 -0
- data/lib/generators/pinnable/install/install_generator.rb +27 -0
- data/lib/generators/pinnable/install/templates/pinnable.rb +23 -0
- data/lib/pinnable/configuration.rb +22 -0
- data/lib/pinnable/engine.rb +20 -0
- data/lib/pinnable/version.rb +3 -0
- data/lib/pinnable.rb +17 -0
- data/lib/tasks/pinnable_tasks.rake +4 -0
- 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,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,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
|