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,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>◆</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 %>">← 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">◆</div>
|
|
43
|
+
<div class="rm-empty-text">
|
|
44
|
+
<% if @current_query.present? %>
|
|
45
|
+
No results for “<%= @current_query %>”.
|
|
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>
|