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
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
module Pinnable::Pin::Encryption
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
# Opt-in: hosts set `config.encrypt = true` (and configure Active Record encryption).
|
|
5
|
+
# Read at load — declared after `serialize :anchor`, so encryption wraps the JSON coder.
|
|
6
|
+
included do
|
|
7
|
+
encrypts :body, :anchor if Pinnable.config.encrypt
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module Pinnable::Pin::Relationships
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
included do
|
|
5
|
+
belongs_to :author, polymorphic: true, optional: true
|
|
6
|
+
belongs_to :tenant, polymorphic: true, optional: true
|
|
7
|
+
belongs_to :resolved_by, polymorphic: true, optional: true
|
|
8
|
+
has_many :comments, class_name: "Pinnable::Comment", dependent: :destroy, inverse_of: :pin
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Pinnable::Pin::Validations
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
included do
|
|
5
|
+
validates :url, presence: true
|
|
6
|
+
validates :body, presence: true
|
|
7
|
+
validate :anchor_within_limit
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def anchor_within_limit
|
|
13
|
+
return if anchor.to_json.bytesize <= Pinnable.config.anchor_max_bytes
|
|
14
|
+
|
|
15
|
+
errors.add(:anchor, "is too large")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Pinnable
|
|
2
|
+
class Pin < ApplicationRecord
|
|
3
|
+
has_secure_token :public_id
|
|
4
|
+
serialize :anchor, coder: JSON, type: Hash
|
|
5
|
+
|
|
6
|
+
include Pin::Relationships
|
|
7
|
+
include Pin::Validations
|
|
8
|
+
include Pin::Scopes
|
|
9
|
+
include Pin::Transitions
|
|
10
|
+
include Pin::Encryption
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Pinnable
|
|
2
|
+
# The wire shape the in-page overlay reads: enough to re-anchor (anchor blob) and to
|
|
3
|
+
# show the note, never the host's User object — only its captured label.
|
|
4
|
+
class MarkerSerializer
|
|
5
|
+
def initialize(pin) = @pin = pin
|
|
6
|
+
|
|
7
|
+
def call
|
|
8
|
+
{
|
|
9
|
+
public_id: pin.public_id,
|
|
10
|
+
url: pin.url,
|
|
11
|
+
body: pin.body,
|
|
12
|
+
status: pin.status,
|
|
13
|
+
author_label: pin.author_label,
|
|
14
|
+
anchor: pin.anchor,
|
|
15
|
+
comments: comments
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
attr_reader :pin
|
|
22
|
+
|
|
23
|
+
def comments
|
|
24
|
+
pin.comments.order(:created_at).map { |c| { author_label: c.author_label, body: c.body } }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Pinnable
|
|
2
|
+
# Appends a reply to a pin, stamping the author's display label like CapturePin does.
|
|
3
|
+
class AddComment
|
|
4
|
+
def initialize(pin:, author:, params:)
|
|
5
|
+
@pin = pin
|
|
6
|
+
@author = author
|
|
7
|
+
@params = params
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call
|
|
11
|
+
pin.comments.create!(
|
|
12
|
+
author:,
|
|
13
|
+
author_label: Pinnable.config.user_label.call(author),
|
|
14
|
+
body: params[:body]
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
attr_reader :pin, :author, :params
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Pinnable
|
|
2
|
+
# Turns a capture payload into a persisted Pin, stamping the author's display label
|
|
3
|
+
# (resolved through the host's `user_label`) so the inbox never needs the host's User.
|
|
4
|
+
class CapturePin
|
|
5
|
+
def initialize(author:, tenant:, params:)
|
|
6
|
+
@author = author
|
|
7
|
+
@tenant = tenant
|
|
8
|
+
@params = params
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
Pin.create!(
|
|
13
|
+
author:,
|
|
14
|
+
tenant:,
|
|
15
|
+
author_label: Pinnable.config.user_label.call(author),
|
|
16
|
+
url: params[:url],
|
|
17
|
+
body: params[:body],
|
|
18
|
+
anchor:
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
attr_reader :author, :tenant, :params
|
|
25
|
+
|
|
26
|
+
def anchor = (params[:anchor] || {}).to_h
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Pinnable
|
|
2
|
+
# Moves a pin along its task lifecycle (open -> resolved/wont_fix and back), stamping
|
|
3
|
+
# who completed it and when, then emitting the host's audit event. Reopening clears
|
|
4
|
+
# the completion stamps so the task reads as live again.
|
|
5
|
+
class ResolvePin
|
|
6
|
+
COMPLETED = %w[resolved wont_fix].freeze
|
|
7
|
+
|
|
8
|
+
def initialize(pin:, by:, status:)
|
|
9
|
+
@pin = pin
|
|
10
|
+
@by = by
|
|
11
|
+
@status = status.to_s
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
pin.update!(status:, **completion)
|
|
16
|
+
Pinnable.config.audit.call(pin, status.to_sym, by)
|
|
17
|
+
pin
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
attr_reader :pin, :by, :status
|
|
23
|
+
|
|
24
|
+
def completion
|
|
25
|
+
return { resolved_by: nil, resolved_by_label: nil, resolved_at: nil } unless completed?
|
|
26
|
+
|
|
27
|
+
{ resolved_by: by, resolved_by_label: Pinnable.config.resolver_label.call(by), resolved_at: Time.current }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def completed? = COMPLETED.include?(status)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Feedback · Pinnable</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<%= csrf_meta_tags %>
|
|
7
|
+
<%= csp_meta_tag %>
|
|
8
|
+
<%= yield :head %>
|
|
9
|
+
<%= stylesheet_link_tag "pinnable/application", media: "all" %>
|
|
10
|
+
</head>
|
|
11
|
+
<body class="pinnable-body">
|
|
12
|
+
<header class="pinnable-topbar">
|
|
13
|
+
<span class="pinnable-topbar__dot">📌</span> Pinnable
|
|
14
|
+
</header>
|
|
15
|
+
|
|
16
|
+
<main class="pinnable-wrap">
|
|
17
|
+
<% if flash[:notice] %>
|
|
18
|
+
<div class="pinnable-flash"><%= flash[:notice] %></div>
|
|
19
|
+
<% end %>
|
|
20
|
+
<%= yield %>
|
|
21
|
+
</main>
|
|
22
|
+
</body>
|
|
23
|
+
</html>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<div
|
|
2
|
+
class="pinnable"
|
|
3
|
+
data-controller="pinnable"
|
|
4
|
+
data-pinnable-pins-url-value="<%= pinnable.pins_path %>"
|
|
5
|
+
data-pinnable-markers-url-value="<%= pinnable.markers_path %>"
|
|
6
|
+
data-pinnable-current-url-value="<%= request.path %>"
|
|
7
|
+
data-pinnable-focus-value="<%= params[:pinnable] %>"
|
|
8
|
+
data-pinnable-csrf-value="<%= form_authenticity_token %>"
|
|
9
|
+
>
|
|
10
|
+
<button
|
|
11
|
+
type="button"
|
|
12
|
+
class="pinnable-toggle"
|
|
13
|
+
data-pinnable-target="toggle"
|
|
14
|
+
data-action="pinnable#toggle"
|
|
15
|
+
>💬 Comments: Off</button>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<%= javascript_import_module_tag "pinnable" %>
|
|
19
|
+
|
|
20
|
+
<style>
|
|
21
|
+
.pinnable-toggle {
|
|
22
|
+
position: fixed; right: 16px; bottom: 16px; z-index: 2147483000;
|
|
23
|
+
padding: 8px 14px; border: 0; border-radius: 999px; cursor: pointer;
|
|
24
|
+
background: #1f2937; color: #fff; font: 500 13px system-ui, sans-serif;
|
|
25
|
+
box-shadow: 0 2px 8px rgba(0,0,0,.25);
|
|
26
|
+
}
|
|
27
|
+
.pinnable-toggle--on { background: #6366f1; }
|
|
28
|
+
.pinnable-composer {
|
|
29
|
+
position: absolute; z-index: 2147483001; width: 240px; padding: 10px;
|
|
30
|
+
background: #fff; border: 1px solid #d1d5db; border-radius: 8px;
|
|
31
|
+
box-shadow: 0 6px 20px rgba(0,0,0,.18); font: 13px system-ui, sans-serif;
|
|
32
|
+
}
|
|
33
|
+
.pinnable-composer__text { width: 100%; min-height: 64px; box-sizing: border-box; resize: vertical; }
|
|
34
|
+
.pinnable-composer__actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; }
|
|
35
|
+
.pinnable-composer__actions button { cursor: pointer; padding: 4px 10px; border-radius: 6px; border: 1px solid #d1d5db; background: #f9fafb; }
|
|
36
|
+
.pinnable-composer__save { background: #6366f1 !important; color: #fff; border-color: #6366f1 !important; }
|
|
37
|
+
.pinnable-marker {
|
|
38
|
+
position: absolute; z-index: 2147483000; transform: translate(-50%, -50%);
|
|
39
|
+
width: 22px; height: 22px; padding: 0; border: 0; border-radius: 50%;
|
|
40
|
+
background: #6366f1; color: #fff; font-size: 12px; line-height: 22px; cursor: pointer;
|
|
41
|
+
box-shadow: 0 1px 4px rgba(0,0,0,.3);
|
|
42
|
+
}
|
|
43
|
+
.pinnable-marker--flash { outline: 3px solid #f59e0b; outline-offset: 2px; }
|
|
44
|
+
.pinnable-pop {
|
|
45
|
+
position: absolute; z-index: 2147483001; width: 220px; padding: 10px;
|
|
46
|
+
background: #fff; border: 1px solid #d1d5db; border-radius: 8px;
|
|
47
|
+
box-shadow: 0 6px 20px rgba(0,0,0,.18); font: 13px system-ui, sans-serif;
|
|
48
|
+
}
|
|
49
|
+
.pinnable-pop__resolve { cursor: pointer; margin-top: 8px; padding: 4px 10px; border-radius: 6px; border: 1px solid #6366f1; background: #6366f1; color: #fff; }
|
|
50
|
+
.pinnable-pop__thread { margin: 8px 0 6px; max-height: 140px; overflow-y: auto; }
|
|
51
|
+
.pinnable-pop__comment { font-size: 12px; line-height: 1.4; margin: 4px 0; }
|
|
52
|
+
.pinnable-pop__author { font-weight: 600; margin-right: 4px; color: #4338ca; }
|
|
53
|
+
.pinnable-pop__reply { margin: 6px 0 0; }
|
|
54
|
+
.pinnable-pop__input { width: 100%; box-sizing: border-box; padding: 5px 8px; border: 1px solid #d1d5db; border-radius: 6px; font: 12px system-ui, sans-serif; }
|
|
55
|
+
.pinnable-tray {
|
|
56
|
+
position: fixed; left: 16px; bottom: 16px; z-index: 2147483000; max-width: 260px;
|
|
57
|
+
padding: 10px; background: #fff; border: 1px solid #d1d5db; border-radius: 8px;
|
|
58
|
+
box-shadow: 0 2px 8px rgba(0,0,0,.2); font: 12px system-ui, sans-serif;
|
|
59
|
+
}
|
|
60
|
+
.pinnable-tray__item { display: block; width: 100%; text-align: left; cursor: pointer; margin-top: 6px; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 6px; padding: 4px 8px; }
|
|
61
|
+
</style>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<section class="pinnable-inbox">
|
|
2
|
+
<div class="pinnable-head">
|
|
3
|
+
<h1>Feedback<% if @pins.any? %><span class="pinnable-count"><%= @pins.size %></span><% end %></h1>
|
|
4
|
+
<p>Comments left across the app. Open one to jump back to the exact element it was pinned on.</p>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<% if @pins.empty? %>
|
|
8
|
+
<div class="pinnable-card">
|
|
9
|
+
<div class="pinnable-empty">
|
|
10
|
+
<strong>No feedback yet</strong>
|
|
11
|
+
Toggle comment mode on any page, then click an element to leave the first note.
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
<% else %>
|
|
15
|
+
<div class="pinnable-card">
|
|
16
|
+
<table class="pinnable-table">
|
|
17
|
+
<thead>
|
|
18
|
+
<tr>
|
|
19
|
+
<th>Comment</th>
|
|
20
|
+
<th>Page</th>
|
|
21
|
+
<th>By</th>
|
|
22
|
+
<th>Status</th>
|
|
23
|
+
<th>When</th>
|
|
24
|
+
<th></th>
|
|
25
|
+
</tr>
|
|
26
|
+
</thead>
|
|
27
|
+
<tbody>
|
|
28
|
+
<% @pins.each do |pin| %>
|
|
29
|
+
<tr class="<%= "is-done" unless pin.open? %>">
|
|
30
|
+
<td class="pinnable-cell-body">
|
|
31
|
+
<%= pin.body %>
|
|
32
|
+
<% if pin.comments.any? %><span class="pinnable-replies">💬 <%= pin.comments.size %></span><% end %>
|
|
33
|
+
</td>
|
|
34
|
+
<td class="pinnable-cell-muted"><%= pin.url %></td>
|
|
35
|
+
<td class="pinnable-cell-muted"><%= pin.author_label %></td>
|
|
36
|
+
<td><span class="pinnable-status pinnable-status--<%= pin.status %>"><%= pin.status.tr("_", " ") %></span></td>
|
|
37
|
+
<td class="pinnable-cell-muted"><%= time_ago_in_words(pin.created_at) %> ago</td>
|
|
38
|
+
<td>
|
|
39
|
+
<div class="pinnable-actions">
|
|
40
|
+
<%= link_to "Open on page", pin_path(pin.public_id), class: "pinnable-btn" %>
|
|
41
|
+
<% if pin.open? %>
|
|
42
|
+
<%=
|
|
43
|
+
button_to "Resolve", pin_path(pin.public_id),
|
|
44
|
+
method: :patch,
|
|
45
|
+
params: { pin: { status: "resolved" } },
|
|
46
|
+
form_class: "pinnable-resolve",
|
|
47
|
+
class: "pinnable-btn pinnable-btn--primary"
|
|
48
|
+
%>
|
|
49
|
+
<% else %>
|
|
50
|
+
<%=
|
|
51
|
+
button_to "Reopen", pin_path(pin.public_id),
|
|
52
|
+
method: :patch,
|
|
53
|
+
params: { pin: { status: "open" } },
|
|
54
|
+
form_class: "pinnable-resolve",
|
|
55
|
+
class: "pinnable-btn"
|
|
56
|
+
%>
|
|
57
|
+
<% end %>
|
|
58
|
+
</div>
|
|
59
|
+
</td>
|
|
60
|
+
</tr>
|
|
61
|
+
<% end %>
|
|
62
|
+
</tbody>
|
|
63
|
+
</table>
|
|
64
|
+
</div>
|
|
65
|
+
<% end %>
|
|
66
|
+
</section>
|
data/config/importmap.rb
ADDED
data/config/routes.rb
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Portable types only (string/text/integer/datetime) — no JSONB — so the engine runs
|
|
2
|
+
# identically on SQLite, MySQL, and Postgres. The anchor blob is JSON-serialized text.
|
|
3
|
+
# author/tenant/resolved_by are polymorphic with string id columns to tolerate any host
|
|
4
|
+
# primary-key type (bigint or uuid).
|
|
5
|
+
class CreatePinnablePins < ActiveRecord::Migration[8.0]
|
|
6
|
+
def change
|
|
7
|
+
create_table :pinnable_pins do |t|
|
|
8
|
+
t.string :public_id, null: false
|
|
9
|
+
|
|
10
|
+
t.string :author_type
|
|
11
|
+
t.string :author_id
|
|
12
|
+
t.string :author_label
|
|
13
|
+
|
|
14
|
+
t.string :tenant_type
|
|
15
|
+
t.string :tenant_id
|
|
16
|
+
|
|
17
|
+
t.string :url, null: false
|
|
18
|
+
t.text :body
|
|
19
|
+
t.text :anchor
|
|
20
|
+
|
|
21
|
+
t.integer :status, null: false, default: 0
|
|
22
|
+
t.string :resolved_by_type
|
|
23
|
+
t.string :resolved_by_id
|
|
24
|
+
t.string :resolved_by_label
|
|
25
|
+
t.datetime :resolved_at
|
|
26
|
+
|
|
27
|
+
t.string :user_agent
|
|
28
|
+
|
|
29
|
+
t.timestamps
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
add_index :pinnable_pins, :public_id, unique: true
|
|
33
|
+
add_index :pinnable_pins, %i[tenant_type tenant_id]
|
|
34
|
+
add_index :pinnable_pins, :url
|
|
35
|
+
add_index :pinnable_pins, :status
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Replies on a pin — the conversation thread. Portable types; body encrypts at rest when
|
|
2
|
+
# the host opts into config.encrypt (it can quote PII just like a pin body).
|
|
3
|
+
class CreatePinnableComments < ActiveRecord::Migration[8.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :pinnable_comments do |t|
|
|
6
|
+
t.references :pin, null: false, foreign_key: { to_table: :pinnable_pins }
|
|
7
|
+
t.string :public_id, null: false
|
|
8
|
+
t.string :author_type
|
|
9
|
+
t.string :author_id
|
|
10
|
+
t.string :author_label
|
|
11
|
+
t.text :body
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
add_index :pinnable_comments, :public_id, unique: true
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require "rails/generators/base"
|
|
2
|
+
|
|
3
|
+
module Pinnable
|
|
4
|
+
module Generators
|
|
5
|
+
# `rails generate pinnable:install` — drops the config initializer and mounts the
|
|
6
|
+
# engine. Migrations are installed separately via `rails pinnable:install:migrations`
|
|
7
|
+
# (the standard engine task), kept out of here so the schema stays single-sourced.
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
def create_initializer
|
|
12
|
+
template "pinnable.rb", "config/initializers/pinnable.rb"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def mount_engine
|
|
16
|
+
route 'mount Pinnable::Engine => "/pinnable"'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def show_post_install
|
|
20
|
+
say ""
|
|
21
|
+
say "Pinnable installed. Two steps left:", :green
|
|
22
|
+
say " 1. bin/rails pinnable:install:migrations && bin/rails db:migrate"
|
|
23
|
+
say " 2. Add <%= pinnable_widget %> to your layout, just before </body>."
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Pinnable configuration. Everything app-specific lives here; the engine assumes none of it.
|
|
2
|
+
Pinnable.configure do |c|
|
|
3
|
+
# The gate. Return false and the widget never renders and every endpoint 404s.
|
|
4
|
+
c.enabled_for = ->(user) { user&.admin? }
|
|
5
|
+
|
|
6
|
+
# How to find the current user from a controller (whatever your auth uses).
|
|
7
|
+
c.current_user = ->(controller) { controller.current_user }
|
|
8
|
+
|
|
9
|
+
# How to label a user in the inbox. No host User object is stored — only this label.
|
|
10
|
+
c.user_label = ->(user) { user.try(:email) || user.try(:name) || user.to_s }
|
|
11
|
+
|
|
12
|
+
# Engine controllers inherit this, picking up your auth, CSRF, and layout.
|
|
13
|
+
c.parent_controller = "ApplicationController"
|
|
14
|
+
|
|
15
|
+
# Optional: scope pins to a tenant (account/org). Return nil for a single-tenant app.
|
|
16
|
+
# c.tenant_scope = ->(controller) { controller.current_account }
|
|
17
|
+
|
|
18
|
+
# Optional: a sink for status changes (open -> resolved/wont_fix and back).
|
|
19
|
+
# c.audit = ->(pin, event, by) { Rails.logger.info("pinnable #{event} #{pin.public_id}") }
|
|
20
|
+
|
|
21
|
+
# Optional: encrypt body + anchor at rest (requires Active Record encryption configured).
|
|
22
|
+
# c.encrypt = true
|
|
23
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Pinnable
|
|
2
|
+
# The single seam between the engine and its host. Defaults are safe-off: with no
|
|
3
|
+
# configuration the widget never renders and every endpoint 404s, so a host opts in
|
|
4
|
+
# deliberately rather than by accident.
|
|
5
|
+
class Configuration
|
|
6
|
+
attr_accessor :enabled_for, :current_user, :tenant_scope, :user_label,
|
|
7
|
+
:resolver_label, :parent_controller, :encrypt, :audit, :anchor_max_bytes, :layout
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@enabled_for = ->(_user) { false } # the gate
|
|
11
|
+
@current_user = ->(_controller) { nil } # host's auth
|
|
12
|
+
@tenant_scope = ->(_controller) { nil } # optional multitenancy
|
|
13
|
+
@user_label = ->(user) { user.try(:email) || user.try(:name) || user.to_s }
|
|
14
|
+
@resolver_label = @user_label
|
|
15
|
+
@parent_controller = "ActionController::Base" # inherit host auth/CSRF/layout
|
|
16
|
+
@encrypt = false # opt-in AR encryption of body/anchor
|
|
17
|
+
@audit = ->(_pin, _event, _by) {} # optional sink
|
|
18
|
+
@anchor_max_bytes = 50_000
|
|
19
|
+
@layout = "pinnable/application" # host sets its own for native chrome
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Pinnable
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace Pinnable
|
|
4
|
+
|
|
5
|
+
# Expose the host-facing helper (`<%= pinnable_widget %>`) in the host's views,
|
|
6
|
+
# regardless of the host's `include_all_helpers` setting.
|
|
7
|
+
initializer "pinnable.helpers" do
|
|
8
|
+
ActiveSupport.on_load(:action_controller_base) { helper Pinnable::WidgetHelper }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Merge the engine's pins into the host import map so `import "pinnable"` resolves.
|
|
12
|
+
# No-op on hosts that don't use importmap-rails (they include the asset themselves).
|
|
13
|
+
initializer "pinnable.importmap", before: "importmap" do |app|
|
|
14
|
+
next unless app.config.respond_to?(:importmap)
|
|
15
|
+
|
|
16
|
+
app.config.importmap.paths << root.join("config/importmap.rb")
|
|
17
|
+
app.config.importmap.cache_sweepers << root.join("app/assets/javascripts")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/pinnable.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require "pinnable/version"
|
|
2
|
+
require "pinnable/configuration"
|
|
3
|
+
require "pinnable/engine"
|
|
4
|
+
|
|
5
|
+
# Pinnable — a host-agnostic, mountable visual-feedback layer. Enable it for some
|
|
6
|
+
# users, flip a toggle, click any element on any page, leave a note. Notes remember
|
|
7
|
+
# who/where/which-element, re-anchor on reload, and are worked like a task list.
|
|
8
|
+
#
|
|
9
|
+
# Everything the host differs on — auth, the gate, multitenancy, the audit sink — is
|
|
10
|
+
# injected through `Pinnable.config`, so the engine carries no app-specific coupling.
|
|
11
|
+
module Pinnable
|
|
12
|
+
class << self
|
|
13
|
+
def config = @config ||= Configuration.new
|
|
14
|
+
def configure = yield config
|
|
15
|
+
def reset_config! = @config = Configuration.new
|
|
16
|
+
end
|
|
17
|
+
end
|