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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +304 -0
  4. data/app/assets/javascripts/rails_markup/toolbar.js +2225 -0
  5. data/app/controllers/rails_markup/annotations_controller.rb +244 -0
  6. data/app/controllers/rails_markup/application_controller.rb +7 -0
  7. data/app/controllers/rails_markup/dashboard_controller.rb +230 -0
  8. data/app/controllers/rails_markup/external/annotations_controller.rb +68 -0
  9. data/app/models/rails_markup/annotation.rb +176 -0
  10. data/app/views/layouts/rails_markup/application.html.erb +158 -0
  11. data/app/views/rails_markup/dashboard/_annotation_card.html.erb +25 -0
  12. data/app/views/rails_markup/dashboard/_annotation_list.html.erb +57 -0
  13. data/app/views/rails_markup/dashboard/_annotation_page.html.erb +13 -0
  14. data/app/views/rails_markup/dashboard/_filters.html.erb +75 -0
  15. data/app/views/rails_markup/dashboard/_stats.html.erb +22 -0
  16. data/app/views/rails_markup/dashboard/_styles.html.erb +115 -0
  17. data/app/views/rails_markup/dashboard/board.html.erb +135 -0
  18. data/app/views/rails_markup/dashboard/index.html.erb +38 -0
  19. data/app/views/rails_markup/dashboard/show.html.erb +129 -0
  20. data/app/views/rails_markup/shared/_toolbar.html.erb +28 -0
  21. data/bin/rails-markup +5 -0
  22. data/config/routes.rb +45 -0
  23. data/db/migrate/20260720000000_add_client_uuid_to_rails_markup_annotations.rb +13 -0
  24. data/db/migrate/20260721000000_backfill_rails_markup_client_uuids.rb +17 -0
  25. data/lib/generators/rails_markup/install/templates/auth_controller.rb.erb +17 -0
  26. data/lib/generators/rails_markup/install/templates/bin_markup.erb +5 -0
  27. data/lib/generators/rails_markup/install/templates/create_rails_markup_annotations.rb.erb +27 -0
  28. data/lib/generators/rails_markup/install/templates/initializer.rb.erb +54 -0
  29. data/lib/generators/rails_markup/install_generator.rb +167 -0
  30. data/lib/generators/rails_markup/uninstall_generator.rb +110 -0
  31. data/lib/rails_markup/cli/base.rb +8 -0
  32. data/lib/rails_markup/cli/initializer_writer.rb +54 -0
  33. data/lib/rails_markup/cli/setup_wizard.rb +229 -0
  34. data/lib/rails_markup/cli.rb +831 -0
  35. data/lib/rails_markup/client_uuid_maintenance.rb +174 -0
  36. data/lib/rails_markup/configuration.rb +160 -0
  37. data/lib/rails_markup/engine.rb +18 -0
  38. data/lib/rails_markup/http_server.rb +226 -0
  39. data/lib/rails_markup/http_store_proxy.rb +122 -0
  40. data/lib/rails_markup/mcp_config.rb +258 -0
  41. data/lib/rails_markup/mcp_server.rb +639 -0
  42. data/lib/rails_markup/server.rb +73 -0
  43. data/lib/rails_markup/store.rb +219 -0
  44. data/lib/rails_markup/version.rb +5 -0
  45. data/lib/rails_markup.rb +11 -0
  46. data/lib/tasks/rails_markup_client_uuids.rake +19 -0
  47. metadata +198 -0
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "digest/sha1"
5
+
6
+ module RailsMarkup
7
+ class Annotation < ActiveRecord::Base
8
+ self.table_name = RailsMarkup.config.table_name
9
+
10
+ INTENTS = %w[fix change question approve].freeze
11
+ SEVERITIES = %w[suggestion important blocking].freeze
12
+ STATUSES = %w[pending acknowledged resolved dismissed].freeze
13
+ BROWSER_ATTRIBUTES = %w[content intent severity selected_text target page_url].freeze
14
+ BROWSER_METADATA_KEYS = %w[tool url localId sessionId screenshot].freeze
15
+ CLIENT_UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/
16
+ CLIENT_UUID_INPUT_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i
17
+ LEGACY_SESSION_PATTERN = /\Arm-[0-9a-f]{16}\z/i
18
+ LEGACY_CLIENT_ID_LIMIT = 256
19
+ LEGACY_UUID_NAMESPACE = "265e7cf0-8be6-5e21-8f31-a582cfde8646"
20
+
21
+ # Optional user association — no FK constraint, engine doesn't know host users table
22
+ belongs_to :user, optional: true
23
+
24
+ # Works with both jsonb (PostgreSQL) and text (SQLite/MySQL) columns.
25
+ attribute :target, :json, default: {}
26
+ attribute :metadata, :json, default: {}
27
+ attribute :thread, :json, default: []
28
+
29
+ validates :content, presence: true, length: { maximum: 5000 }
30
+ validates :page_url, presence: true, length: { maximum: 2048 }
31
+ validates :selected_text, length: { maximum: 2000 }, allow_nil: true
32
+ before_validation :ensure_client_uuid
33
+
34
+ validates :client_uuid, presence: true, length: { maximum: 64 }, format: { with: CLIENT_UUID_PATTERN }
35
+ validates :intent, inclusion: { in: INTENTS }
36
+ validates :severity, inclusion: { in: SEVERITIES }
37
+ validates :status, inclusion: { in: STATUSES }
38
+ validate :thread_must_be_array
39
+
40
+ def self.valid_client_uuid?(value)
41
+ value.is_a?(String) && CLIENT_UUID_PATTERN.match?(value)
42
+ end
43
+
44
+ def self.normalize_client_uuid(value)
45
+ value.downcase if value.is_a?(String) && CLIENT_UUID_INPUT_PATTERN.match?(value)
46
+ end
47
+
48
+ def self.legacy_client_uuid(session_id:, legacy_client_id:)
49
+ session_id = session_id.to_s
50
+ legacy_client_id = legacy_client_id.to_s
51
+ unless LEGACY_SESSION_PATTERN.match?(session_id) && legacy_client_id.present? && legacy_client_id.bytesize <= LEGACY_CLIENT_ID_LIMIT
52
+ raise ArgumentError, "legacy client identity requires a valid session and client id"
53
+ end
54
+
55
+ namespace = [LEGACY_UUID_NAMESPACE.delete("-")].pack("H*")
56
+ bytes = Digest::SHA1.digest(namespace + "#{session_id}\0#{legacy_client_id}").bytes.first(16)
57
+ bytes[6] = (bytes[6] & 0x0f) | 0x50
58
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
59
+ hex = bytes.pack("C*").unpack1("H*")
60
+ "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
61
+ end
62
+
63
+ scope :pending, -> { where(status: "pending") }
64
+ scope :acknowledged, -> { where(status: "acknowledged") }
65
+ scope :resolved, -> { where(status: "resolved") }
66
+ scope :dismissed, -> { where(status: "dismissed") }
67
+ scope :active, -> { where(status: %w[pending acknowledged]) }
68
+ scope :for_page, ->(url) { where(page_url: url) }
69
+ scope :recent, -> { order(created_at: :desc, id: :desc) }
70
+
71
+ # Keyset (cursor) companion to :recent — rows strictly older than the
72
+ # (created_at, id) cursor. Avoids the offset-overlap that repeats a boundary
73
+ # row when annotations are inserted between "Load more" requests.
74
+ scope :before_cursor, ->(created_at, id) {
75
+ where("created_at < :t OR (created_at = :t AND id < :id)", t: created_at, id: id)
76
+ }
77
+
78
+ scope :search, ->(query) {
79
+ where("content LIKE :q OR selected_text LIKE :q", q: "%#{sanitize_sql_like(query)}%")
80
+ }
81
+
82
+ scope :by_author, ->(name) {
83
+ if connection.adapter_name.downcase.include?("postgres")
84
+ where("metadata->>'author' = ?", name)
85
+ else
86
+ where("json_extract(metadata, '$.author') = ?", name)
87
+ end
88
+ }
89
+
90
+ def self.distinct_authors
91
+ if connection.adapter_name.downcase.include?("postgres")
92
+ where("metadata->>'author' IS NOT NULL").distinct.pluck(Arel.sql("metadata->>'author'")).compact.sort
93
+ else
94
+ all.filter_map(&:author_name).uniq.sort
95
+ end
96
+ end
97
+
98
+ def author_name
99
+ metadata&.dig("author")
100
+ end
101
+
102
+ def apply_browser_state(attributes, dirty_fields: [])
103
+ assign_attributes(attributes.slice(*BROWSER_ATTRIBUTES))
104
+ self.metadata = (metadata || {}).merge(attributes.fetch("metadata", {}).slice(*BROWSER_METADATA_KEYS))
105
+ self.status = attributes["status"] if dirty_fields.include?("status")
106
+ self
107
+ end
108
+
109
+ def acknowledge!
110
+ raise "Cannot acknowledge a #{status} annotation" unless status == "pending"
111
+
112
+ update!(status: "acknowledged")
113
+ end
114
+
115
+ def resolve!(summary: nil)
116
+ raise "Cannot resolve a #{status} annotation" unless status.in?(%w[pending acknowledged])
117
+
118
+ transaction do
119
+ add_thread_entry(role: "agent", message: summary) if summary.present?
120
+ update!(status: "resolved")
121
+ end
122
+ end
123
+
124
+ def dismiss!(reason: nil)
125
+ raise "Cannot dismiss a #{status} annotation" unless status.in?(%w[pending acknowledged])
126
+
127
+ transaction do
128
+ add_thread_entry(role: "agent", message: reason) if reason.present?
129
+ update!(status: "dismissed")
130
+ end
131
+ end
132
+
133
+ def add_reply!(message:, role: "agent")
134
+ add_thread_entry(role: role, message: message)
135
+ save!
136
+ end
137
+
138
+ def as_api_json
139
+ {
140
+ id: id.to_s,
141
+ clientId: client_uuid,
142
+ userId: user_id,
143
+ authorName: author_name,
144
+ content: content,
145
+ intent: intent,
146
+ severity: severity,
147
+ status: status,
148
+ selectedText: selected_text,
149
+ pageUrl: page_url,
150
+ target: target,
151
+ metadata: metadata,
152
+ thread: thread,
153
+ createdAt: created_at&.iso8601,
154
+ updatedAt: updated_at&.iso8601
155
+ }
156
+ end
157
+
158
+ private
159
+
160
+ def ensure_client_uuid
161
+ self.client_uuid = if client_uuid.blank?
162
+ SecureRandom.uuid
163
+ else
164
+ self.class.normalize_client_uuid(client_uuid) || client_uuid
165
+ end
166
+ end
167
+
168
+ def add_thread_entry(role:, message:)
169
+ self.thread = thread + [{ "role" => role, "message" => message, "timestamp" => Time.current.iso8601 }]
170
+ end
171
+
172
+ def thread_must_be_array
173
+ errors.add(:thread, "must be an array") unless thread.is_a?(Array)
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,158 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title><%= content_for?(:title) ? "#{yield(:title)} — Rails Markup" : "Rails Markup" %></title>
7
+ <style>
8
+ /* Reset */
9
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
10
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f8f9fa; color: #1a1a2e; line-height: 1.5; }
11
+ a { color: #4361ee; text-decoration: none; }
12
+ a:hover { text-decoration: underline; }
13
+
14
+ /* Layout */
15
+ .rm-header { background: #1a1a2e; color: #fff; padding: 0 24px; height: 56px; display: flex; align-items: center; justify-content: space-between; }
16
+ .rm-header h1 { font-size: 16px; font-weight: 600; letter-spacing: -0.01em; }
17
+ .rm-header h1 span { color: #818cf8; }
18
+ .rm-header nav { display: flex; gap: 16px; font-size: 13px; }
19
+ .rm-header nav a { color: #a5b4fc; }
20
+ .rm-header nav a:hover { color: #fff; text-decoration: none; }
21
+ .rm-container { max-width: 960px; margin: 0 auto; padding: 24px 16px; }
22
+
23
+ /* Flash */
24
+ .rm-flash { padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; font-size: 13px; }
25
+ .rm-flash-notice { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; }
26
+ .rm-flash-alert { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
27
+
28
+ /* Stats bar */
29
+ .rm-stats { display: flex; gap: 12px; margin-bottom: 24px; flex-wrap: wrap; }
30
+ .rm-stat { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 16px 20px; flex: 1; min-width: 120px; }
31
+ .rm-stat-value { font-size: 28px; font-weight: 700; line-height: 1; }
32
+ .rm-stat-label { font-size: 12px; color: #6b7280; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.05em; }
33
+
34
+ /* Filters */
35
+ .rm-filters { display: flex; gap: 8px; margin-bottom: 20px; align-items: center; flex-wrap: wrap; }
36
+ .rm-pill { display: inline-flex; align-items: center; padding: 6px 14px; border-radius: 20px; font-size: 12px; font-weight: 500; cursor: pointer; border: 1px solid #e5e7eb; background: #fff; color: #6b7280; transition: all 0.15s; }
37
+ .rm-pill:hover { border-color: #818cf8; color: #4361ee; text-decoration: none; }
38
+ .rm-pill-active { background: #4361ee; color: #fff; border-color: #4361ee; }
39
+ .rm-pill-active:hover { background: #3730a3; color: #fff; }
40
+ .rm-select { padding: 6px 12px; border-radius: 8px; border: 1px solid #e5e7eb; font-size: 12px; background: #fff; color: #374151; cursor: pointer; max-width: 240px; }
41
+
42
+ /* Cards */
43
+ .rm-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 16px 20px; margin-bottom: 10px; transition: border-color 0.15s; position: relative; }
44
+ .rm-card:hover { border-color: #818cf8; }
45
+ .rm-card-link { display: block; color: inherit; }
46
+ .rm-card-link:hover { text-decoration: none; }
47
+ .rm-card-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
48
+ .rm-card-content { font-size: 14px; color: #374151; line-height: 1.6; }
49
+ .rm-card-meta { display: flex; align-items: center; gap: 12px; margin-top: 10px; font-size: 11px; color: #9ca3af; }
50
+ .rm-card-meta code { font-size: 11px; background: #f3f4f6; padding: 2px 6px; border-radius: 4px; color: #6b7280; }
51
+
52
+ /* Badges */
53
+ .rm-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; text-transform: capitalize; }
54
+ .rm-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
55
+ .rm-status-pending { background: #fef3c7; color: #92400e; }
56
+ .rm-status-acknowledged { background: #dbeafe; color: #1e40af; }
57
+ .rm-status-resolved { background: #d1fae5; color: #065f46; }
58
+ .rm-status-dismissed { background: #f3f4f6; color: #6b7280; }
59
+ .rm-dot-pending { background: #f59e0b; }
60
+ .rm-dot-acknowledged { background: #3b82f6; }
61
+ .rm-dot-resolved { background: #10b981; }
62
+ .rm-dot-dismissed { background: #9ca3af; }
63
+ .rm-intent-fix { background: #fef2f2; color: #991b1b; }
64
+ .rm-intent-change { background: #eff6ff; color: #1e40af; }
65
+ .rm-intent-question { background: #f5f3ff; color: #5b21b6; }
66
+ .rm-intent-approve { background: #ecfdf5; color: #065f46; }
67
+ .rm-severity-blocking { background: #fef2f2; color: #991b1b; font-weight: 700; }
68
+ .rm-severity-important { background: #fff7ed; color: #9a3412; }
69
+ .rm-severity-suggestion { background: #f3f4f6; color: #6b7280; }
70
+
71
+ /* Detail page */
72
+ .rm-detail { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 24px; }
73
+ .rm-detail-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
74
+ .rm-detail-content { font-size: 15px; line-height: 1.7; margin-bottom: 24px; white-space: pre-wrap; }
75
+ .rm-detail-meta { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; font-size: 13px; margin-bottom: 24px; padding: 16px; background: #f9fafb; border-radius: 8px; }
76
+ .rm-detail-meta dt { color: #6b7280; font-weight: 500; }
77
+ .rm-detail-meta dd { color: #374151; }
78
+ .rm-detail-meta dd code { font-size: 12px; background: #e5e7eb; padding: 1px 5px; border-radius: 3px; }
79
+
80
+ /* Thread */
81
+ .rm-thread { margin-top: 24px; border-top: 1px solid #e5e7eb; padding-top: 20px; }
82
+ .rm-thread h3 { font-size: 14px; font-weight: 600; margin-bottom: 12px; color: #374151; }
83
+ .rm-thread-entry { padding: 12px 16px; border-radius: 8px; margin-bottom: 8px; font-size: 13px; line-height: 1.5; }
84
+ .rm-thread-agent { background: #eff6ff; border-left: 3px solid #3b82f6; }
85
+ .rm-thread-user { background: #f9fafb; border-left: 3px solid #e5e7eb; }
86
+ .rm-thread-role { font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
87
+ .rm-thread-time { font-size: 11px; color: #9ca3af; margin-top: 4px; }
88
+
89
+ /* Actions */
90
+ .rm-actions { display: flex; gap: 8px; margin-top: 24px; flex-wrap: wrap; }
91
+ .rm-btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500; border: 1px solid #e5e7eb; background: #fff; color: #374151; cursor: pointer; transition: all 0.15s; }
92
+ .rm-btn:hover { border-color: #818cf8; color: #4361ee; text-decoration: none; }
93
+ .rm-btn-primary { background: #4361ee; color: #fff; border-color: #4361ee; }
94
+ .rm-btn-primary:hover { background: #3730a3; color: #fff; }
95
+ .rm-btn-success { background: #10b981; color: #fff; border-color: #10b981; }
96
+ .rm-btn-success:hover { background: #059669; color: #fff; }
97
+ .rm-btn-danger { background: #ef4444; color: #fff; border-color: #ef4444; }
98
+ .rm-btn-danger:hover { background: #dc2626; color: #fff; }
99
+
100
+ /* Forms */
101
+ .rm-form-group { margin-bottom: 12px; }
102
+ .rm-form-group label { display: block; font-size: 12px; font-weight: 500; color: #6b7280; margin-bottom: 4px; }
103
+ .rm-input { width: 100%; padding: 8px 12px; border: 1px solid #e5e7eb; border-radius: 8px; font-size: 13px; font-family: inherit; resize: vertical; }
104
+ .rm-input:focus { outline: none; border-color: #818cf8; box-shadow: 0 0 0 3px rgba(99,102,241,0.1); }
105
+
106
+ /* Back link */
107
+ .rm-back { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: #6b7280; margin-bottom: 16px; }
108
+ .rm-back:hover { color: #4361ee; text-decoration: none; }
109
+
110
+ /* Empty state */
111
+ .rm-empty { text-align: center; padding: 48px 24px; color: #9ca3af; }
112
+ .rm-empty-icon { font-size: 36px; margin-bottom: 12px; }
113
+ .rm-empty-text { font-size: 14px; }
114
+
115
+ /* Two-column layout */
116
+ .rm-layout { display: flex; gap: 24px; }
117
+ .rm-main { flex: 1; min-width: 0; }
118
+ .rm-panel { width: 420px; flex-shrink: 0; position: sticky; top: 80px; max-height: calc(100vh - 100px); overflow-y: auto; background: #f8f9fa; }
119
+ .rm-panel .rm-detail { border-left: 3px solid #818cf8; }
120
+ .rm-card-active { border-color: #818cf8 !important; background: #f5f3ff; }
121
+ .rm-panel-empty { display: none; }
122
+
123
+ /* Responsive */
124
+ @media (max-width: 900px) {
125
+ .rm-layout { flex-direction: column; }
126
+ .rm-panel { width: 100%; position: static; max-height: none; background: #f8f9fa; }
127
+ }
128
+ @media (max-width: 640px) {
129
+ .rm-stats { flex-direction: column; }
130
+ .rm-stat { min-width: unset; }
131
+ .rm-filters { flex-direction: column; align-items: stretch; }
132
+ .rm-detail-header { flex-direction: column; align-items: flex-start; }
133
+ }
134
+ </style>
135
+ </head>
136
+ <body>
137
+ <header class="rm-header">
138
+ <h1><span>&#9670;</span> Rails Markup</h1>
139
+ <nav>
140
+ <a href="<%= rails_markup.root_path %>">Dashboard</a>
141
+ <% if RailsMarkup.config.return_url.present? %>
142
+ <a href="<%= RailsMarkup.config.return_url %>">&#8592; Back to app</a>
143
+ <% end %>
144
+ </nav>
145
+ </header>
146
+
147
+ <main class="rm-container">
148
+ <% if notice.present? %>
149
+ <div class="rm-flash rm-flash-notice"><%= notice %></div>
150
+ <% end %>
151
+ <% if alert.present? %>
152
+ <div class="rm-flash rm-flash-alert"><%= alert %></div>
153
+ <% end %>
154
+
155
+ <%= yield %>
156
+ </main>
157
+ </body>
158
+ </html>
@@ -0,0 +1,25 @@
1
+ <div class="rm-card" data-annotation-id="<%= annotation.id %>">
2
+ <a href="<%= rails_markup.annotation_path(annotation) %>"
3
+ data-turbo-frame="detail-panel"
4
+ class="rm-card-link">
5
+ <div class="rm-card-header">
6
+ <span class="rm-dot rm-dot-<%= annotation.status %>"></span>
7
+ <span class="rm-badge rm-status-<%= annotation.status %>"><%= annotation.status %></span>
8
+ <span class="rm-badge rm-intent-<%= annotation.intent %>"><%= annotation.intent %></span>
9
+ <% if annotation.severity != "suggestion" %>
10
+ <span class="rm-badge rm-severity-<%= annotation.severity %>"><%= annotation.severity %></span>
11
+ <% end %>
12
+ </div>
13
+ <div class="rm-card-content"><%= truncate(annotation.content, length: 200) %></div>
14
+ <div class="rm-card-meta">
15
+ <code><%= truncate(annotation.page_url, length: 50) %></code>
16
+ <% if annotation.author_name.present? %>
17
+ <span class="rm-author"><%= annotation.author_name %></span>
18
+ <% end %>
19
+ <span title="<%= annotation.created_at.strftime("%B %d, %Y at %H:%M") %>"><%= time_ago_in_words(annotation.created_at) %> ago</span>
20
+ <% if annotation.thread.any? %>
21
+ <span><%= annotation.thread.size %> replies</span>
22
+ <% end %>
23
+ </div>
24
+ </a>
25
+ </div>
@@ -0,0 +1,57 @@
1
+ <% if @annotations.any? %>
2
+ <div id="rm-annotation-list">
3
+ <% @annotations.each do |annotation| %>
4
+ <%= render "annotation_card", annotation: annotation %>
5
+ <% end %>
6
+
7
+ <% if @next_page %>
8
+ <div class="rm-load-more">
9
+ <button class="rm-load-more-btn"
10
+ data-next-url="<%= rails_markup.load_more_path(status: @current_status, before_time: @next_time, before_id: @next_id, page_url: @current_page_url, q: @current_query, author: @current_author) %>"
11
+ onclick="rmLoadMore(this)">
12
+ Load more (<%= @remaining %> remaining)
13
+ </button>
14
+ </div>
15
+ <% end %>
16
+ </div>
17
+
18
+ <script>
19
+ function rmLoadMore(button) {
20
+ button.disabled = true;
21
+ button.textContent = "Loading\u2026";
22
+ var url = button.getAttribute("data-next-url");
23
+ var wrapper = button.closest(".rm-load-more");
24
+
25
+ fetch(url, { headers: { "X-Requested-With": "XMLHttpRequest" } })
26
+ .then(function(response) {
27
+ if (!response.ok) throw new Error("HTTP " + response.status);
28
+ return response.text();
29
+ })
30
+ .then(function(html) {
31
+ wrapper.insertAdjacentHTML("afterend", html);
32
+ wrapper.remove();
33
+ })
34
+ .catch(function() {
35
+ button.disabled = false;
36
+ button.textContent = "Error \u2014 click to retry";
37
+ });
38
+ }
39
+ </script>
40
+ <% else %>
41
+ <div class="rm-empty">
42
+ <div class="rm-empty-icon">&#9670;</div>
43
+ <div class="rm-empty-text">
44
+ <% if @current_query.present? %>
45
+ No results for &ldquo;<%= @current_query %>&rdquo;.
46
+ <a href="<%= rails_markup.root_path(status: @current_status, page_url: @current_page_url, author: @current_author) %>">Clear search</a>
47
+ <% elsif @current_author.present? %>
48
+ No annotations by <%= @current_author %>.
49
+ <a href="<%= rails_markup.root_path(status: @current_status, page_url: @current_page_url) %>">Clear filter</a>
50
+ <% elsif @current_status != "all" %>
51
+ No <%= @current_status %> annotations.
52
+ <% else %>
53
+ No annotations yet.<br>Use the toolbar to start annotating.
54
+ <% end %>
55
+ </div>
56
+ </div>
57
+ <% end %>
@@ -0,0 +1,13 @@
1
+ <% @annotations.each do |annotation| %>
2
+ <%= render "annotation_card", annotation: annotation %>
3
+ <% end %>
4
+
5
+ <% if @next_page %>
6
+ <div class="rm-load-more">
7
+ <button class="rm-load-more-btn"
8
+ data-next-url="<%= rails_markup.load_more_path(status: @current_status, before_time: @next_time, before_id: @next_id, page_url: params[:page_url], q: params[:q], author: params[:author]) %>"
9
+ onclick="rmLoadMore(this)">
10
+ Load more (<%= @remaining %> remaining)
11
+ </button>
12
+ </div>
13
+ <% end %>
@@ -0,0 +1,75 @@
1
+ <div class="rm-filters">
2
+ <%
3
+ statuses = [
4
+ ["all", "All"],
5
+ ["pending", "Pending"],
6
+ ["acknowledged", "Acknowledged"],
7
+ ["resolved", "Resolved"],
8
+ ["dismissed", "Dismissed"]
9
+ ]
10
+ %>
11
+ <% statuses.each do |value, label| %>
12
+ <a href="<%= rails_markup.root_path(status: value, page_url: @current_page_url, q: @current_query, author: @current_author) %>"
13
+ class="rm-pill <%= 'rm-pill-active' if @current_status == value %>">
14
+ <%= label %>
15
+ </a>
16
+ <% end %>
17
+
18
+ <% if @current_status.in?(%w[pending acknowledged]) && @annotations.any? %>
19
+ <%= form_tag(rails_markup.dismiss_all_path(status: @current_status), method: :post, style: "display:inline", data: { turbo_confirm: "Dismiss all #{@current_status} annotations?" }) do %>
20
+ <button type="submit" class="rm-pill" style="color: #ef4444; border-color: #fecaca;">Dismiss all <%= @current_status %></button>
21
+ <% end %>
22
+ <% end %>
23
+
24
+ <div style="margin-left: auto; display: flex; gap: 4px; align-items: center;">
25
+ <a href="<%= rails_markup.root_path(status: @current_status, page_url: @current_page_url, q: @current_query, author: @current_author) %>"
26
+ class="rm-pill <%= 'rm-pill-active' unless action_name == 'board' %>">List</a>
27
+ <a href="<%= rails_markup.board_path %>" class="rm-pill <%= 'rm-pill-active' if action_name == 'board' %>">Board</a>
28
+ </div>
29
+ </div>
30
+
31
+ <div class="rm-filters">
32
+ <%# Search %>
33
+ <form action="<%= rails_markup.root_path %>" method="get" style="display:flex;gap:6px;align-items:center;">
34
+ <input type="text" name="q" value="<%= @current_query %>" placeholder="Search annotations..." class="rm-select" style="min-width:180px;">
35
+ <input type="hidden" name="status" value="<%= @current_status %>">
36
+ <input type="hidden" name="page_url" value="<%= @current_page_url %>">
37
+ <input type="hidden" name="author" value="<%= @current_author %>">
38
+ <button type="submit" class="rm-pill">Search</button>
39
+ <% if @current_query.present? %>
40
+ <a href="<%= rails_markup.root_path(status: @current_status, page_url: @current_page_url, author: @current_author) %>" class="rm-pill" style="color:#ef4444;">Clear</a>
41
+ <% end %>
42
+ </form>
43
+
44
+ <%# Page URL filter %>
45
+ <% if @page_urls.any? %>
46
+ <select class="rm-select" onchange="document.getElementById('annotations-content').src=this.value">
47
+ <option value="<%= rails_markup.root_path(status: @current_status == 'all' ? nil : @current_status, q: @current_query, author: @current_author) %>">All pages</option>
48
+ <% @page_urls.each do |url| %>
49
+ <option value="<%= rails_markup.root_path(status: @current_status == 'all' ? nil : @current_status, page_url: url, q: @current_query, author: @current_author) %>"
50
+ <%= 'selected' if @current_page_url == url %>>
51
+ <%= truncate(url, length: 60) %>
52
+ </option>
53
+ <% end %>
54
+ </select>
55
+ <% end %>
56
+
57
+ <%# Author filter %>
58
+ <% if @authors.any? %>
59
+ <select class="rm-select" onchange="window.location=this.value">
60
+ <option value="<%= rails_markup.root_path(status: @current_status, page_url: @current_page_url, q: @current_query) %>">All authors</option>
61
+ <% @authors.each do |author| %>
62
+ <option value="<%= rails_markup.root_path(status: @current_status, page_url: @current_page_url, q: @current_query, author: author) %>"
63
+ <%= 'selected' if @current_author == author %>>
64
+ <%= author %>
65
+ </option>
66
+ <% end %>
67
+ </select>
68
+ <% end %>
69
+
70
+ <%# Export %>
71
+ <div style="margin-left:auto;display:flex;gap:4px;">
72
+ <a href="<%= rails_markup.export_csv_path(status: @current_status, page_url: @current_page_url, q: @current_query, author: @current_author) %>" class="rm-pill">CSV</a>
73
+ <a href="<%= rails_markup.export_json_path(status: @current_status, page_url: @current_page_url, q: @current_query, author: @current_author) %>" class="rm-pill">JSON</a>
74
+ </div>
75
+ </div>
@@ -0,0 +1,22 @@
1
+ <div class="rm-stats">
2
+ <div class="rm-stat">
3
+ <div class="rm-stat-value"><%= @total_count %></div>
4
+ <div class="rm-stat-label">Total</div>
5
+ </div>
6
+ <div class="rm-stat">
7
+ <div class="rm-stat-value" style="color: #f59e0b;"><%= @pending_count %></div>
8
+ <div class="rm-stat-label">Pending</div>
9
+ </div>
10
+ <div class="rm-stat">
11
+ <div class="rm-stat-value" style="color: #3b82f6;"><%= @acknowledged_count %></div>
12
+ <div class="rm-stat-label">Acknowledged</div>
13
+ </div>
14
+ <div class="rm-stat">
15
+ <div class="rm-stat-value" style="color: #10b981;"><%= @resolved_count %></div>
16
+ <div class="rm-stat-label">Resolved</div>
17
+ </div>
18
+ <div class="rm-stat">
19
+ <div class="rm-stat-value" style="color: #9ca3af;"><%= @dismissed_count %></div>
20
+ <div class="rm-stat-label">Dismissed</div>
21
+ </div>
22
+ </div>
@@ -0,0 +1,115 @@
1
+ <style id="rm-dashboard-styles" data-turbo-permanent>
2
+ /* Rails Markup Dashboard Styles */
3
+ .rm-stats { display: flex; gap: 12px; margin-bottom: 24px; flex-wrap: wrap; }
4
+ .rm-stat { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 16px 20px; flex: 1; min-width: 120px; }
5
+ .rm-stat-value { font-size: 28px; font-weight: 700; line-height: 1; }
6
+ .rm-stat-label { font-size: 12px; color: #6b7280; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.05em; }
7
+
8
+ .rm-filters { display: flex; gap: 8px; margin-bottom: 20px; align-items: center; flex-wrap: wrap; }
9
+ .rm-pill { display: inline-flex; align-items: center; padding: 6px 14px; border-radius: 20px; font-size: 12px; font-weight: 500; cursor: pointer; border: 1px solid #e5e7eb; background: #fff; color: #6b7280; transition: all 0.15s; text-decoration: none; }
10
+ .rm-pill:hover { border-color: #818cf8; color: #4361ee; text-decoration: none; }
11
+ .rm-pill-active { background: #4361ee; color: #fff; border-color: #4361ee; }
12
+ .rm-pill-active:hover { background: #3730a3; color: #fff; }
13
+ .rm-select { padding: 6px 12px; border-radius: 8px; border: 1px solid #e5e7eb; font-size: 12px; background: #fff; color: #374151; cursor: pointer; max-width: 240px; }
14
+
15
+ .rm-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 16px 20px; margin-bottom: 10px; transition: border-color 0.15s; position: relative; }
16
+ .rm-card:hover { border-color: #818cf8; }
17
+ .rm-card-link { display: block; color: inherit; }
18
+ .rm-card-link:hover { text-decoration: none; }
19
+ .rm-card-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
20
+ .rm-card-content { font-size: 14px; color: #374151; line-height: 1.6; }
21
+ .rm-card-meta { display: flex; align-items: center; gap: 12px; margin-top: 10px; font-size: 11px; color: #9ca3af; }
22
+ .rm-card-meta code { font-size: 11px; background: #f3f4f6; padding: 2px 6px; border-radius: 4px; color: #6b7280; }
23
+
24
+ .rm-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; text-transform: capitalize; }
25
+ .rm-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
26
+ .rm-status-pending { background: #fef3c7; color: #92400e; }
27
+ .rm-status-acknowledged { background: #dbeafe; color: #1e40af; }
28
+ .rm-status-resolved { background: #d1fae5; color: #065f46; }
29
+ .rm-status-dismissed { background: #f3f4f6; color: #6b7280; }
30
+ .rm-dot-pending { background: #f59e0b; }
31
+ .rm-dot-acknowledged { background: #3b82f6; }
32
+ .rm-dot-resolved { background: #10b981; }
33
+ .rm-dot-dismissed { background: #9ca3af; }
34
+ .rm-intent-fix { background: #fef2f2; color: #991b1b; }
35
+ .rm-intent-change { background: #eff6ff; color: #1e40af; }
36
+ .rm-intent-question { background: #f5f3ff; color: #5b21b6; }
37
+ .rm-intent-approve { background: #ecfdf5; color: #065f46; }
38
+ .rm-severity-blocking { background: #fef2f2; color: #991b1b; font-weight: 700; }
39
+ .rm-severity-important { background: #fff7ed; color: #9a3412; }
40
+ .rm-severity-suggestion { background: #f3f4f6; color: #6b7280; }
41
+
42
+ .rm-detail { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 24px; }
43
+ .rm-detail-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
44
+ .rm-detail-content { font-size: 15px; line-height: 1.7; margin-bottom: 24px; white-space: pre-wrap; }
45
+ .rm-detail-meta { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; font-size: 13px; margin-bottom: 24px; padding: 16px; background: #f9fafb; border-radius: 8px; }
46
+ .rm-detail-meta dt { color: #6b7280; font-weight: 500; }
47
+ .rm-detail-meta dd { color: #374151; }
48
+ .rm-detail-meta dd code { font-size: 12px; background: #e5e7eb; padding: 1px 5px; border-radius: 3px; }
49
+
50
+ .rm-thread { margin-top: 24px; border-top: 1px solid #e5e7eb; padding-top: 20px; }
51
+ .rm-thread h3 { font-size: 14px; font-weight: 600; margin-bottom: 12px; color: #374151; }
52
+ .rm-thread-entry { padding: 12px 16px; border-radius: 8px; margin-bottom: 8px; font-size: 13px; line-height: 1.5; }
53
+ .rm-thread-agent { background: #eff6ff; border-left: 3px solid #3b82f6; }
54
+ .rm-thread-user { background: #f9fafb; border-left: 3px solid #e5e7eb; }
55
+ .rm-thread-role { font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
56
+ .rm-thread-time { font-size: 11px; color: #9ca3af; margin-top: 4px; }
57
+
58
+ .rm-actions { display: flex; gap: 8px; margin-top: 24px; flex-wrap: wrap; }
59
+ .rm-btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500; border: 1px solid #e5e7eb; background: #fff; color: #374151; cursor: pointer; transition: all 0.15s; }
60
+ .rm-btn:hover { border-color: #818cf8; color: #4361ee; text-decoration: none; }
61
+ .rm-btn-primary { background: #4361ee; color: #fff; border-color: #4361ee; }
62
+ .rm-btn-primary:hover { background: #3730a3; color: #fff; }
63
+ .rm-btn-success { background: #10b981; color: #fff; border-color: #10b981; }
64
+ .rm-btn-success:hover { background: #059669; color: #fff; }
65
+ .rm-btn-danger { background: #ef4444; color: #fff; border-color: #ef4444; }
66
+ .rm-btn-danger:hover { background: #dc2626; color: #fff; }
67
+
68
+ .rm-form-group { margin-bottom: 12px; }
69
+ .rm-form-group label { display: block; font-size: 12px; font-weight: 500; color: #6b7280; margin-bottom: 4px; }
70
+ .rm-input { width: 100%; padding: 8px 12px; border: 1px solid #e5e7eb; border-radius: 8px; font-size: 13px; font-family: inherit; resize: vertical; }
71
+ .rm-input:focus { outline: none; border-color: #818cf8; box-shadow: 0 0 0 3px rgba(99,102,241,0.1); }
72
+
73
+ .rm-back { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; color: #6b7280; margin-bottom: 16px; border: none; background: none; cursor: pointer; }
74
+ .rm-back:hover { color: #4361ee; text-decoration: none; }
75
+
76
+ .rm-empty { text-align: center; padding: 48px 24px; color: #9ca3af; }
77
+ .rm-empty-icon { font-size: 36px; margin-bottom: 12px; }
78
+ .rm-empty-text { font-size: 14px; }
79
+
80
+ .rm-layout { display: flex; gap: 24px; }
81
+ .rm-main { flex: 1; min-width: 0; }
82
+ .rm-panel { width: 420px; flex-shrink: 0; position: sticky; top: 80px; max-height: calc(100vh - 100px); overflow-y: auto; }
83
+ .rm-panel .rm-detail { border-left: 3px solid #818cf8; }
84
+ .rm-card-active { border-color: #818cf8 !important; background: #f5f3ff; }
85
+ .rm-panel-empty { display: none; }
86
+
87
+ .rm-author { font-weight: 500; color: #6b7280; }
88
+
89
+ .rm-load-more { text-align: center; margin-top: 16px; }
90
+ .rm-load-more-btn { width: 100%; padding: 10px; border-radius: 8px; font-size: 13px; border: 1px solid #e5e7eb; background: #fff; color: #6b7280; cursor: pointer; transition: all 0.15s; }
91
+ .rm-load-more-btn:hover { border-color: #818cf8; color: #4361ee; background: #f5f3ff; }
92
+ .rm-load-more-btn:disabled { opacity: 0.6; cursor: wait; }
93
+
94
+ .rm-board { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; min-height: 400px; }
95
+ .rm-board-column { background: #f9fafb; border-radius: 12px; padding: 12px; min-height: 200px; }
96
+ .rm-board-column-header { font-size: 13px; font-weight: 600; padding: 8px 12px; border-radius: 8px; margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center; }
97
+ .rm-board-cards { display: flex; flex-direction: column; gap: 8px; min-height: 100px; }
98
+ .rm-board-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; cursor: grab; font-size: 13px; transition: box-shadow 0.15s; }
99
+ .rm-board-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
100
+ .rm-board-card:active { cursor: grabbing; }
101
+ .rm-drag-over { background: #eff6ff; border: 2px dashed #818cf8; }
102
+
103
+ @media (max-width: 900px) {
104
+ .rm-layout { flex-direction: column; }
105
+ .rm-panel { width: 100%; position: static; max-height: none; }
106
+ .rm-board { grid-template-columns: repeat(2, 1fr); }
107
+ }
108
+ @media (max-width: 640px) {
109
+ .rm-stats { flex-direction: column; }
110
+ .rm-stat { min-width: unset; }
111
+ .rm-filters { flex-direction: column; align-items: stretch; }
112
+ .rm-detail-header { flex-direction: column; align-items: flex-start; }
113
+ .rm-board { grid-template-columns: 1fr; }
114
+ }
115
+ </style>