coplan-engine 0.1.3 → 0.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.
@@ -0,0 +1,134 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["position", "resolvedToggle"]
5
+ static values = { planId: String }
6
+
7
+ connect() {
8
+ this.currentIndex = -1
9
+ this.updatePosition()
10
+ this.handleKeydown = this.handleKeydown.bind(this)
11
+ document.addEventListener("keydown", this.handleKeydown)
12
+ }
13
+
14
+ disconnect() {
15
+ document.removeEventListener("keydown", this.handleKeydown)
16
+ }
17
+
18
+ handleKeydown(event) {
19
+ // Don't intercept when typing in inputs/textareas or when modifier keys are held
20
+ const tag = event.target.tagName
21
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || event.target.isContentEditable) return
22
+ if (event.metaKey || event.ctrlKey || event.altKey) return
23
+
24
+ switch (event.key) {
25
+ case "j":
26
+ case "ArrowDown":
27
+ event.preventDefault()
28
+ this.next()
29
+ break
30
+ case "k":
31
+ case "ArrowUp":
32
+ event.preventDefault()
33
+ this.prev()
34
+ break
35
+ case "r":
36
+ event.preventDefault()
37
+ this.focusReply()
38
+ break
39
+ }
40
+ }
41
+
42
+ get openHighlights() {
43
+ return Array.from(document.querySelectorAll("mark.anchor-highlight--open"))
44
+ }
45
+
46
+ get allHighlights() {
47
+ return Array.from(document.querySelectorAll("mark.anchor-highlight"))
48
+ }
49
+
50
+ next() {
51
+ const highlights = this.openHighlights
52
+ if (highlights.length === 0) return
53
+
54
+ this.currentIndex = (this.currentIndex + 1) % highlights.length
55
+ this.navigateTo(highlights[this.currentIndex])
56
+ }
57
+
58
+ prev() {
59
+ const highlights = this.openHighlights
60
+ if (highlights.length === 0) return
61
+
62
+ this.currentIndex = this.currentIndex <= 0 ? highlights.length - 1 : this.currentIndex - 1
63
+ this.navigateTo(highlights[this.currentIndex])
64
+ }
65
+
66
+ navigateTo(mark) {
67
+ // Remove active highlight from all marks
68
+ document.querySelectorAll(".anchor-highlight--active").forEach(el => {
69
+ el.classList.remove("anchor-highlight--active")
70
+ })
71
+
72
+ // Add active class and scroll into view
73
+ mark.classList.add("anchor-highlight--active")
74
+ mark.scrollIntoView({ behavior: "smooth", block: "center" })
75
+
76
+ // Open the thread popover if there's one
77
+ const threadId = mark.dataset.threadId
78
+ if (threadId) {
79
+ const popover = document.getElementById(`${threadId}_popover`)
80
+ if (popover) {
81
+ const markRect = mark.getBoundingClientRect()
82
+ popover.showPopover()
83
+ popover.style.top = `${markRect.top}px`
84
+ popover.style.left = `${markRect.right + 12}px`
85
+ }
86
+ }
87
+
88
+ this.updatePosition()
89
+ }
90
+
91
+ focusReply() {
92
+ let openPopover
93
+ try {
94
+ openPopover = document.querySelector(".thread-popover:popover-open")
95
+ } catch {
96
+ // :popover-open not supported — find the visible popover manually
97
+ openPopover = Array.from(document.querySelectorAll(".thread-popover[popover]"))
98
+ .find(el => el.checkVisibility?.())
99
+ }
100
+ if (!openPopover) return
101
+
102
+ const textarea = openPopover.querySelector(".thread-popover__reply textarea")
103
+ if (textarea) {
104
+ textarea.focus({ preventScroll: true })
105
+ }
106
+ }
107
+
108
+ toggleResolved() {
109
+ const planLayout = document.querySelector(".plan-layout")
110
+ if (!planLayout) return
111
+
112
+ if (this.resolvedToggleTarget.checked) {
113
+ planLayout.classList.add("plan-layout--show-resolved")
114
+ } else {
115
+ planLayout.classList.remove("plan-layout--show-resolved")
116
+ }
117
+ }
118
+
119
+ updatePosition() {
120
+ if (!this.hasPositionTarget) return
121
+
122
+ const highlights = this.openHighlights
123
+ if (highlights.length === 0) {
124
+ this.positionTarget.textContent = ""
125
+ return
126
+ }
127
+
128
+ if (this.currentIndex < 0) {
129
+ this.positionTarget.textContent = `${highlights.length} total`
130
+ } else {
131
+ this.positionTarget.textContent = `${this.currentIndex + 1} of ${highlights.length}`
132
+ }
133
+ }
134
+ }
@@ -1,7 +1,7 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  export default class extends Controller {
4
- static targets = ["content", "popover", "form", "anchorInput", "contextInput", "occurrenceInput", "anchorPreview", "anchorQuote"]
4
+ static targets = ["content", "popover", "form", "anchorInput", "contextInput", "occurrenceInput", "anchorPreview", "anchorQuote", "margin", "threads"]
5
5
  static values = { planId: String }
6
6
 
7
7
  connect() {
@@ -9,13 +9,21 @@ export default class extends Controller {
9
9
  this.contentTarget.addEventListener("mouseup", this.handleMouseUp.bind(this))
10
10
  document.addEventListener("mousedown", this.handleDocumentMouseDown.bind(this))
11
11
  this.highlightAnchors()
12
- this.observeThreadLists()
12
+
13
+ // Watch for broadcast-appended threads and re-highlight
14
+ if (this.hasThreadsTarget) {
15
+ this._threadsObserver = new MutationObserver(() => this.highlightAnchors())
16
+ this._threadsObserver.observe(this.threadsTarget, { childList: true })
17
+ }
13
18
  }
14
19
 
15
20
  disconnect() {
16
21
  this.contentTarget.removeEventListener("mouseup", this.handleMouseUp.bind(this))
17
22
  document.removeEventListener("mousedown", this.handleDocumentMouseDown.bind(this))
18
- if (this.threadListObserver) this.threadListObserver.disconnect()
23
+ if (this._threadsObserver) {
24
+ this._threadsObserver.disconnect()
25
+ this._threadsObserver = null
26
+ }
19
27
  }
20
28
 
21
29
  handleMouseUp(event) {
@@ -72,14 +80,9 @@ export default class extends Controller {
72
80
  : this.selectedText
73
81
  this.anchorPreviewTarget.style.display = "block"
74
82
 
75
- // Position the form in the sidebar at the same vertical level as the selection
76
- const layoutRect = this.element.getBoundingClientRect()
77
- const popoverRect = this.popoverTarget.getBoundingClientRect()
78
- const offsetTop = popoverRect.top - layoutRect.top
79
- this.formTarget.style.position = "absolute"
80
- this.formTarget.style.top = `${offsetTop}px`
81
-
82
- // Show form, hide popover
83
+ // Position form where the popover was, then show it
84
+ this.formTarget.style.top = this.popoverTarget.style.top
85
+ this.formTarget.style.left = this.popoverTarget.style.left
83
86
  this.formTarget.style.display = "block"
84
87
  this.popoverTarget.style.display = "none"
85
88
 
@@ -129,7 +132,6 @@ export default class extends Controller {
129
132
  const anchor = event.currentTarget.dataset.anchor
130
133
  if (!anchor) return
131
134
 
132
- const context = event.currentTarget.closest("[data-anchor-context]")?.dataset.anchorContext || ""
133
135
  const occurrence = event.currentTarget.dataset.anchorOccurrence
134
136
 
135
137
  // Remove existing highlights first
@@ -140,13 +142,55 @@ export default class extends Controller {
140
142
  // Build full text for position lookups
141
143
  this.fullText = this.contentTarget.textContent
142
144
 
143
- // Find and highlight using occurrence index (most reliable), then context, then first match
144
- const highlighted = this.findAndHighlightForThread(anchor, context, occurrence, "anchor-highlight--active", null)
145
+ const highlighted = this.findAndHighlight(anchor, occurrence, "anchor-highlight--active")
145
146
  if (highlighted) {
146
147
  highlighted.scrollIntoView({ behavior: "smooth", block: "center" })
147
148
  }
148
149
  }
149
150
 
151
+ openThreadPopover(event) {
152
+ const threadId = event.currentTarget.dataset.threadId
153
+ if (!threadId) return
154
+
155
+ const popover = document.getElementById(`${threadId}_popover`)
156
+ if (!popover) return
157
+
158
+ // Position the popover near the clicked element
159
+ const trigger = event.currentTarget
160
+ const triggerRect = trigger.getBoundingClientRect()
161
+
162
+ // Hide visually while positioning to prevent flash
163
+ popover.style.visibility = "hidden"
164
+ popover.showPopover()
165
+
166
+ // Position after showing (popover needs to be in top layer first)
167
+ const popoverRect = popover.getBoundingClientRect()
168
+ const viewportWidth = window.innerWidth
169
+ const viewportHeight = window.innerHeight
170
+
171
+ // Default: right of the content area, aligned with the trigger
172
+ let top = triggerRect.top
173
+ let left = triggerRect.right + 12
174
+
175
+ // If it would overflow right, position to the left
176
+ if (left + popoverRect.width > viewportWidth - 16) {
177
+ left = triggerRect.left - popoverRect.width - 12
178
+ }
179
+
180
+ // If it would overflow bottom, shift up
181
+ if (top + popoverRect.height > viewportHeight - 16) {
182
+ top = viewportHeight - popoverRect.height - 16
183
+ }
184
+
185
+ // Ensure it doesn't go outside viewport
186
+ if (top < 16) top = 16
187
+ if (left < 16) left = 16
188
+
189
+ popover.style.top = `${top}px`
190
+ popover.style.left = `${left}px`
191
+ popover.style.visibility = "visible"
192
+ }
193
+
150
194
  extractContext(range, selectedText) {
151
195
  // Grab surrounding text for disambiguation
152
196
  const fullText = this.contentTarget.textContent
@@ -214,105 +258,63 @@ export default class extends Controller {
214
258
  })
215
259
  this.contentTarget.normalize()
216
260
 
261
+ // Clear margin dots
262
+ if (this.hasMarginTarget) {
263
+ this.marginTarget.innerHTML = ""
264
+ }
265
+
217
266
  // Build full text once for position lookups
218
267
  this.fullText = this.contentTarget.textContent
219
268
 
220
269
  const threads = this.element.querySelectorAll("[data-anchor-text]")
221
270
  threads.forEach(thread => {
222
271
  const anchor = thread.dataset.anchorText
223
- const context = thread.dataset.anchorContext
224
272
  const occurrence = thread.dataset.anchorOccurrence
273
+ const status = thread.dataset.threadStatus || "pending"
274
+ const threadId = thread.id
275
+
225
276
  if (anchor && anchor.length > 0) {
226
- this.findAndHighlightForThread(anchor, context, occurrence, "anchor-highlight", thread)
277
+ const isOpen = status === "pending" || status === "todo"
278
+ const statusClass = isOpen ? "anchor-highlight--open" : "anchor-highlight--resolved"
279
+ const mark = this.findAndHighlight(anchor, occurrence, `anchor-highlight ${statusClass}`)
280
+
281
+ if (mark && threadId) {
282
+ // Make highlight clickable to open popover
283
+ mark.dataset.threadId = threadId
284
+ mark.style.cursor = "pointer"
285
+ mark.addEventListener("click", (e) => this.openThreadPopover(e))
286
+
287
+ // Create margin dot
288
+ if (this.hasMarginTarget) {
289
+ this.createMarginDot(mark, threadId, status)
290
+ }
291
+ }
227
292
  }
228
293
  })
229
-
230
- this.positionThreads()
231
- }
232
-
233
- // Re-highlight and reposition when threads are added/removed via turbo stream broadcasts
234
- observeThreadLists() {
235
- this.threadListObserver = new MutationObserver(() => {
236
- // Debounce — multiple mutations may fire in quick succession
237
- clearTimeout(this._repositionTimer)
238
- this._repositionTimer = setTimeout(() => this.highlightAnchors(), 50)
239
- })
240
-
241
- this.element.querySelectorAll(".comment-threads-list").forEach(list => {
242
- this.threadListObserver.observe(list, { childList: true })
243
- })
244
- }
245
-
246
- repositionThreads() {
247
- // Small delay to let the tab panel become visible before measuring positions
248
- setTimeout(() => this.positionThreads(), 10)
249
- }
250
-
251
- positionThreads() {
252
- const sidebar = this.element.querySelector(".plan-layout__sidebar")
253
- if (!sidebar) return
254
-
255
- // Pause observer while reordering DOM to avoid triggering a rehighlight loop
256
- if (this.threadListObserver) this.threadListObserver.disconnect()
257
-
258
- this.positionThreadList("#comment-threads", sidebar)
259
- this.positionThreadList("#resolved-comment-threads", sidebar)
260
-
261
- // Re-observe after reordering
262
- if (this.threadListObserver) {
263
- this.element.querySelectorAll(".comment-threads-list").forEach(list => {
264
- this.threadListObserver.observe(list, { childList: true })
265
- })
266
- }
267
294
  }
268
295
 
269
- positionThreadList(selector, sidebar) {
270
- const threadList = this.element.querySelector(selector)
271
- if (!threadList) return
272
-
273
- const threads = Array.from(threadList.querySelectorAll(".comment-thread"))
274
- if (threads.length === 0) return
275
-
276
- const sidebarRect = sidebar.getBoundingClientRect()
277
-
278
- // Sort threads by their anchor's vertical position in the document
279
- threads.sort((a, b) => {
280
- const markA = a._highlightMark
281
- const markB = b._highlightMark
282
- const yA = markA ? markA.getBoundingClientRect().top : Infinity
283
- const yB = markB ? markB.getBoundingClientRect().top : Infinity
284
- return yA - yB
285
- })
286
-
287
- // Reorder DOM within this list only
288
- threads.forEach(thread => threadList.appendChild(thread))
289
-
290
- // Position threads vertically
291
- const gap = 8
292
- let cursor = 0
293
-
294
- threads.forEach(thread => {
295
- const mark = thread._highlightMark
296
- let desiredY = cursor
297
-
298
- if (mark) {
299
- desiredY = mark.getBoundingClientRect().top - sidebarRect.top + sidebar.scrollTop
300
- }
301
-
302
- const y = Math.max(desiredY, cursor)
303
- thread.style.marginTop = `${y - cursor}px`
304
- cursor = y + thread.offsetHeight + gap
305
- })
296
+ createMarginDot(highlightMark, threadId, status) {
297
+ const contentRect = this.contentTarget.getBoundingClientRect()
298
+ const markRect = highlightMark.getBoundingClientRect()
299
+ const marginRect = this.marginTarget.getBoundingClientRect()
300
+
301
+ const dot = document.createElement("button")
302
+ const isOpen = status === "pending" || status === "todo"
303
+ dot.className = `margin-dot margin-dot--${isOpen ? "open" : "resolved"}`
304
+ dot.style.top = `${markRect.top - marginRect.top}px`
305
+ dot.dataset.threadId = threadId
306
+ dot.addEventListener("click", (e) => this.openThreadPopover(e))
307
+ dot.title = `${status} comment`
308
+
309
+ this.marginTarget.appendChild(dot)
306
310
  }
307
311
 
308
- // Primary highlight method: uses occurrence index (from server-side OT positions)
309
- // to find the correct Nth occurrence of anchor text in the rendered DOM.
310
- // Falls back to context matching, then to first occurrence.
311
- findAndHighlightForThread(text, context, occurrence, className, threadEl) {
312
+ // Find and highlight the Nth occurrence of text in the rendered DOM.
313
+ // Uses the occurrence index from server-side positional data.
314
+ findAndHighlight(text, occurrence, className) {
312
315
  const fullText = this.fullText
313
316
  let targetIndex = -1
314
317
 
315
- // Strategy 1: Use the occurrence index from the server (most reliable)
316
318
  if (occurrence !== undefined && occurrence !== "") {
317
319
  const occurrenceNum = parseInt(occurrence, 10)
318
320
  if (!isNaN(occurrenceNum)) {
@@ -320,27 +322,9 @@ export default class extends Controller {
320
322
  }
321
323
  }
322
324
 
323
- // Strategy 2: Fall back to context matching
324
- if (targetIndex === -1 && context && context.length > 0) {
325
- const contextIndex = fullText.indexOf(context)
326
- if (contextIndex !== -1) {
327
- targetIndex = fullText.indexOf(text, contextIndex)
328
- if (targetIndex === -1 || targetIndex > contextIndex + context.length) {
329
- targetIndex = fullText.indexOf(text)
330
- }
331
- }
332
- }
333
-
334
- // Strategy 3: Fall back to first occurrence
335
- if (targetIndex === -1) {
336
- targetIndex = fullText.indexOf(text)
337
- }
338
-
339
325
  if (targetIndex === -1) return null
340
326
 
341
- const mark = this.highlightAtIndex(targetIndex, text.length, className)
342
- if (mark && threadEl) threadEl._highlightMark = mark
343
- return mark
327
+ return this.highlightAtIndex(targetIndex, text.length, className)
344
328
  }
345
329
 
346
330
  findNthOccurrence(text, search, n) {
@@ -352,43 +336,6 @@ export default class extends Controller {
352
336
  return pos
353
337
  }
354
338
 
355
- findAndHighlightWithContext(text, context, className) {
356
- // Use context to find the right occurrence of the anchor text
357
- const fullText = this.fullText
358
- let targetIndex
359
-
360
- if (context && context.length > 0) {
361
- const contextIndex = fullText.indexOf(context)
362
- if (contextIndex !== -1) {
363
- // Find the anchor text within the context region
364
- targetIndex = fullText.indexOf(text, contextIndex)
365
- if (targetIndex === -1 || targetIndex > contextIndex + context.length) {
366
- targetIndex = fullText.indexOf(text) // fallback
367
- }
368
- } else {
369
- targetIndex = fullText.indexOf(text)
370
- }
371
- } else {
372
- targetIndex = fullText.indexOf(text)
373
- }
374
-
375
- if (targetIndex === -1) return null
376
-
377
- // Find the thread element to store the mark reference
378
- const threads = this.element.querySelectorAll(".comment-thread[data-anchor-text]")
379
- let threadEl = null
380
- for (const t of threads) {
381
- if (t.dataset.anchorText === text && t.dataset.anchorContext === (context || "")) {
382
- threadEl = t
383
- break
384
- }
385
- }
386
-
387
- const mark = this.highlightAtIndex(targetIndex, text.length, className)
388
- if (mark && threadEl) threadEl._highlightMark = mark
389
- return mark
390
- }
391
-
392
339
  highlightAtIndex(startIndex, length, className) {
393
340
  if (startIndex < 0 || length <= 0) return null
394
341
 
@@ -433,13 +380,4 @@ export default class extends Controller {
433
380
 
434
381
  return firstHighlighted
435
382
  }
436
-
437
- // Keep legacy method for scrollToAnchor (single-use highlight)
438
- findAndHighlight(text, className) {
439
- if (!text || text.length === 0) return null
440
- const fullText = this.contentTarget.textContent
441
- const index = fullText.indexOf(text)
442
- if (index === -1) return null
443
- return this.highlightAtIndex(index, text.length, className)
444
- }
445
383
  }
@@ -46,7 +46,7 @@ module CoPlan
46
46
  plan_version: version,
47
47
  created_by_user: created_by,
48
48
  anchor_text: item[:anchor_text],
49
- status: "open"
49
+ status: "pending"
50
50
  )
51
51
 
52
52
  thread.comments.create!(
@@ -60,10 +60,10 @@ module CoPlan
60
60
  end
61
61
 
62
62
  def broadcast_new_thread(plan, thread)
63
- Broadcaster.prepend_to(
63
+ Broadcaster.append_to(
64
64
  plan,
65
- target: "comment-threads",
66
- partial: "coplan/comment_threads/thread",
65
+ target: "plan-threads",
66
+ partial: "coplan/comment_threads/thread_popover",
67
67
  locals: { thread: thread, plan: plan }
68
68
  )
69
69
  end
@@ -1,6 +1,8 @@
1
1
  module CoPlan
2
2
  class CommentThread < ApplicationRecord
3
- STATUSES = %w[open resolved accepted dismissed].freeze
3
+ STATUSES = %w[pending todo resolved discarded].freeze
4
+ OPEN_STATUSES = %w[pending todo].freeze
5
+ CLOSED_STATUSES = %w[resolved discarded].freeze
4
6
 
5
7
  attr_accessor :anchor_occurrence
6
8
 
@@ -16,10 +18,10 @@ module CoPlan
16
18
 
17
19
  before_create :resolve_anchor_position
18
20
 
19
- scope :open_threads, -> { where(status: "open") }
21
+ scope :open_threads, -> { where(status: OPEN_STATUSES) }
20
22
  scope :current, -> { where(out_of_date: false) }
21
- scope :active, -> { where(status: "open", out_of_date: false) }
22
- scope :archived, -> { where("status != 'open' OR out_of_date = ?", true) }
23
+ scope :active, -> { where(status: OPEN_STATUSES, out_of_date: false) }
24
+ scope :archived, -> { where("status NOT IN (?) OR out_of_date = ?", OPEN_STATUSES, true) }
23
25
 
24
26
  # Transforms anchor positions through intervening version edits using OT.
25
27
  # Threads without positional data (anchor_start/anchor_end/anchor_revision)
@@ -94,11 +96,15 @@ module CoPlan
94
96
  end
95
97
 
96
98
  def accept!(user)
97
- update!(status: "accepted", resolved_by_user: user)
99
+ update!(status: "todo", resolved_by_user: user)
98
100
  end
99
101
 
100
- def dismiss!(user)
101
- update!(status: "dismissed", resolved_by_user: user)
102
+ def discard!(user)
103
+ update!(status: "discarded", resolved_by_user: user)
104
+ end
105
+
106
+ def open?
107
+ OPEN_STATUSES.include?(status)
102
108
  end
103
109
 
104
110
  def anchor_valid?
@@ -12,7 +12,7 @@ module CoPlan
12
12
  record.plan.created_by_user_id == user.id
13
13
  end
14
14
 
15
- def dismiss?
15
+ def discard?
16
16
  record.plan.created_by_user_id == user.id
17
17
  end
18
18
 
@@ -8,7 +8,8 @@
8
8
  <blockquote class="comment-form__quote" data-coplan--text-selection-target="anchorQuote"></blockquote>
9
9
  </div>
10
10
  <div class="form-group">
11
- <textarea name="comment_thread[body_markdown]" id="comment_thread_body_markdown" rows="3" placeholder="Write a comment..." required></textarea>
11
+ <textarea name="comment_thread[body_markdown]" id="comment_thread_body_markdown" rows="3" placeholder="Write a comment..." required
12
+ data-controller="coplan--comment-form" data-action="keydown->coplan--comment-form#submitOnEnter"></textarea>
12
13
  </div>
13
14
  <div class="comment-form__actions">
14
15
  <button type="submit" class="btn btn--primary btn--sm">Comment</button>
@@ -1,7 +1,8 @@
1
1
  <div class="comment-thread__reply" id="<%= dom_id(thread, :reply_form) %>">
2
2
  <%= form_with url: plan_comment_thread_comments_path(plan, thread), method: :post, data: { action: "turbo:submit-end->coplan--text-selection#resetReplyForm" } do |f| %>
3
3
  <div class="form-group">
4
- <textarea name="comment[body_markdown]" rows="2" placeholder="Reply..." required></textarea>
4
+ <textarea name="comment[body_markdown]" rows="2" placeholder="Reply..." required
5
+ data-controller="coplan--comment-form" data-action="keydown->coplan--comment-form#submitOnEnter"></textarea>
5
6
  </div>
6
7
  <button type="submit" class="btn btn--secondary btn--sm">Reply</button>
7
8
  <% end %>
@@ -5,7 +5,7 @@
5
5
  data-thread-id="<%= thread.id %>">
6
6
  <div class="comment-thread__header">
7
7
  <div class="comment-thread__meta">
8
- <span class="badge badge--<%= thread.status == 'open' ? 'live' : 'abandoned' %>"><%= thread.status %></span>
8
+ <span class="badge badge--<%= thread.open? ? 'live' : 'abandoned' %>"><%= thread.status %></span>
9
9
  <% if thread.out_of_date? %>
10
10
  <span class="badge badge--abandoned">out of date</span>
11
11
  <% end %>
@@ -26,16 +26,13 @@
26
26
  <% is_plan_author = viewer.nil? || plan.created_by_user_id == viewer.id %>
27
27
  <% is_thread_author = viewer.nil? || thread.created_by_user_id == viewer.id %>
28
28
 
29
- <% if thread.status == "open" %>
29
+ <% if thread.open? %>
30
30
  <%= render partial: "coplan/comment_threads/reply_form", locals: { thread: thread, plan: plan } %>
31
31
 
32
32
  <div class="comment-thread__actions">
33
- <% if is_plan_author || is_thread_author %>
34
- <%= link_to "Resolve", resolve_plan_comment_thread_path(plan, thread), data: { turbo_method: :patch }, class: "btn btn--secondary btn--sm" %>
35
- <% end %>
36
33
  <% if is_plan_author %>
37
34
  <%= link_to "Accept", accept_plan_comment_thread_path(plan, thread), data: { turbo_method: :patch }, class: "btn btn--secondary btn--sm" %>
38
- <%= link_to "Dismiss", dismiss_plan_comment_thread_path(plan, thread), data: { turbo_method: :patch }, class: "btn btn--secondary btn--sm" %>
35
+ <%= link_to "Discard", discard_plan_comment_thread_path(plan, thread), data: { turbo_method: :patch }, class: "btn btn--secondary btn--sm" %>
39
36
  <% end %>
40
37
  </div>
41
38
  <% else %>
@@ -0,0 +1,58 @@
1
+ <div class="thread-popover-data"
2
+ id="<%= dom_id(thread) %>"
3
+ data-anchor-text="<%= thread.anchor_text %>"
4
+ data-anchor-occurrence="<%= thread.anchor_occurrence_index %>"
5
+ data-thread-id="<%= thread.id %>"
6
+ data-thread-status="<%= thread.status %>">
7
+
8
+ <div popover="auto" id="<%= dom_id(thread) %>_popover" class="thread-popover">
9
+ <div class="thread-popover__header">
10
+ <span class="badge badge--<%= thread.open? ? 'live' : 'abandoned' %>"><%= thread.status %></span>
11
+ <% if thread.out_of_date? %>
12
+ <span class="badge badge--abandoned">out of date</span>
13
+ <% end %>
14
+ </div>
15
+
16
+ <% if thread.anchored? %>
17
+ <blockquote class="thread-popover__quote"><%= thread.anchor_preview %></blockquote>
18
+ <% end %>
19
+
20
+ <div class="thread-popover__comments" id="<%= dom_id(thread, :comments) %>">
21
+ <% thread.comments.each do |comment| %>
22
+ <%= render partial: "coplan/comments/comment", locals: { comment: comment } %>
23
+ <% end %>
24
+ </div>
25
+
26
+ <%# current_user may not be available during Turbo Stream broadcasts %>
27
+ <% viewer = local_assigns.fetch(:current_user, nil) %>
28
+ <% is_plan_author = viewer.nil? || plan.created_by_user_id == viewer&.id %>
29
+ <% is_thread_author = viewer.nil? || thread.created_by_user_id == viewer&.id %>
30
+
31
+ <% if thread.open? %>
32
+ <div class="thread-popover__reply">
33
+ <%= form_with url: plan_comment_thread_comments_path(plan, thread), method: :post, data: { action: "turbo:submit-end->coplan--text-selection#resetReplyForm" } do |f| %>
34
+ <div class="form-group">
35
+ <textarea name="comment[body_markdown]" rows="2" placeholder="Reply..." required
36
+ data-controller="coplan--comment-form" data-action="keydown->coplan--comment-form#submitOnEnter"></textarea>
37
+ </div>
38
+ <button type="submit" class="btn btn--secondary btn--sm">Reply</button>
39
+ <% end %>
40
+ </div>
41
+
42
+ <div class="thread-popover__actions">
43
+ <% if is_plan_author %>
44
+ <% if thread.status == "pending" %>
45
+ <%= button_to "Accept", accept_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm" %>
46
+ <% end %>
47
+ <%= button_to "Discard", discard_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm" %>
48
+ <% end %>
49
+ </div>
50
+ <% else %>
51
+ <% if is_plan_author || is_thread_author %>
52
+ <div class="thread-popover__actions">
53
+ <%= button_to "Reopen", reopen_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm" %>
54
+ </div>
55
+ <% end %>
56
+ <% end %>
57
+ </div>
58
+ </div>