rails-markup 1.2.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/LICENSE +21 -0
- data/README.md +304 -0
- data/app/assets/javascripts/rails_markup/toolbar.js +2225 -0
- data/app/controllers/rails_markup/annotations_controller.rb +244 -0
- data/app/controllers/rails_markup/application_controller.rb +7 -0
- data/app/controllers/rails_markup/dashboard_controller.rb +230 -0
- data/app/controllers/rails_markup/external/annotations_controller.rb +68 -0
- data/app/models/rails_markup/annotation.rb +176 -0
- data/app/views/layouts/rails_markup/application.html.erb +158 -0
- data/app/views/rails_markup/dashboard/_annotation_card.html.erb +25 -0
- data/app/views/rails_markup/dashboard/_annotation_list.html.erb +57 -0
- data/app/views/rails_markup/dashboard/_annotation_page.html.erb +13 -0
- data/app/views/rails_markup/dashboard/_filters.html.erb +75 -0
- data/app/views/rails_markup/dashboard/_stats.html.erb +22 -0
- data/app/views/rails_markup/dashboard/_styles.html.erb +115 -0
- data/app/views/rails_markup/dashboard/board.html.erb +135 -0
- data/app/views/rails_markup/dashboard/index.html.erb +38 -0
- data/app/views/rails_markup/dashboard/show.html.erb +129 -0
- data/app/views/rails_markup/shared/_toolbar.html.erb +28 -0
- data/bin/rails-markup +5 -0
- data/config/routes.rb +45 -0
- data/db/migrate/20260720000000_add_client_uuid_to_rails_markup_annotations.rb +13 -0
- data/db/migrate/20260721000000_backfill_rails_markup_client_uuids.rb +17 -0
- data/lib/generators/rails_markup/install/templates/auth_controller.rb.erb +17 -0
- data/lib/generators/rails_markup/install/templates/bin_markup.erb +5 -0
- data/lib/generators/rails_markup/install/templates/create_rails_markup_annotations.rb.erb +27 -0
- data/lib/generators/rails_markup/install/templates/initializer.rb.erb +54 -0
- data/lib/generators/rails_markup/install_generator.rb +167 -0
- data/lib/generators/rails_markup/uninstall_generator.rb +110 -0
- data/lib/rails_markup/cli/base.rb +8 -0
- data/lib/rails_markup/cli/initializer_writer.rb +54 -0
- data/lib/rails_markup/cli/setup_wizard.rb +229 -0
- data/lib/rails_markup/cli.rb +831 -0
- data/lib/rails_markup/client_uuid_maintenance.rb +174 -0
- data/lib/rails_markup/configuration.rb +160 -0
- data/lib/rails_markup/engine.rb +18 -0
- data/lib/rails_markup/http_server.rb +226 -0
- data/lib/rails_markup/http_store_proxy.rb +122 -0
- data/lib/rails_markup/mcp_config.rb +258 -0
- data/lib/rails_markup/mcp_server.rb +639 -0
- data/lib/rails_markup/server.rb +73 -0
- data/lib/rails_markup/store.rb +219 -0
- data/lib/rails_markup/version.rb +5 -0
- data/lib/rails_markup.rb +11 -0
- data/lib/tasks/rails_markup_client_uuids.rake +19 -0
- metadata +198 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<% content_for(:title) { "Board" } %>
|
|
2
|
+
<% content_for(:page_title) { "Annotations Board" } %>
|
|
3
|
+
|
|
4
|
+
<%= render "styles" %>
|
|
5
|
+
|
|
6
|
+
<%# View toggle %>
|
|
7
|
+
<div class="rm-filters">
|
|
8
|
+
<a href="<%= rails_markup.root_path %>" class="rm-pill">List</a>
|
|
9
|
+
<a href="<%= rails_markup.board_path %>" class="rm-pill rm-pill-active">Board</a>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<div class="rm-board">
|
|
13
|
+
<% column_config = {
|
|
14
|
+
pending: { label: "Pending", color: "#f59e0b", bg: "#fffbeb", border: "#fde68a" },
|
|
15
|
+
acknowledged: { label: "Acknowledged", color: "#3b82f6", bg: "#eff6ff", border: "#bfdbfe" },
|
|
16
|
+
resolved: { label: "Resolved", color: "#10b981", bg: "#ecfdf5", border: "#a7f3d0" },
|
|
17
|
+
dismissed: { label: "Dismissed", color: "#9ca3af", bg: "#f9fafb", border: "#e5e7eb" }
|
|
18
|
+
} %>
|
|
19
|
+
|
|
20
|
+
<% column_config.each do |status, config| %>
|
|
21
|
+
<div class="rm-board-column" data-status="<%= status %>"
|
|
22
|
+
style="border-top: 3px solid <%= config[:color] %>;">
|
|
23
|
+
<div class="rm-board-column-header" style="background: <%= config[:bg] %>; color: <%= config[:color] %>;">
|
|
24
|
+
<span><%= config[:label] %></span>
|
|
25
|
+
<span style="font-size:12px;font-weight:400;"><%= @columns[status].size %></span>
|
|
26
|
+
</div>
|
|
27
|
+
<div class="rm-board-cards">
|
|
28
|
+
<% @columns[status].each do |annotation| %>
|
|
29
|
+
<div class="rm-board-card" draggable="true" data-annotation-id="<%= annotation.id %>">
|
|
30
|
+
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;">
|
|
31
|
+
<span class="rm-badge rm-intent-<%= annotation.intent %>" style="font-size:10px;"><%= annotation.intent %></span>
|
|
32
|
+
<% if annotation.severity != "suggestion" %>
|
|
33
|
+
<span class="rm-badge rm-severity-<%= annotation.severity %>" style="font-size:10px;"><%= annotation.severity %></span>
|
|
34
|
+
<% end %>
|
|
35
|
+
<% if annotation.author_name.present? %>
|
|
36
|
+
<span style="font-size:10px;color:#9ca3af;margin-left:auto;"><%= annotation.author_name %></span>
|
|
37
|
+
<% end %>
|
|
38
|
+
</div>
|
|
39
|
+
<div style="font-size:13px;line-height:1.5;color:#374151;"><%= truncate(annotation.content, length: 120) %></div>
|
|
40
|
+
<div style="margin-top:6px;font-size:10px;color:#d1d5db;">
|
|
41
|
+
<code style="font-size:10px;"><%= truncate(annotation.page_url, length: 30) %></code>
|
|
42
|
+
·
|
|
43
|
+
<span title="<%= annotation.created_at.strftime("%B %d, %Y at %H:%M") %>"><%= time_ago_in_words(annotation.created_at) %></span>
|
|
44
|
+
</div>
|
|
45
|
+
<%# Touch fallback for drag-and-drop: pick a status directly %>
|
|
46
|
+
<select class="rm-board-move" data-annotation-id="<%= annotation.id %>"
|
|
47
|
+
aria-label="Move annotation to status"
|
|
48
|
+
style="margin-top:8px;width:100%;font-size:11px;padding:4px;border:1px solid #e5e7eb;border-radius:6px;background:#fff;color:#6b7280;">
|
|
49
|
+
<% %w[pending acknowledged resolved dismissed].each do |s| %>
|
|
50
|
+
<option value="<%= s %>" <%= "selected" if annotation.status == s %>><%= s.capitalize %></option>
|
|
51
|
+
<% end %>
|
|
52
|
+
</select>
|
|
53
|
+
</div>
|
|
54
|
+
<% end %>
|
|
55
|
+
<% if @columns[status].empty? %>
|
|
56
|
+
<div style="text-align:center;padding:24px 8px;color:#d1d5db;font-size:12px;">No items</div>
|
|
57
|
+
<% end %>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
<% end %>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<script>
|
|
64
|
+
(function() {
|
|
65
|
+
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
|
|
66
|
+
|
|
67
|
+
document.querySelectorAll('.rm-board-card').forEach(card => {
|
|
68
|
+
card.addEventListener('dragstart', (e) => {
|
|
69
|
+
e.dataTransfer.setData('text/plain', card.dataset.annotationId);
|
|
70
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
71
|
+
card.style.opacity = '0.5';
|
|
72
|
+
});
|
|
73
|
+
card.addEventListener('dragend', () => { card.style.opacity = '1'; });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
document.querySelectorAll('.rm-board-column').forEach(col => {
|
|
77
|
+
col.addEventListener('dragover', (e) => {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
e.dataTransfer.dropEffect = 'move';
|
|
80
|
+
col.classList.add('rm-drag-over');
|
|
81
|
+
});
|
|
82
|
+
col.addEventListener('dragleave', (e) => {
|
|
83
|
+
if (!col.contains(e.relatedTarget)) col.classList.remove('rm-drag-over');
|
|
84
|
+
});
|
|
85
|
+
col.addEventListener('drop', (e) => {
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
col.classList.remove('rm-drag-over');
|
|
88
|
+
const id = e.dataTransfer.getData('text/plain');
|
|
89
|
+
moveCard(id, col.dataset.status);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Touch fallback: status <select> on each card
|
|
94
|
+
document.querySelectorAll('.rm-board-move').forEach(select => {
|
|
95
|
+
select.addEventListener('change', () => {
|
|
96
|
+
moveCard(select.dataset.annotationId, select.value);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
function moveCard(id, newStatus) {
|
|
101
|
+
const card = document.querySelector('[data-annotation-id="' + id + '"]');
|
|
102
|
+
if (!card || card.closest('[data-status]').dataset.status === newStatus) return;
|
|
103
|
+
|
|
104
|
+
// Optimistically move the card to the target column, then reconcile.
|
|
105
|
+
const targetCol = document.querySelector('.rm-board-column[data-status="' + newStatus + '"]');
|
|
106
|
+
if (targetCol) targetCol.querySelector('.rm-board-cards').appendChild(card);
|
|
107
|
+
const select = card.querySelector('.rm-board-move');
|
|
108
|
+
if (select) select.value = newStatus;
|
|
109
|
+
updateColumnCounts();
|
|
110
|
+
|
|
111
|
+
const basePath = window.location.pathname.replace(/\/board\/?$/, '');
|
|
112
|
+
fetch(basePath + '/annotations/' + id, {
|
|
113
|
+
method: 'PATCH',
|
|
114
|
+
headers: {
|
|
115
|
+
'Content-Type': 'application/json',
|
|
116
|
+
'X-CSRF-Token': csrfToken || ''
|
|
117
|
+
},
|
|
118
|
+
body: JSON.stringify({ action_type: 'transition', status: newStatus })
|
|
119
|
+
}).then((resp) => {
|
|
120
|
+
// A rejected transition (422/401/…) resolves without throwing — resync.
|
|
121
|
+
if (!resp.ok) window.location.reload();
|
|
122
|
+
}).catch(() => {
|
|
123
|
+
window.location.reload();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function updateColumnCounts() {
|
|
128
|
+
document.querySelectorAll('.rm-board-column').forEach(col => {
|
|
129
|
+
const count = col.querySelectorAll('.rm-board-card').length;
|
|
130
|
+
const countEl = col.querySelector('.rm-board-column-header span:last-child');
|
|
131
|
+
if (countEl) countEl.textContent = count;
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
})();
|
|
135
|
+
</script>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<% content_for(:title) { "Dashboard" } %>
|
|
2
|
+
<% content_for(:page_title) { "Annotations" } %>
|
|
3
|
+
|
|
4
|
+
<%= render "styles" %>
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
<div class="rm-layout">
|
|
8
|
+
<div class="rm-main">
|
|
9
|
+
<turbo-frame id="annotations-content">
|
|
10
|
+
<%= render "stats" %>
|
|
11
|
+
<%= render "filters" %>
|
|
12
|
+
<%= render "annotation_list" %>
|
|
13
|
+
</turbo-frame>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<turbo-frame id="detail-panel" class="rm-panel rm-panel-empty">
|
|
17
|
+
</turbo-frame>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<script>
|
|
21
|
+
document.addEventListener("turbo:frame-load", (e) => {
|
|
22
|
+
if (e.target.id !== "detail-panel") return;
|
|
23
|
+
// Show panel (hidden when empty)
|
|
24
|
+
e.target.classList.remove("rm-panel-empty");
|
|
25
|
+
// Highlight active card
|
|
26
|
+
document.querySelectorAll(".rm-card-active").forEach(c => c.classList.remove("rm-card-active"));
|
|
27
|
+
const src = e.target.getAttribute("src") || "";
|
|
28
|
+
const match = src.match(/annotations\/(\d+)/);
|
|
29
|
+
if (match) {
|
|
30
|
+
const card = document.querySelector(`[data-annotation-id="${match[1]}"]`);
|
|
31
|
+
if (card) card.classList.add("rm-card-active");
|
|
32
|
+
}
|
|
33
|
+
// Scroll to panel on mobile
|
|
34
|
+
if (window.innerWidth <= 900) {
|
|
35
|
+
e.target.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
</script>
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
<% content_for(:title) { "Annotation ##{@annotation.id}" } %>
|
|
2
|
+
|
|
3
|
+
<%= render "styles" %>
|
|
4
|
+
|
|
5
|
+
<turbo-frame id="detail-panel">
|
|
6
|
+
<div class="rm-detail">
|
|
7
|
+
<%# Close button %>
|
|
8
|
+
<button type="button" class="rm-back" onclick="var f=this.closest('turbo-frame'); f.innerHTML=''; f.classList.add('rm-panel-empty'); document.querySelectorAll('.rm-card-active').forEach(c => c.classList.remove('rm-card-active'));">✕ Close</button>
|
|
9
|
+
|
|
10
|
+
<%# Header badges %>
|
|
11
|
+
<div class="rm-detail-header">
|
|
12
|
+
<span class="rm-dot rm-dot-<%= @annotation.status %>"></span>
|
|
13
|
+
<span class="rm-badge rm-status-<%= @annotation.status %>"><%= @annotation.status %></span>
|
|
14
|
+
<span class="rm-badge rm-intent-<%= @annotation.intent %>"><%= @annotation.intent %></span>
|
|
15
|
+
<span class="rm-badge rm-severity-<%= @annotation.severity %>"><%= @annotation.severity %></span>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<%# Content %>
|
|
19
|
+
<div class="rm-detail-content"><%= @annotation.content %></div>
|
|
20
|
+
|
|
21
|
+
<%# Metadata %>
|
|
22
|
+
<dl class="rm-detail-meta">
|
|
23
|
+
<dt>Page</dt>
|
|
24
|
+
<dd><code><%= @annotation.page_url %></code></dd>
|
|
25
|
+
|
|
26
|
+
<% if @annotation.selected_text.present? %>
|
|
27
|
+
<dt>Selected text</dt>
|
|
28
|
+
<dd>“<%= truncate(@annotation.selected_text, length: 200) %>”</dd>
|
|
29
|
+
<% end %>
|
|
30
|
+
|
|
31
|
+
<% if @annotation.target.present? && @annotation.target["selector"].present? %>
|
|
32
|
+
<dt>Element</dt>
|
|
33
|
+
<dd><code><%= @annotation.target["selector"] %></code></dd>
|
|
34
|
+
<% end %>
|
|
35
|
+
|
|
36
|
+
<% if @annotation.target.present? && @annotation.target["boundingBox"].present? %>
|
|
37
|
+
<dt>Position</dt>
|
|
38
|
+
<dd><%= @annotation.target["boundingBox"].values_at("x", "y", "width", "height").compact.join(" \u00D7 ") %></dd>
|
|
39
|
+
<% end %>
|
|
40
|
+
|
|
41
|
+
<% if @annotation.author_name.present? %>
|
|
42
|
+
<dt>Author</dt>
|
|
43
|
+
<dd><%= @annotation.author_name %></dd>
|
|
44
|
+
<% end %>
|
|
45
|
+
|
|
46
|
+
<dt>Created</dt>
|
|
47
|
+
<dd><span title="<%= @annotation.created_at.strftime("%B %d, %Y at %H:%M:%S") %>"><%= time_ago_in_words(@annotation.created_at) %> ago</span></dd>
|
|
48
|
+
|
|
49
|
+
<% if @annotation.updated_at != @annotation.created_at %>
|
|
50
|
+
<dt>Updated</dt>
|
|
51
|
+
<dd><span title="<%= @annotation.updated_at.strftime("%B %d, %Y at %H:%M:%S") %>"><%= time_ago_in_words(@annotation.updated_at) %> ago</span></dd>
|
|
52
|
+
<% end %>
|
|
53
|
+
|
|
54
|
+
<% if @annotation.metadata&.dig("screenshot").present? %>
|
|
55
|
+
<dt>Screenshot</dt>
|
|
56
|
+
<dd><img src="<%= @annotation.metadata["screenshot"] %>" style="max-width:100%;border:1px solid #e5e7eb;border-radius:8px;" alt="Element screenshot"></dd>
|
|
57
|
+
<% end %>
|
|
58
|
+
</dl>
|
|
59
|
+
|
|
60
|
+
<%# Actions (only for non-terminal states) %>
|
|
61
|
+
<% if @annotation.status.in?(%w[pending acknowledged]) %>
|
|
62
|
+
<div class="rm-actions">
|
|
63
|
+
<% if @annotation.status == "pending" %>
|
|
64
|
+
<%= form_tag(rails_markup.annotation_path(@annotation, action_type: "acknowledge"), method: :patch, style: "display:inline") do %>
|
|
65
|
+
<button type="submit" class="rm-btn rm-btn-primary">Acknowledge</button>
|
|
66
|
+
<% end %>
|
|
67
|
+
<% end %>
|
|
68
|
+
|
|
69
|
+
<button type="button" class="rm-btn rm-btn-success" onclick="document.getElementById('resolve-form-<%= @annotation.id %>').style.display='block'">Resolve</button>
|
|
70
|
+
<button type="button" class="rm-btn rm-btn-danger" onclick="document.getElementById('dismiss-form-<%= @annotation.id %>').style.display='block'">Dismiss</button>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<%# Resolve form %>
|
|
74
|
+
<div id="resolve-form-<%= @annotation.id %>" style="display:none; margin-top: 16px;">
|
|
75
|
+
<%= form_tag(rails_markup.annotation_path(@annotation, action_type: "resolve"), method: :patch) do %>
|
|
76
|
+
<div class="rm-form-group">
|
|
77
|
+
<label for="summary">Resolution summary (optional)</label>
|
|
78
|
+
<textarea name="summary" id="summary" class="rm-input" rows="2" placeholder="What was done to resolve this?"></textarea>
|
|
79
|
+
</div>
|
|
80
|
+
<button type="submit" class="rm-btn rm-btn-success">Confirm Resolve</button>
|
|
81
|
+
<button type="button" class="rm-btn" onclick="this.parentElement.parentElement.style.display='none'">Cancel</button>
|
|
82
|
+
<% end %>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<%# Dismiss form %>
|
|
86
|
+
<div id="dismiss-form-<%= @annotation.id %>" style="display:none; margin-top: 16px;">
|
|
87
|
+
<%= form_tag(rails_markup.annotation_path(@annotation, action_type: "dismiss"), method: :patch) do %>
|
|
88
|
+
<div class="rm-form-group">
|
|
89
|
+
<label for="reason">Dismiss reason (optional)</label>
|
|
90
|
+
<textarea name="reason" id="reason" class="rm-input" rows="2" placeholder="Why is this being dismissed?"></textarea>
|
|
91
|
+
</div>
|
|
92
|
+
<button type="submit" class="rm-btn rm-btn-danger">Confirm Dismiss</button>
|
|
93
|
+
<button type="button" class="rm-btn" onclick="this.parentElement.parentElement.style.display='none'">Cancel</button>
|
|
94
|
+
<% end %>
|
|
95
|
+
</div>
|
|
96
|
+
<% end %>
|
|
97
|
+
|
|
98
|
+
<%# Thread %>
|
|
99
|
+
<% if @annotation.thread.any? %>
|
|
100
|
+
<div class="rm-thread">
|
|
101
|
+
<h3>Thread (<%= @annotation.thread.size %>)</h3>
|
|
102
|
+
<% @annotation.thread.each do |entry| %>
|
|
103
|
+
<div class="rm-thread-entry rm-thread-<%= entry['role'] || 'user' %>">
|
|
104
|
+
<div class="rm-thread-role"><%= entry["role"] || "user" %></div>
|
|
105
|
+
<div><%= entry["message"] %></div>
|
|
106
|
+
<div class="rm-thread-time"><%= entry["timestamp"] %></div>
|
|
107
|
+
</div>
|
|
108
|
+
<% end %>
|
|
109
|
+
</div>
|
|
110
|
+
<% end %>
|
|
111
|
+
|
|
112
|
+
<%# Reply form %>
|
|
113
|
+
<div class="rm-thread" style="<%= @annotation.thread.empty? ? 'margin-top: 24px; border-top: 1px solid #e5e7eb; padding-top: 20px;' : '' %>">
|
|
114
|
+
<h3>Add reply</h3>
|
|
115
|
+
<%= form_tag(rails_markup.annotation_path(@annotation, action_type: "reply"), method: :patch) do %>
|
|
116
|
+
<div class="rm-form-group">
|
|
117
|
+
<textarea name="message" class="rm-input" rows="2" placeholder="Type a reply..." required></textarea>
|
|
118
|
+
</div>
|
|
119
|
+
<div class="rm-form-group">
|
|
120
|
+
<select name="role" class="rm-select">
|
|
121
|
+
<option value="agent">Agent</option>
|
|
122
|
+
<option value="user">User</option>
|
|
123
|
+
</select>
|
|
124
|
+
</div>
|
|
125
|
+
<button type="submit" class="rm-btn rm-btn-primary">Reply</button>
|
|
126
|
+
<% end %>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</turbo-frame>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<%# Rails Markup annotation toolbar — self-contained, no Tailwind/Stimulus dependency %>
|
|
2
|
+
<% if RailsMarkup.config.toolbar_enabled %>
|
|
3
|
+
<script>
|
|
4
|
+
<%== File.read(File.expand_path("../../../assets/javascripts/rails_markup/toolbar.js", __dir__)) %>
|
|
5
|
+
</script>
|
|
6
|
+
<script>
|
|
7
|
+
(function() {
|
|
8
|
+
if (window.__rmToolbarBound) return;
|
|
9
|
+
window.__rmToolbarBound = true;
|
|
10
|
+
|
|
11
|
+
var opts = {
|
|
12
|
+
endpoint: "<%= rails_markup.root_path.chomp("/") %>/api",
|
|
13
|
+
accent: "<%= j(RailsMarkup.config.toolbar_accent) %>",
|
|
14
|
+
position: "<%= j(RailsMarkup.config.toolbar_position) %>",
|
|
15
|
+
size: "<%= j(RailsMarkup.config.toolbar_size) %>",
|
|
16
|
+
fabVisible: <%= RailsMarkup.config.fab_visible ? "true" : "false" %>,
|
|
17
|
+
enableScreenshots: <%= RailsMarkup.config.enable_screenshots ? "true" : "false" %>,
|
|
18
|
+
healthInterval: <%= RailsMarkup.config.health_interval %>
|
|
19
|
+
};
|
|
20
|
+
// Init immediately (DOM is ready — script is at end of body)
|
|
21
|
+
RailsMarkupToolbar.init(opts);
|
|
22
|
+
// Re-init after Turbo Drive navigations (DOMContentLoaded won't fire again)
|
|
23
|
+
document.addEventListener("turbo:load", function() {
|
|
24
|
+
RailsMarkupToolbar.init(opts);
|
|
25
|
+
});
|
|
26
|
+
})();
|
|
27
|
+
</script>
|
|
28
|
+
<% end %>
|
data/bin/rails-markup
ADDED
data/config/routes.rb
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RailsMarkup::Engine.routes.draw do
|
|
4
|
+
# Dashboard
|
|
5
|
+
root to: "dashboard#index"
|
|
6
|
+
resources :annotations, only: [:show, :update], controller: "dashboard", constraints: { id: /\d+/ }
|
|
7
|
+
post "dismiss_all", to: "dashboard#dismiss_all"
|
|
8
|
+
get "load_more", to: "dashboard#load_more", as: :load_more
|
|
9
|
+
get "board", to: "dashboard#board"
|
|
10
|
+
get "export.csv", to: "dashboard#export_csv", as: :export_csv
|
|
11
|
+
get "export.json", to: "dashboard#export_json", as: :export_json
|
|
12
|
+
|
|
13
|
+
# Toolbar API (same-origin, used by browser toolbar)
|
|
14
|
+
scope :api, defaults: { format: :json } do
|
|
15
|
+
post "sessions", to: "annotations#create_session"
|
|
16
|
+
post "sessions/:session_id/annotations", to: "annotations#create"
|
|
17
|
+
get "health", to: "annotations#health"
|
|
18
|
+
|
|
19
|
+
get "annotations", to: "annotations#index"
|
|
20
|
+
put "annotations/:client_uuid", to: "annotations#upsert"
|
|
21
|
+
delete "annotations/:client_uuid", to: "annotations#destroy"
|
|
22
|
+
|
|
23
|
+
scope "annotations/:id", constraints: { id: /\d+/ } do
|
|
24
|
+
post "acknowledge", to: "annotations#acknowledge"
|
|
25
|
+
post "resolve", to: "annotations#resolve"
|
|
26
|
+
post "dismiss", to: "annotations#dismiss"
|
|
27
|
+
post "reply", to: "annotations#reply"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# External API (token-authenticated, used by MCP/CLI tools)
|
|
32
|
+
# Paths: /external/pending, /external/:id, /external/:id/{action}
|
|
33
|
+
# Full URL (with mount): /admin/annotations/external/pending
|
|
34
|
+
namespace :external, defaults: { format: :json } do
|
|
35
|
+
get "pending", to: "annotations#pending"
|
|
36
|
+
get ":id", to: "annotations#show", constraints: { id: /\d+/ }
|
|
37
|
+
|
|
38
|
+
scope ":id", constraints: { id: /\d+/ } do
|
|
39
|
+
patch "acknowledge", to: "annotations#acknowledge"
|
|
40
|
+
patch "resolve", to: "annotations#resolve"
|
|
41
|
+
patch "dismiss", to: "annotations#dismiss"
|
|
42
|
+
patch "reply", to: "annotations#reply"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddClientUuidToRailsMarkupAnnotations < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
# Honor a custom config.table_name — hardcoding the default silently skips
|
|
6
|
+
# the migration (and defeats create-dedup) on installs that renamed the table.
|
|
7
|
+
table = RailsMarkup.config.table_name.to_sym
|
|
8
|
+
return unless table_exists?(table)
|
|
9
|
+
|
|
10
|
+
add_column table, :client_uuid, :string, limit: 64 unless column_exists?(table, :client_uuid)
|
|
11
|
+
add_index table, :client_uuid, unique: true unless index_exists?(table, :client_uuid, unique: true)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails_markup/client_uuid_maintenance"
|
|
4
|
+
|
|
5
|
+
class BackfillRailsMarkupClientUuids < ActiveRecord::Migration[7.0]
|
|
6
|
+
def up
|
|
7
|
+
RailsMarkup::ClientUuidMaintenance.repair!(
|
|
8
|
+
connection:,
|
|
9
|
+
table_name: RailsMarkup.config.table_name
|
|
10
|
+
)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# The expand/backfill release deliberately leaves the column nullable while
|
|
14
|
+
# old application instances may still write rows. A later contract migration
|
|
15
|
+
# adds NOT NULL only after repair + verification and old-instance shutdown.
|
|
16
|
+
def down; end
|
|
17
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Base controller for Rails Markup engine.
|
|
4
|
+
# Inherits host authentication from <%= options[:base_controller] %> and denies
|
|
5
|
+
# access unless the conventional current_user.admin? contract is satisfied.
|
|
6
|
+
# Customize authorize_rails_markup! when your application uses another policy.
|
|
7
|
+
class RailsMarkupAuthController < <%= options[:base_controller] %>
|
|
8
|
+
before_action :authorize_rails_markup!
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def authorize_rails_markup!
|
|
13
|
+
return if respond_to?(:current_user, true) && current_user&.respond_to?(:admin?) && current_user.admin?
|
|
14
|
+
|
|
15
|
+
head :forbidden
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
class CreateRailsMarkupAnnotations < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
# jsonb on PostgreSQL; json elsewhere (SQLite/MySQL have no jsonb type).
|
|
4
|
+
json_type = connection.adapter_name.downcase.include?("postgres") ? :jsonb : :json
|
|
5
|
+
|
|
6
|
+
create_table :<%= options[:table_name] %> do |t|
|
|
7
|
+
t.bigint :user_id
|
|
8
|
+
t.string :page_url, null: false
|
|
9
|
+
t.send json_type, :target, default: {}
|
|
10
|
+
t.text :content, null: false
|
|
11
|
+
t.string :intent, null: false, default: "change"
|
|
12
|
+
t.string :severity, null: false, default: "suggestion"
|
|
13
|
+
t.string :status, null: false, default: "pending"
|
|
14
|
+
t.text :selected_text
|
|
15
|
+
t.send json_type, :metadata, default: {}
|
|
16
|
+
t.send json_type, :thread, default: []
|
|
17
|
+
t.string :client_uuid, limit: 64, null: false
|
|
18
|
+
|
|
19
|
+
t.timestamps
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
add_index :<%= options[:table_name] %>, [:status, :created_at]
|
|
23
|
+
add_index :<%= options[:table_name] %>, :page_url
|
|
24
|
+
add_index :<%= options[:table_name] %>, :user_id
|
|
25
|
+
add_index :<%= options[:table_name] %>, :client_uuid, unique: true
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RailsMarkup.configure do |config|
|
|
4
|
+
# Base controller class for the dashboard and toolbar API. This controller
|
|
5
|
+
# must enforce authentication and authorization; a public controller makes
|
|
6
|
+
# both interfaces public. The generated controller denies non-admin users by
|
|
7
|
+
# default and is the place to integrate your host application's policy.
|
|
8
|
+
config.base_controller_class = "RailsMarkupAuthController"
|
|
9
|
+
|
|
10
|
+
# Bearer token for the external API (used by MCP production tools).
|
|
11
|
+
# Set to enable the /external/* endpoints.
|
|
12
|
+
# config.api_token = Rails.application.credentials.dig(:rails_markup, :api_token)
|
|
13
|
+
|
|
14
|
+
# Database table name (default: "rails_markup_annotations")
|
|
15
|
+
# config.table_name = "rails_markup_annotations"
|
|
16
|
+
|
|
17
|
+
# Annotations per page on the dashboard (default: 25)
|
|
18
|
+
# config.per_page = 25
|
|
19
|
+
|
|
20
|
+
# Toolbar accent color: "indigo", "amber", "blue", "emerald", "rose"
|
|
21
|
+
# config.toolbar_accent = "indigo"
|
|
22
|
+
|
|
23
|
+
# Render the annotation toolbar system at all — FAB, panel, pins, sync (default: true)
|
|
24
|
+
# config.toolbar_enabled = true
|
|
25
|
+
|
|
26
|
+
# Show the floating action button specifically. When false the FAB is hidden
|
|
27
|
+
# but pins still render and the panel stays reachable (default: true)
|
|
28
|
+
# config.fab_visible = true
|
|
29
|
+
|
|
30
|
+
# FAB button position: "bl" (bottom-left), "br" (bottom-right),
|
|
31
|
+
# "tl" (top-left), "tr" (top-right). Default: "bl"
|
|
32
|
+
# config.toolbar_position = "bl"
|
|
33
|
+
|
|
34
|
+
# FAB button size: "default" (48px), "compact" (40px), "slim" (32px)
|
|
35
|
+
# config.toolbar_size = "default"
|
|
36
|
+
|
|
37
|
+
# "Back to app" link in the dashboard header.
|
|
38
|
+
# config.return_url = "/admin"
|
|
39
|
+
|
|
40
|
+
# How to resolve the author's display name from current_user.
|
|
41
|
+
# Symbol: calls the method on the user — e.g. :name, :email, :display_name
|
|
42
|
+
# Proc: receives user, returns string — e.g. ->(u) { "#{u.first_name} #{u.last_name}" }
|
|
43
|
+
# Default: :email
|
|
44
|
+
# config.author_name_method = :name
|
|
45
|
+
|
|
46
|
+
# Callback fired after each annotation is created.
|
|
47
|
+
# Use for Slack, email, or webhook notifications.
|
|
48
|
+
# config.on_create_callback = ->(annotation) {
|
|
49
|
+
# SlackNotifier.notify("New feedback on #{annotation.page_url}: #{annotation.content}")
|
|
50
|
+
# }
|
|
51
|
+
|
|
52
|
+
# Enable element screenshot capture in the toolbar (default: true)
|
|
53
|
+
# config.enable_screenshots = true
|
|
54
|
+
end
|