collavre 0.2.3 → 0.2.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d5c931501423b83339fe8760249bae4ff611eb1ab835167dade3a6d8ee587630
4
- data.tar.gz: dd34bc4c214b8a5df3cbfd189ca3dd22aa47fcf22eaa4974a63fd23b2bc8754a
3
+ metadata.gz: 35c8808cc85f9a6a43fc6800c660850f2725137c805765436f7439dd6b75e07c
4
+ data.tar.gz: f6783666bfa9dc57128f4e5497d1e5267dfbf0de47cc9f0a8d95b021f1e35843
5
5
  SHA512:
6
- metadata.gz: ada88101f9013822d0aa944d16b040db4e2ce954fc1c5fd055c391a222a273d3e469a99c7f7ab255bc5ad2bc149e93dd72cdf3d597f49957ae825d3fd93fa89a
7
- data.tar.gz: be1b0f2704a49f5cf446f639dc197d0d69fef529595e2b4a46401a4caa801c5ac44cb1df2a14799ee043e7021648f495ff41e16133b2a0a9936e7542f5af0b1e
6
+ metadata.gz: ed9266ed122d64cedc2fbda308d3400d837776bb60a71b39a21c3a717bf5dad6ecfb2fda336928fa1ac112bb59c5fda5171f627583648a353d53fe5dbc2508c4
7
+ data.tar.gz: f46c41dde92548ff415f9eaa5b233f8d35af31f69ad7859c122a4e7cca52d992b6459f147af270652193e5db815fcd7669a632eee5928480d3a1babd9960e028
@@ -9,38 +9,23 @@
9
9
  max-height: 80vh;
10
10
  min-width: 200px;
11
11
  min-height: 200px;
12
- transition: all 0.2s;
12
+ transition: top 0.25s ease, left 0.25s ease, width 0.25s ease, height 0.25s ease,
13
+ right 0.25s ease, bottom 0.25s ease, border-radius 0.25s ease,
14
+ box-shadow 0.25s ease, padding 0.25s ease;
13
15
  max-width: calc(100vw - 0.5em) !important;
14
16
  }
15
17
 
16
18
  body.chat-fullscreen {
17
- margin: 0;
18
- padding: 0;
19
- height: 100vh;
20
19
  overflow: hidden;
21
20
  }
22
21
 
23
- body.chat-fullscreen main {
24
- height: 100vh;
25
- width: 100vw;
26
- max-width: none;
27
- padding: 0;
28
- margin: 0;
29
- box-sizing: border-box;
30
- }
31
-
32
- .comments-fullscreen-page {
33
- height: 100vh;
34
- width: 100vw;
35
- padding: 0;
36
- margin: 0;
37
- box-sizing: border-box;
38
- }
39
-
40
- .comments-fullscreen-page #comments-popup,
41
22
  #comments-popup[data-fullscreen="true"] {
42
23
  display: flex !important;
43
- position: static;
24
+ position: fixed;
25
+ top: 0;
26
+ left: 0;
27
+ right: 0;
28
+ bottom: 0;
44
29
  width: 100%;
45
30
  height: 100%;
46
31
  max-width: none !important;
@@ -48,7 +33,7 @@ body.chat-fullscreen main {
48
33
  border-radius: 0;
49
34
  box-shadow: none;
50
35
  border: none;
51
- z-index: auto;
36
+ z-index: 9999;
52
37
  box-sizing: border-box;
53
38
  padding: 1em;
54
39
  }
@@ -517,7 +502,9 @@ body.chat-fullscreen main {
517
502
  }
518
503
  }
519
504
 
520
- .comment-owner-only {
505
+ .comment-owner-only,
506
+ .comment-delete-hidden,
507
+ .comment-approve-hidden {
521
508
  display: none;
522
509
  }
523
510
 
@@ -616,13 +603,11 @@ body.chat-fullscreen main {
616
603
  transform: translateY(0);
617
604
  }
618
605
 
619
- .comments-fullscreen-page #comments-popup,
620
606
  #comments-popup[data-fullscreen="true"] {
621
607
  display: flex !important;
622
608
  transform: none;
623
609
  border-radius: 0;
624
610
  padding: 1em;
625
- position: static;
626
611
  width: 100%;
627
612
  height: 100%;
628
613
  box-sizing: border-box;
@@ -1,11 +1,19 @@
1
1
  module Collavre
2
2
  class CommentsController < ApplicationController
3
- layout "collavre/chat", only: [ :fullscreen ]
4
3
  before_action :set_creative
5
4
  before_action :set_comment, only: [ :destroy, :show, :update, :convert, :approve, :update_action ]
6
5
 
7
6
  def fullscreen
8
- @creative_snippet = @creative.creative_snippet
7
+ # Render the creative index page with comments popup auto-opened in fullscreen.
8
+ # This way the creative list loads behind the popup, so exiting fullscreen
9
+ # doesn't require a page reload.
10
+ @parent_creative = @creative
11
+ @creatives = []
12
+ @shared_list = @creative.all_shared_users
13
+ @auto_fullscreen = true
14
+ # Prepend creatives prefix so partials like 'add_button' resolve to collavre/creatives/_add_button
15
+ lookup_context.prefixes.prepend "collavre/creatives"
16
+ render "collavre/creatives/index"
9
17
  end
10
18
 
11
19
  def index
@@ -3,7 +3,7 @@ import { renderCommentMarkdown } from '../lib/utils/markdown'
3
3
 
4
4
  // Connects to data-controller="comment"
5
5
  export default class extends Controller {
6
- static targets = ["ownerButton"]
6
+ static targets = ["ownerButton", "deleteButton", "approveButton", "actionApproveControls"]
7
7
 
8
8
  connect() {
9
9
  const contentElement = this.element.querySelector('.comment-content')
@@ -15,12 +15,39 @@ export default class extends Controller {
15
15
 
16
16
  this.currentUserId = document.body.dataset.currentUserId
17
17
  const commentAuthorId = this.element.dataset.userId
18
+ const creativeOwnerId = this.element.dataset.creativeOwnerId
19
+ const isAdmin = document.body.dataset.systemAdmin === 'true'
20
+ const isOwner = this.currentUserId && commentAuthorId && this.currentUserId === commentAuthorId
21
+ const isCreativeOwner = this.currentUserId && creativeOwnerId && this.currentUserId === creativeOwnerId
18
22
 
19
- if (this.currentUserId && commentAuthorId && this.currentUserId === commentAuthorId) {
23
+ if (isOwner) {
20
24
  this.ownerButtonTargets.forEach((button) => {
21
25
  button.classList.remove('comment-owner-only')
22
26
  })
23
27
  }
28
+
29
+ // Show delete button if user is comment author, creative owner, or admin
30
+ if (isOwner || isCreativeOwner || isAdmin) {
31
+ this.deleteButtonTargets.forEach((button) => {
32
+ button.classList.remove('comment-delete-hidden')
33
+ })
34
+ }
35
+
36
+ // Show approve button: user must be the designated approver or a system admin
37
+ const hasPendingAction = this.element.dataset.hasPendingAction === 'true'
38
+ const approverId = this.element.dataset.approverId
39
+ const isApprover = this.currentUserId && approverId && this.currentUserId === approverId
40
+ const canApprove = hasPendingAction && (isApprover || isAdmin)
41
+
42
+ if (canApprove) {
43
+ this.approveButtonTargets.forEach((button) => {
44
+ button.classList.remove('comment-approve-hidden')
45
+ })
46
+ // Also show action block approve controls (edit action button, form)
47
+ this.actionApproveControlsTargets.forEach((el) => {
48
+ el.classList.remove('comment-approve-hidden')
49
+ })
50
+ }
24
51
  }
25
52
 
26
53
  triggerReactionPicker(event) {
@@ -11,7 +11,9 @@ export default class extends Controller {
11
11
  'closeButton',
12
12
  'leftHandle',
13
13
  'rightHandle',
14
- 'fullscreenLink',
14
+ 'fullscreenButton',
15
+ 'fullscreenIcon',
16
+ 'exitFullscreenIcon',
15
17
  ]
16
18
 
17
19
  connect() {
@@ -31,11 +33,13 @@ export default class extends Controller {
31
33
  this.handleOnline = this.handleOnline.bind(this)
32
34
  this.handleWindowFocus = this.handleWindowFocus.bind(this)
33
35
  this.handleVisibilityChange = this.handleVisibilityChange.bind(this)
36
+ this.handlePopState = this.handlePopState.bind(this)
34
37
 
35
38
  document.addEventListener(CREATIVE_CLICK_EVENT, this.handleCreativeClick)
36
39
  window.addEventListener('online', this.handleOnline)
37
40
  window.addEventListener('focus', this.handleWindowFocus)
38
41
  document.addEventListener('visibilitychange', this.handleVisibilityChange)
42
+ window.addEventListener('popstate', this.handlePopState)
39
43
 
40
44
  if (this.hasCloseButtonTarget) {
41
45
  this.closeButtonTarget.addEventListener('click', () => this.close())
@@ -61,7 +65,21 @@ export default class extends Controller {
61
65
  form.addEventListener('submit', () => window.localStorage.removeItem(SIZE_STORAGE_KEY))
62
66
  })
63
67
 
64
- if (this.isFullscreen()) {
68
+ if (this.element.dataset.autoFullscreen === 'true') {
69
+ // Auto-fullscreen: open popup for creative then enter fullscreen
70
+ delete this.element.dataset.autoFullscreen
71
+ // Set previous URL to creative page (not the fullscreen URL)
72
+ const creativeId = this.element.dataset.creativeId
73
+ if (creativeId) {
74
+ this._previousUrl = `/creatives/${creativeId}`
75
+ }
76
+ requestAnimationFrame(() => {
77
+ this.openForCreative()
78
+ this._enterFullscreenImmediate()
79
+ })
80
+ } else if (this.isFullscreen()) {
81
+ // Sync UI for initial fullscreen state (legacy fullscreen page)
82
+ this._syncFullscreenUI(true)
65
83
  // Defer to ensure all sibling controllers are connected
66
84
  requestAnimationFrame(() => this.openForCreative())
67
85
  } else {
@@ -75,6 +93,7 @@ export default class extends Controller {
75
93
  window.removeEventListener('online', this.handleOnline)
76
94
  window.removeEventListener('focus', this.handleWindowFocus)
77
95
  document.removeEventListener('visibilitychange', this.handleVisibilityChange)
96
+ window.removeEventListener('popstate', this.handlePopState)
78
97
  window.removeEventListener('mousemove', this.handleResizeMove)
79
98
  window.removeEventListener('mouseup', this.handleResizeStop)
80
99
  if (this.isMobile()) {
@@ -131,8 +150,6 @@ export default class extends Controller {
131
150
  this.element.dataset.canComment = canComment ? 'true' : 'false'
132
151
  this.titleTarget.textContent = snippet
133
152
 
134
- this.updateFullscreenLink(resolvedCreativeId)
135
-
136
153
  this.prepareSize()
137
154
 
138
155
  this.showPopup()
@@ -161,8 +178,6 @@ export default class extends Controller {
161
178
  this.element.dataset.canComment = canComment ? 'true' : 'false'
162
179
  this.titleTarget.textContent = snippet
163
180
 
164
- this.updateFullscreenLink(resolvedCreativeId)
165
-
166
181
  this.showPopup()
167
182
 
168
183
  await this.notifyChildControllers({ creativeId: resolvedCreativeId, canComment })
@@ -388,11 +403,304 @@ export default class extends Controller {
388
403
  }
389
404
  }
390
405
 
391
- updateFullscreenLink(creativeId) {
392
- if (!this.hasFullscreenLinkTarget || !creativeId) return
393
- const template = this.element.dataset.fullscreenUrlTemplate
394
- if (!template) return
395
- this.fullscreenLinkTarget.href = template.replace('__CREATIVE_ID__', creativeId)
406
+ // Enter fullscreen immediately without animation (for auto-fullscreen on page load)
407
+ _enterFullscreenImmediate() {
408
+ const el = this.element
409
+ el.style.transition = 'none'
410
+ el.dataset.fullscreen = 'true'
411
+ document.body.classList.add('chat-fullscreen')
412
+ this._syncFullscreenUI(true)
413
+ // Clear any inline position styles so CSS fullscreen rules apply
414
+ el.style.top = ''
415
+ el.style.left = ''
416
+ el.style.right = ''
417
+ el.style.bottom = ''
418
+ el.style.width = ''
419
+ el.style.height = ''
420
+ el.style.position = ''
421
+ // Force layout then restore transition
422
+ el.offsetHeight // eslint-disable-line no-unused-expressions
423
+ el.style.transition = ''
424
+
425
+ // URL is already /comments/fullscreen, no pushState needed
426
+ requestAnimationFrame(() => this.listController?.scrollToBottom())
427
+ }
428
+
429
+ toggleFullscreen() {
430
+ const entering = !this.isFullscreen()
431
+ const el = this.element
432
+
433
+ if (entering) {
434
+ // Save current inline styles for later restore
435
+ this._savedStyles = {
436
+ top: el.style.top,
437
+ right: el.style.right,
438
+ left: el.style.left,
439
+ width: el.style.width,
440
+ height: el.style.height,
441
+ }
442
+
443
+ // Capture current visual position
444
+ const rect = el.getBoundingClientRect()
445
+
446
+ // Disable transition, pin to current position as fixed
447
+ el.style.transition = 'none'
448
+ el.style.position = 'fixed'
449
+ el.style.top = `${rect.top}px`
450
+ el.style.left = `${rect.left}px`
451
+ el.style.right = 'auto'
452
+ el.style.width = `${rect.width}px`
453
+ el.style.height = `${rect.height}px`
454
+
455
+ // Force layout so the pinned position is applied
456
+ el.offsetHeight // eslint-disable-line no-unused-expressions
457
+
458
+ // Now enable transition and expand to fullscreen
459
+ el.style.transition = ''
460
+ el.dataset.fullscreen = 'true'
461
+ document.body.classList.add('chat-fullscreen')
462
+ this._syncFullscreenUI(true)
463
+
464
+ // Clear inline position so CSS fullscreen rules take over
465
+ el.style.top = '0'
466
+ el.style.left = '0'
467
+ el.style.right = '0'
468
+ el.style.bottom = '0'
469
+ el.style.width = '100%'
470
+ el.style.height = '100%'
471
+
472
+ // Update URL
473
+ const creativeId = el.dataset.creativeId
474
+ if (creativeId) {
475
+ this._previousUrl = window.location.href
476
+ const fullscreenPath = `/creatives/${creativeId}/comments/fullscreen`
477
+ window.history.pushState({ fullscreen: true }, '', fullscreenPath)
478
+ }
479
+
480
+ // Clean up inline styles after transition ends
481
+ const cleanup = () => {
482
+ el.removeEventListener('transitionend', cleanup)
483
+ el.style.top = ''
484
+ el.style.left = ''
485
+ el.style.right = ''
486
+ el.style.bottom = ''
487
+ el.style.width = ''
488
+ el.style.height = ''
489
+ el.style.position = ''
490
+ }
491
+ el.addEventListener('transitionend', cleanup, { once: true })
492
+ // Fallback if transitionend doesn't fire
493
+ setTimeout(cleanup, 300)
494
+
495
+ } else {
496
+ const savedStyles = this._savedStyles
497
+ this._savedStyles = null
498
+
499
+ // Calculate target position using the same logic as updatePosition()
500
+ // so cleanup can apply it directly without calling updatePosition() (which would cause a snap)
501
+ const creativeId = el.dataset.creativeId
502
+ const scrollY = window.scrollY || window.pageYOffset
503
+
504
+ // Final absolute-position values (what updatePosition would set)
505
+ let finalTop = '' // px string with scrollY included
506
+ let finalRight = '' // px string
507
+ let finalWidth = savedStyles?.width || ''
508
+ let finalHeight = savedStyles?.height || ''
509
+
510
+ // Fixed-position animation targets (viewport-relative)
511
+ let animTop, animLeft, animWidth, animHeight
512
+
513
+ // Try to find the comment button for precise positioning
514
+ let targetButton = this.currentButton
515
+ if (!targetButton && creativeId) {
516
+ const row = document.querySelector(`creative-tree-row[creative-id="${creativeId}"]`)
517
+ targetButton = row?.querySelector('.comments-btn')
518
+ }
519
+
520
+ if (targetButton) {
521
+ this.currentButton = targetButton
522
+ const btnRect = targetButton.getBoundingClientRect()
523
+ const rightPx = window.innerWidth - btnRect.right + 24
524
+
525
+ animWidth = parseFloat(finalWidth) || 420
526
+ animHeight = parseFloat(finalHeight) || 640
527
+
528
+ // Calculate top in absolute coords (with scrollY) — same as updatePosition
529
+ let absTop = btnRect.bottom + scrollY + 4
530
+ const absBottom = absTop + animHeight
531
+ const viewportBottom = scrollY + window.innerHeight
532
+ if (absBottom > viewportBottom) {
533
+ absTop = Math.max(scrollY + 4, viewportBottom - animHeight - 4)
534
+ }
535
+
536
+ // Store final absolute-position values for cleanup
537
+ finalTop = `${absTop}px`
538
+ finalRight = `${rightPx}px`
539
+
540
+ // Convert to fixed coordinates for animation
541
+ animTop = absTop - scrollY
542
+ animLeft = window.innerWidth - rightPx - animWidth
543
+ } else if (savedStyles && Object.values(savedStyles).some(v => v)) {
544
+ // Fallback to saved styles
545
+ const rightVal = parseFloat(savedStyles.right) || 32
546
+ animWidth = parseFloat(savedStyles.width) || 420
547
+ animHeight = parseFloat(savedStyles.height) || 640
548
+ animLeft = savedStyles.left ? parseFloat(savedStyles.left) : (window.innerWidth - rightVal - animWidth)
549
+ animTop = parseFloat(savedStyles.top) ? (parseFloat(savedStyles.top) - scrollY) : 100
550
+
551
+ finalTop = savedStyles.top || ''
552
+ finalRight = savedStyles.right || ''
553
+ } else {
554
+ // No reference at all: use CSS defaults
555
+ animWidth = 420
556
+ animHeight = 640
557
+ animLeft = window.innerWidth - 32 - animWidth // right: 2em
558
+ animTop = 100
559
+ }
560
+
561
+ // Animated exit: pin at fullscreen position, then shrink to target
562
+ const fsRect = el.getBoundingClientRect()
563
+
564
+ el.style.transition = 'none'
565
+ el.style.position = 'fixed'
566
+ el.style.top = `${fsRect.top}px`
567
+ el.style.left = `${fsRect.left}px`
568
+ el.style.right = 'auto'
569
+ el.style.bottom = 'auto'
570
+ el.style.width = `${fsRect.width}px`
571
+ el.style.height = `${fsRect.height}px`
572
+
573
+ el.dataset.fullscreen = 'false'
574
+ document.body.classList.remove('chat-fullscreen')
575
+ this._syncFullscreenUI(false)
576
+
577
+ // Force layout so the pinned position is applied
578
+ el.offsetHeight // eslint-disable-line no-unused-expressions
579
+
580
+ // Animate to target position (fixed coordinates)
581
+ el.style.transition = ''
582
+ el.style.top = `${animTop}px`
583
+ el.style.left = `${animLeft}px`
584
+ el.style.width = `${animWidth}px`
585
+ el.style.height = `${animHeight}px`
586
+
587
+ const cleanup = () => {
588
+ el.removeEventListener('transitionend', cleanup)
589
+ // Switch from fixed back to default (absolute) positioning
590
+ // Apply the pre-calculated absolute coords directly — no updatePosition() needed
591
+ el.style.transition = 'none'
592
+ el.style.position = ''
593
+ el.style.bottom = ''
594
+
595
+ if (targetButton) {
596
+ // Set absolute coords matching updatePosition output
597
+ el.style.top = finalTop
598
+ el.style.right = finalRight
599
+ el.style.left = ''
600
+ el.style.width = finalWidth
601
+ el.style.height = finalHeight
602
+ } else if (savedStyles) {
603
+ el.style.top = ''
604
+ el.style.left = ''
605
+ el.style.right = ''
606
+ el.style.width = ''
607
+ el.style.height = ''
608
+ Object.assign(el.style, savedStyles)
609
+ } else {
610
+ el.style.top = ''
611
+ el.style.left = ''
612
+ el.style.right = ''
613
+ el.style.width = ''
614
+ el.style.height = ''
615
+ }
616
+
617
+ // Force layout then restore transitions
618
+ el.offsetHeight // eslint-disable-line no-unused-expressions
619
+ el.style.transition = ''
620
+
621
+ // Scroll active topic into view after popup has settled at final size
622
+ this.topicsController?.scrollToActiveTopic()
623
+ }
624
+ el.addEventListener('transitionend', cleanup, { once: true })
625
+ setTimeout(cleanup, 300)
626
+
627
+ // Update URL — append open_comments=true so the popup stays open on refresh
628
+ let backUrl = this._previousUrl || (creativeId ? `/creatives/${creativeId}` : null)
629
+ if (backUrl) {
630
+ const url = new URL(backUrl, window.location.origin)
631
+ url.searchParams.set('open_comments', 'true')
632
+ window.history.pushState({ fullscreen: false }, '', url.pathname + url.search)
633
+ }
634
+ this._previousUrl = null
635
+
636
+ // Scroll the selected creative row into view after exiting fullscreen
637
+ if (creativeId) {
638
+ requestAnimationFrame(() => {
639
+ const row = document.querySelector(`creative-tree-row[creative-id="${creativeId}"]`)
640
+ row?.scrollIntoView({ behavior: 'smooth', block: 'center' })
641
+ })
642
+ }
643
+ }
644
+
645
+ // Scroll to bottom after layout change
646
+ requestAnimationFrame(() => {
647
+ this.listController?.scrollToBottom()
648
+ })
649
+ }
650
+
651
+ handlePopState(event) {
652
+ const isFs = event.state?.fullscreen === true
653
+ if (isFs !== this.isFullscreen()) {
654
+ const el = this.element
655
+ // Clear any animation inline styles to avoid stale positions
656
+ el.style.transition = 'none'
657
+ el.style.position = ''
658
+ el.style.top = ''
659
+ el.style.left = ''
660
+ el.style.right = ''
661
+ el.style.bottom = ''
662
+ el.style.width = ''
663
+ el.style.height = ''
664
+
665
+ el.dataset.fullscreen = isFs ? 'true' : 'false'
666
+ document.body.classList.toggle('chat-fullscreen', isFs)
667
+ this._syncFullscreenUI(isFs)
668
+
669
+ if (!isFs && this._savedStyles) {
670
+ Object.assign(el.style, this._savedStyles)
671
+ this._savedStyles = null
672
+ }
673
+
674
+ // Restore transition
675
+ el.offsetHeight // eslint-disable-line no-unused-expressions
676
+ el.style.transition = ''
677
+
678
+ requestAnimationFrame(() => this.listController?.scrollToBottom())
679
+ }
680
+ }
681
+
682
+ _syncFullscreenUI(entering) {
683
+ if (this.hasFullscreenIconTarget) {
684
+ this.fullscreenIconTarget.style.display = entering ? 'none' : ''
685
+ }
686
+ if (this.hasExitFullscreenIconTarget) {
687
+ this.exitFullscreenIconTarget.style.display = entering ? '' : 'none'
688
+ }
689
+ if (this.hasLeftHandleTarget) {
690
+ this.leftHandleTarget.style.display = entering ? 'none' : ''
691
+ }
692
+ if (this.hasRightHandleTarget) {
693
+ this.rightHandleTarget.style.display = entering ? 'none' : ''
694
+ }
695
+ if (this.hasCloseButtonTarget) {
696
+ this.closeButtonTarget.style.display = entering ? 'none' : ''
697
+ }
698
+ if (this.hasFullscreenButtonTarget) {
699
+ const label = entering
700
+ ? (this.element.dataset.exitFullscreenLabel || 'Exit full screen')
701
+ : (this.element.dataset.fullscreenLabel || 'Full screen')
702
+ this.fullscreenButtonTarget.setAttribute('aria-label', label)
703
+ }
396
704
  }
397
705
 
398
706
  openFromUrl() {
@@ -86,7 +86,7 @@ export default class extends Controller {
86
86
  }
87
87
 
88
88
  renderTopics(topics, canManage = false) {
89
- const dragActions = canManage
89
+ const dragActions = canManage
90
90
  ? 'dragstart->comments--topics#handleTopicDragStart dragend->comments--topics#handleTopicDragEnd'
91
91
  : ''
92
92
  const dropActions = 'dragover->comments--topics#handleDragOver dragleave->comments--topics#handleDragLeave drop->comments--topics#handleDrop'
@@ -150,11 +150,11 @@ export default class extends Controller {
150
150
  const targetTopicId = event.currentTarget.dataset.id // Empty string for Main
151
151
 
152
152
  // Dispatch event for list_controller to handle the move
153
- this.dispatch('move-to-topic', {
154
- detail: {
155
- commentIds,
156
- targetTopicId
157
- }
153
+ this.dispatch('move-to-topic', {
154
+ detail: {
155
+ commentIds,
156
+ targetTopicId
157
+ }
158
158
  })
159
159
  }
160
160
 
@@ -170,7 +170,7 @@ export default class extends Controller {
170
170
  this.draggingTopicId = topicId
171
171
  event.dataTransfer.setData('application/x-topic-id', topicId)
172
172
  event.dataTransfer.effectAllowed = 'move'
173
-
173
+
174
174
  requestAnimationFrame(() => {
175
175
  topicEl.classList.add('topic-dragging')
176
176
  })
@@ -213,7 +213,7 @@ export default class extends Controller {
213
213
 
214
214
  async handleTopicReorderDrop(event) {
215
215
  event.preventDefault()
216
-
216
+
217
217
  const targetEl = event.currentTarget
218
218
  targetEl.classList.remove('topic-drag-over-left', 'topic-drag-over-right')
219
219
 
@@ -356,13 +356,13 @@ export default class extends Controller {
356
356
  if (event.target.closest('.topic-edit-input')) return
357
357
 
358
358
  const id = event.currentTarget.dataset.id
359
-
359
+
360
360
  // If clicking on already active topic (not Main), show edit mode
361
361
  if (id && String(this.currentTopicId) === String(id) && this.canManageTopics) {
362
362
  this.showEditInput(event.currentTarget, id)
363
363
  return
364
364
  }
365
-
365
+
366
366
  this.selectTopic(id)
367
367
  }
368
368
 
@@ -380,15 +380,15 @@ export default class extends Controller {
380
380
  if (!topic) return
381
381
 
382
382
  const currentName = topic.name
383
-
383
+
384
384
  // Store original HTML for restore
385
385
  topicEl.dataset.originalHtml = topicEl.innerHTML
386
-
386
+
387
387
  // Replace content with input
388
388
  topicEl.innerHTML = `<input type="text" class="topic-edit-input" value="${currentName}"
389
389
  data-action="keydown->comments--topics#handleEditKey blur->comments--topics#cancelEdit"
390
390
  data-topic-id="${topicId}">`
391
-
391
+
392
392
  const input = topicEl.querySelector('input')
393
393
  requestAnimationFrame(() => {
394
394
  input.focus()
@@ -476,6 +476,13 @@ export default class extends Controller {
476
476
  }
477
477
  }
478
478
 
479
+ scrollToActiveTopic() {
480
+ const activeEl = this.listTarget.querySelector('.topic-tag.active')
481
+ if (activeEl) {
482
+ activeEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
483
+ }
484
+ }
485
+
479
486
  handleNewMessage(event) {
480
487
  const topicId = event.detail.topicId
481
488
  if (!topicId) return
@@ -1,17 +1,18 @@
1
1
  import { marked } from 'marked'
2
+ import DOMPurify from 'dompurify'
2
3
 
3
4
  export function renderMarkdown(html) {
4
- return marked.parse(html)
5
+ return DOMPurify.sanitize(marked.parse(html))
5
6
  }
6
7
 
7
8
  export function renderMarkdownInline(html) {
8
- return marked.parseInline(html)
9
+ return DOMPurify.sanitize(marked.parseInline(html))
9
10
  }
10
11
 
11
12
  export function renderCommentMarkdown(text) {
12
13
  const content = text || ''
13
14
  const html = content.includes('\n') ? marked.parse(content) : marked.parseInline(content)
14
- return html.trim()
15
+ return DOMPurify.sanitize(html.trim())
15
16
  }
16
17
 
17
18
  export function renderMarkdownInContainer(container) {
@@ -1,6 +1,8 @@
1
1
  <% comment_topic = comment.topic %>
2
2
  <% current_topic_id = local_assigns[:current_topic_id] %>
3
- <div class="comment-item" id="<%= dom_id(comment) %>" data-controller="comment" data-user-id="<%= comment.user&.id %>" data-comment-id="<%= comment.id %>" data-topic-id="<%= comment_topic&.id %>" data-creative-id="<%= comment.creative_id %>">
3
+ <% has_pending_action = comment.action.present? && comment.action_executed_at.blank? %>
4
+ <% approver_id = comment.approver_id %>
5
+ <div class="comment-item" id="<%= dom_id(comment) %>" data-controller="comment" data-user-id="<%= comment.user&.id %>" data-comment-id="<%= comment.id %>" data-topic-id="<%= comment_topic&.id %>" data-creative-id="<%= comment.creative_id %>" data-creative-owner-id="<%= comment.creative&.user_id %>" data-has-pending-action="<%= has_pending_action %>" data-approver-id="<%= approver_id %>">
4
6
  <div class="comment-select">
5
7
  <input type="checkbox"
6
8
  class="comment-select-checkbox"
@@ -49,9 +51,8 @@
49
51
  <button class="<%= ['convert-comment-btn', ('comment-owner-only' unless can_convert_comment)].compact.join(' ') %>" data-comment-target="ownerButton" data-comment-id="<%= comment.id %>" title="<%= t('collavre.comments.convert_to_creative') %>">
50
52
  <%= t('collavre.comments.convert_button') %>
51
53
  </button>
52
- <% can_approve = comment.approval_status(Current.user) == :ok %>
53
- <% if can_approve %>
54
- <button class="approve-comment-btn" data-comment-id="<%= comment.id %>" title="<%= t('collavre.comments.approve_button') %>">
54
+ <% if has_pending_action %>
55
+ <button class="approve-comment-btn comment-approve-hidden" data-comment-target="approveButton" data-comment-id="<%= comment.id %>" title="<%= t('collavre.comments.approve_button') %>">
55
56
  <%= t('collavre.comments.approve_button') %>
56
57
  </button>
57
58
  <% end %>
@@ -61,12 +62,9 @@
61
62
  <button class="copy-comment-link-btn" data-comment-id="<%= comment.id %>" data-comment-url="<%= collavre.creative_comment_url(comment.creative, comment, Rails.application.config.action_mailer.default_url_options) %>" title="<%= t('collavre.comments.copy_link_button') %>">
62
63
  <%= t('collavre.comments.copy_link_button') %>
63
64
  </button>
64
- <% can_delete = comment.user == Current.user || comment.creative.user == Current.user || comment.creative.has_permission?(Current.user, :admin) %>
65
- <% if can_delete %>
66
- <button class="delete-comment-btn" data-comment-id="<%= comment.id %>" title="<%= t('collavre.comments.delete') %>">
67
- <%= t('collavre.comments.delete_button') %>
68
- </button>
69
- <% end %>
65
+ <button class="delete-comment-btn comment-delete-hidden" data-comment-target="deleteButton" data-comment-id="<%= comment.id %>" title="<%= t('collavre.comments.delete') %>">
66
+ <%= t('collavre.comments.delete_button') %>
67
+ </button>
70
68
  </div>
71
69
  <div class="comment-content"><%= comment.content %></div>
72
70
 
@@ -118,15 +116,17 @@
118
116
  <summary class="comment-action-summary"><%= t("collavre.comments.action_summary") %></summary>
119
117
  <div class="comment-action-body">
120
118
  <pre class="comment-action-json" data-comment-action-json><%= formatted_comment_action(comment) %></pre>
121
- <% if can_approve && comment.action_executed_at.blank? %>
122
- <button class="edit-comment-action-btn" type="button" data-comment-id="<%= comment.id %>"><%= t("collavre.comments.edit_action_button") %></button>
123
- <form class="comment-action-edit-form" data-comment-id="<%= comment.id %>" style="display:none;">
124
- <textarea class="comment-action-edit-textarea" name="comment[action]" rows="8"><%= formatted_comment_action(comment) %></textarea>
125
- <div class="comment-action-edit-buttons">
126
- <button class="cancel-comment-action-edit-btn" type="button"><%= t("app.cancel") %></button>
127
- <button class="save-comment-action-btn" type="submit"><%= t("app.save") %></button>
128
- </div>
129
- </form>
119
+ <% if has_pending_action %>
120
+ <div class="comment-action-approve-controls comment-approve-hidden" data-comment-target="actionApproveControls">
121
+ <button class="edit-comment-action-btn" type="button" data-comment-id="<%= comment.id %>"><%= t("collavre.comments.edit_action_button") %></button>
122
+ <form class="comment-action-edit-form" data-comment-id="<%= comment.id %>" style="display:none;">
123
+ <textarea class="comment-action-edit-textarea" name="comment[action]" rows="8"><%= formatted_comment_action(comment) %></textarea>
124
+ <div class="comment-action-edit-buttons">
125
+ <button class="cancel-comment-action-edit-btn" type="button"><%= t("app.cancel") %></button>
126
+ <button class="save-comment-action-btn" type="submit"><%= t("app.save") %></button>
127
+ </div>
128
+ </form>
129
+ </div>
130
130
  <% end %>
131
131
  </div>
132
132
  </details>
@@ -1,13 +1,17 @@
1
1
 
2
2
  <% fullscreen = local_assigns.fetch(:fullscreen, false) %>
3
+ <% auto_fullscreen = local_assigns.fetch(:auto_fullscreen, false) %>
3
4
  <% creative = local_assigns[:creative] %>
4
5
  <div id="comments-popup" data-controller="comments--popup comments--list comments--form comments--presence comments--mention-menu comments--topics" class="popup-box"
5
6
  data-fullscreen="<%= fullscreen %>"
6
- data-fullscreen-url-template="<%= collavre.fullscreen_creative_comments_path(creative_id: "__CREATIVE_ID__") %>"
7
- <% if fullscreen && creative.present? %>
8
- data-creative-id="<%= creative.id %>"
9
- data-creative-snippet="<%= creative.creative_snippet %>"
10
- data-can-comment="<%= creative.has_permission?(Current.user, :feedback) %>"
7
+ <% if auto_fullscreen %>data-auto-fullscreen="true"<% end %>
8
+ data-fullscreen-label="<%= t('collavre.comments.fullscreen', default: 'Full screen') %>"
9
+ data-exit-fullscreen-label="<%= t('collavre.comments.exit_fullscreen', default: 'Exit full screen') %>"
10
+ <% popup_creative = creative || (@parent_creative if auto_fullscreen) %>
11
+ <% if (fullscreen || auto_fullscreen) && popup_creative.present? %>
12
+ data-creative-id="<%= popup_creative.id %>"
13
+ data-creative-snippet="<%= popup_creative.creative_snippet %>"
14
+ data-can-comment="<%= popup_creative.has_permission?(Current.user, :feedback) %>"
11
15
  <% end %>
12
16
  data-loading-text="<%= t('app.loading') %>"
13
17
  data-delete-confirm-text="<%= t("collavre.comments.delete_confirm") %>"
@@ -28,29 +32,25 @@
28
32
  data-hint-drag-topic-text="<%= t('collavre.comments.hint_drag_topic') %>"
29
33
  data-hint-move-button-text="<%= t('collavre.comments.hint_move_button') %>"
30
34
  data-add-participant-text="<%= t('collavre.comments.add_participant') %>">
31
- <% unless fullscreen %>
32
- <div class="resize-handle resize-handle-left" data-comments--popup-target="leftHandle"></div>
33
- <div class="resize-handle resize-handle-right" data-comments--popup-target="rightHandle"></div>
34
- <% end %>
35
+ <div class="resize-handle resize-handle-left" data-comments--popup-target="leftHandle"></div>
36
+ <div class="resize-handle resize-handle-right" data-comments--popup-target="rightHandle"></div>
35
37
  <div class="comments-popup-header">
36
38
  <h3 id="comments-popup-title" data-comments--popup-target="title"><%= fullscreen && creative.present? ? creative.creative_snippet : t('collavre.comments.comments') %></h3>
37
39
  <span data-integration-badges class="integration-badges"></span>
38
40
  <div class="comments-popup-actions">
39
- <% if fullscreen && creative.present? %>
40
- <%= link_to collavre.creative_path(creative, open_comments: true),
41
- class: "comments-popup-action comments-popup-fullscreen",
42
- aria: { label: t("collavre.comments.exit_fullscreen", default: "Exit full screen") } do %>
43
- <%= svg_tag "exit-fullscreen.svg", class: "comments-popup-action-icon" %>
44
- <% end %>
45
- <% else %>
46
- <%= link_to "#",
47
- class: "comments-popup-action comments-popup-fullscreen",
48
- data: { "comments--popup-target": "fullscreenLink" },
49
- aria: { label: t("collavre.comments.fullscreen", default: "Full screen") } do %>
41
+ <button class="comments-popup-action comments-popup-fullscreen"
42
+ data-comments--popup-target="fullscreenButton"
43
+ data-action="click->comments--popup#toggleFullscreen"
44
+ type="button"
45
+ aria-label="<%= t('collavre.comments.fullscreen', default: 'Full screen') %>">
46
+ <span data-comments--popup-target="fullscreenIcon">
50
47
  <%= svg_tag "fullscreen.svg", class: "comments-popup-action-icon" %>
51
- <% end %>
52
- <button id="close-comments-btn" data-comments--popup-target="closeButton" class="comments-popup-action comments-popup-close" type="button">&times;</button>
53
- <% end %>
48
+ </span>
49
+ <span data-comments--popup-target="exitFullscreenIcon" style="display:none;">
50
+ <%= svg_tag "exit-fullscreen.svg", class: "comments-popup-action-icon" %>
51
+ </span>
52
+ </button>
53
+ <button id="close-comments-btn" data-comments--popup-target="closeButton" class="comments-popup-action comments-popup-close" type="button">&times;</button>
54
54
  </div>
55
55
  </div>
56
56
  <div id="comment-participants" data-comments--presence-target="participants" data-comments--mention-menu-target="participants"></div>
@@ -10,7 +10,7 @@
10
10
  data-delete-error="<%= t('collavre.creatives.index.github_integration_delete_error', default: 'Failed to remove the Github integration.') %>"
11
11
  data-delete-button-label="<%= t('collavre.creatives.index.github_integration_delete_button', default: 'Remove integration') %>"
12
12
  data-delete-select-warning="<%= t('collavre.creatives.index.github_integration_delete_select_warning', default: '삭제할 Repository를 선택하세요.') %>"
13
- style="display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:1000;align-items:center;justify-content:center;">
13
+ style="display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:10000;align-items:center;justify-content:center;">
14
14
  <div class="popup-box" style="min-width:360px;max-width:90vw;">
15
15
  <button type="button" id="close-github-modal" class="popup-close-btn">&times;</button>
16
16
  <h2><%= t('collavre.creatives.index.github_integration_title', default: 'Configure Github integration') %></h2>
@@ -1,4 +1,4 @@
1
- <div id="set-plan-modal" data-creatives--set-plan-modal-target="modal" data-action="click->creatives--set-plan-modal#backdrop" style="display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:1000;align-items:center;justify-content:center;">
1
+ <div id="set-plan-modal" data-creatives--set-plan-modal-target="modal" data-action="click->creatives--set-plan-modal#backdrop" style="display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:10000;align-items:center;justify-content:center;">
2
2
  <div class="popup-box" style="min-width:320px;max-width:90vw;">
3
3
  <button id="close-set-plan-modal" class="popup-close-btn" data-action="creatives--set-plan-modal#close">&times;</button>
4
4
  <h2><%= t('collavre.creatives.index.set_plan_title', default: 'Set Plan for Selected Creatives') %></h2>
@@ -2,7 +2,7 @@
2
2
  <span aria-hidden="true"><%= svg_tag 'share.svg', class: 'icon-up', width: 22, height: 20 %></span>
3
3
  </button>
4
4
  <!-- Share Creative Modal -->
5
- <div id="share-creative-modal" style="display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:1100;align-items:center;justify-content:center;" data-creative-id="<%= (@parent_creative || @creative).id %>">
5
+ <div id="share-creative-modal" style="display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:10000;align-items:center;justify-content:center;" data-creative-id="<%= (@parent_creative || @creative).id %>">
6
6
  <div class="popup-box" style="min-width:320px;max-width:90vw;">
7
7
  <button id="close-share-modal" class="popup-close-btn">&times;</button>
8
8
  <h2><%= t('collavre.creatives.index.share_creative') %></h2>
@@ -197,7 +197,7 @@
197
197
  }
198
198
  ) %>
199
199
 
200
- <%= render 'collavre/comments/comments_popup' %>
200
+ <%= render 'collavre/comments/comments_popup', auto_fullscreen: @auto_fullscreen %>
201
201
  <%= render 'inline_edit_form' %>
202
202
 
203
203
  <%= render 'set_plan_modal' %>
@@ -1,3 +1,3 @@
1
1
  module Collavre
2
- VERSION = "0.2.3"
2
+ VERSION = "0.2.4"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: collavre
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Collavre
@@ -391,7 +391,6 @@ files:
391
391
  - app/views/collavre/comments/_presence_avatars.html.erb
392
392
  - app/views/collavre/comments/_reaction_picker.html.erb
393
393
  - app/views/collavre/comments/_read_receipts.html.erb
394
- - app/views/collavre/comments/fullscreen.html.erb
395
394
  - app/views/collavre/creatives/_add_button.html.erb
396
395
  - app/views/collavre/creatives/_delete_button.html.erb
397
396
  - app/views/collavre/creatives/_github_integration_modal.html.erb
@@ -449,7 +448,6 @@ files:
449
448
  - app/views/collavre/users/passkeys.html.erb
450
449
  - app/views/collavre/users/show.html.erb
451
450
  - app/views/inbox/badge_component/_count.html.erb
452
- - app/views/layouts/collavre/chat.html.erb
453
451
  - app/views/layouts/collavre/slide.html.erb
454
452
  - config/locales/ai_agent.en.yml
455
453
  - config/locales/ai_agent.ko.yml
@@ -1,5 +0,0 @@
1
- <% content_for :title, "#{@creative_snippet} - #{t('collavre.comments.comments')}" %>
2
-
3
- <div class="comments-fullscreen-page">
4
- <%= render "collavre/comments/comments_popup", fullscreen: true, creative: @creative %>
5
- </div>
@@ -1,46 +0,0 @@
1
- <!DOCTYPE html>
2
- <html>
3
- <head>
4
- <title><%= content_for(:title) || t('app.name') %></title>
5
- <meta name="viewport" content="width=device-width,initial-scale=1">
6
- <meta name="apple-mobile-web-app-capable" content="yes">
7
- <meta name="mobile-web-app-capable" content="yes">
8
- <meta name="app-version" content="<%= Rails.application.config.app_version %>">
9
- <%= csrf_meta_tags %>
10
- <%= csp_meta_tag %>
11
-
12
- <%= yield :head %>
13
-
14
- <link rel="icon" href="/icon-1e3cf549d2.png" type="image/png">
15
- <link rel="icon" href="/icon-1e3cf549d2.svg" type="image/svg+xml">
16
- <link rel="apple-touch-icon" href="/icon-1e3cf549d2.png">
17
-
18
- <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
19
- <%= stylesheet_link_tag "collavre/creatives" %>
20
- <%= stylesheet_link_tag "collavre/actiontext" %>
21
- <%= stylesheet_link_tag "collavre/dark_mode" %>
22
- <%= stylesheet_link_tag "collavre/popup" %>
23
- <%= stylesheet_link_tag "collavre/comments_popup" %>
24
-
25
- <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true, type: "module" %>
26
-
27
- <% if Current.user&.theme.present? && !%w[light dark].include?(Current.user.theme) %>
28
- <% if (custom_theme = UserTheme.find_by(id: Current.user.theme)) %>
29
- <style id="user-theme-styles" data-turbo-track="reload">
30
- body {
31
- <% custom_theme.variables.each do |key, value| %>
32
- <%= key %>: <%= value %> !important;
33
- <% end %>
34
- }
35
- </style>
36
- <% end %>
37
- <% end %>
38
- </head>
39
- <body class="chat-fullscreen<%= ' dark-mode' if Current.user&.theme == 'dark' %><%= ' light-mode' if Current.user&.theme == 'light' %>" data-current-user-id="<%= Current.user&.id %>" data-controller="action-text-attachment-link" data-action="click->action-text-attachment-link#download">
40
- <main>
41
- <%= yield %>
42
- </main>
43
-
44
- <%= render "collavre/comments/reaction_picker" %>
45
- </body>
46
- </html>