collavre 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/creatives.css +241 -46
  3. data/app/channels/collavre/topics_channel.rb +2 -0
  4. data/app/controllers/collavre/comments_controller.rb +6 -2
  5. data/app/controllers/collavre/concerns/tree_manageable.rb +2 -2
  6. data/app/controllers/collavre/creatives_controller.rb +22 -2
  7. data/app/controllers/collavre/topics_controller.rb +10 -1
  8. data/app/controllers/collavre/user_creative_preferences_controller.rb +60 -0
  9. data/app/helpers/collavre/creatives_helper.rb +38 -3
  10. data/app/javascript/components/InlineLexicalEditor.jsx +31 -0
  11. data/app/javascript/components/creative_tree_row.js +73 -0
  12. data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +20 -0
  13. data/app/javascript/controllers/comments/popup_controller.js +26 -0
  14. data/app/javascript/controllers/comments/presence_controller.js +4 -0
  15. data/app/javascript/controllers/comments/topics_controller.js +49 -6
  16. data/app/javascript/controllers/creatives/import_controller.js +9 -3
  17. data/app/javascript/controllers/creatives/tree_controller.js +8 -3
  18. data/app/javascript/controllers/popup_menu_controller.js +10 -0
  19. data/app/javascript/lib/api/topics.js +23 -0
  20. data/app/javascript/lib/gnb_popup_manager.js +30 -0
  21. data/app/javascript/modules/creative_guide.js +14 -0
  22. data/app/javascript/modules/creative_row_editor.js +16 -5
  23. data/app/javascript/modules/inbox_panel.js +6 -0
  24. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  25. data/app/javascript/modules/plans_menu.js +10 -0
  26. data/app/models/collavre/creative.rb +1 -1
  27. data/app/models/collavre/topic.rb +2 -0
  28. data/app/models/collavre/user.rb +1 -1
  29. data/app/models/collavre/{creative_expanded_state.rb → user_creative_preference.rb} +4 -3
  30. data/app/services/collavre/creatives/filters/search_filter.rb +17 -4
  31. data/app/services/collavre/creatives/tree_builder.rb +3 -3
  32. data/app/views/collavre/creatives/_add_button.html.erb +2 -2
  33. data/app/views/collavre/creatives/index.html.erb +79 -51
  34. data/config/locales/creatives.en.yml +4 -0
  35. data/config/locales/creatives.ko.yml +4 -0
  36. data/config/locales/user_creative_preferences.en.yml +5 -0
  37. data/config/locales/user_creative_preferences.ko.yml +5 -0
  38. data/config/routes.rb +2 -1
  39. data/db/migrate/20260320061144_rename_creative_expanded_states_to_user_creative_preferences.rb +6 -0
  40. data/lib/collavre/user_extensions.rb +1 -1
  41. data/lib/collavre/version.rb +1 -1
  42. metadata +7 -3
  43. data/app/controllers/collavre/creative_expanded_states_controller.rb +0 -27
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e3281424919fc07e271f712389040c20bef756de2f467de4b5c22d41caf7431
4
- data.tar.gz: aafc13da62eef7e36d0cc80014553fd1299260b5e8c8b5aa97e1238f9d6241a3
3
+ metadata.gz: f1357a437e24482ad596536b50cdae4f58dd247e6e32dbef0be05b99dfdf2103
4
+ data.tar.gz: cc8f618b3b06dcf43f484c87e05a8b92d03a59ff6d63d1356dbc13647316b8a5
5
5
  SHA512:
6
- metadata.gz: '09cbd1f6b17e92ec90b3e2d7f8da598ff1db2dbbaf01282bfd53df19bb8620afcd0e3ebd8dac2c1929233f816d000f8b573990df7dea62ebcb098bd414b0af9f'
7
- data.tar.gz: 56bf15d2aca40fc28ba43cad261a91b2ae64e9c5bdf873c490a14f4f0fad4608d499906b295bfca416d4026406334f79fc422f989f81de4549f367ad88eade8d
6
+ metadata.gz: 26ec481a50ca488ed2155f8979619e868825282d96057e5875b7de1d6f265f29cac8511197f7fc7797c4f087e27c36981135012feb2c0f0a752ae78a2fc0dbab
7
+ data.tar.gz: 8caef1fbc81332be2aaac3bdbefc10563851f20b929769338a9416d1f48a329f4c1b4be8c79ccef723298351ccc7fff32ca3de65d3e3fed2e71e217cb341a233
@@ -2,11 +2,19 @@
2
2
  --creative-row-text-offset: 1.8em;
3
3
  --creative-loading-emojis: "🚀,🧠,🧩,🤝,🗂️,⚡";
4
4
 
5
+ /* Icon size used for vertical centering calc */
6
+ --creative-icon-size: 16px;
7
+
5
8
  /* Creative tree heading styles — defaults match pre-#799 behavior.
6
9
  Themes that don't define these variables render identically to before. */
7
10
  --creative-h1-size: 1.3em;
8
11
  --creative-h2-size: 1.2em;
9
12
  --creative-h3-size: 1.1em;
13
+ /* Line heights for headings (used by calc-based vertical centering) */
14
+ --creative-h1-lh: 1.3;
15
+ --creative-h2-lh: 1.3;
16
+ --creative-h3-lh: 1.3;
17
+ --creative-body-lh: 1.5;
10
18
  --creative-h1-weight: bold;
11
19
  --creative-h2-weight: bold;
12
20
  --creative-h3-weight: bold;
@@ -44,17 +52,135 @@ html.creative-alignment-ready .page-title {
44
52
  }
45
53
 
46
54
  .creative-actions-row {
47
- margin: 0.0em 1.0em 0.0em var(--creative-row-text-offset);
48
- padding: 0.5em 0.5em 0.5em 0;
55
+ margin: 0 0 0 var(--creative-row-text-offset);
56
+ padding: var(--space-1) 0;
57
+ display: flex;
58
+ justify-content: space-between;
59
+ align-items: center;
60
+ min-height: 36px;
61
+ border: none;
62
+ }
63
+
64
+ /* === Breadcrumb (left-aligned) === */
65
+ .creative-breadcrumb {
66
+ display: flex;
67
+ align-items: center;
68
+ font-size: var(--text-0);
69
+ color: var(--text-muted);
70
+ overflow-x: auto;
71
+ white-space: nowrap;
72
+ scrollbar-width: none;
73
+ min-width: 0;
74
+ margin-right: var(--space-3);
75
+ }
76
+
77
+ .creative-breadcrumb::-webkit-scrollbar {
78
+ display: none;
79
+ }
80
+
81
+ /* Override application.css .creative-actions-row a for breadcrumb links */
82
+ .creative-actions-row a.creative-breadcrumb-link {
83
+ color: var(--text-muted) !important;
84
+ text-decoration: underline !important;
85
+ text-underline-offset: 2px;
86
+ text-decoration-color: color-mix(in srgb, var(--text-muted) 40%, transparent);
87
+ background: none !important;
88
+ border: none !important;
89
+ padding: 0 !important;
90
+ font-size: var(--text-0) !important;
91
+ filter: none !important;
92
+ line-height: normal;
93
+ }
94
+
95
+ .creative-actions-row a.creative-breadcrumb-link:hover {
96
+ color: var(--text-primary) !important;
97
+ text-decoration-color: var(--text-primary) !important;
98
+ }
99
+
100
+ .creative-actions-row a.creative-breadcrumb-current {
101
+ color: var(--text-secondary) !important;
102
+ font-weight: var(--weight-5);
103
+ }
104
+
105
+ .creative-breadcrumb-sep {
106
+ color: var(--text-muted);
107
+ opacity: 0.4;
108
+ margin: 0 var(--space-1);
109
+ font-size: var(--text-00);
110
+ user-select: none;
111
+ }
112
+
113
+ /* === Header actions (right-aligned) === */
114
+ .creative-header-actions {
115
+ display: flex;
116
+ align-items: center;
117
+ gap: var(--space-1);
118
+ flex-shrink: 0;
119
+ }
120
+
121
+ /* Outline button — GNB-like with border.
122
+ Higher specificity to override application.css .creative-actions-row button */
123
+ .creative-actions-row .creative-header-actions .creative-header-outline-btn {
124
+ background: transparent;
125
+ border: 1px solid var(--border-color);
126
+ color: var(--text-secondary);
127
+ cursor: pointer;
128
+ height: 30px;
129
+ padding: 0 var(--space-2);
130
+ border-radius: var(--radius-2);
131
+ display: inline-flex;
132
+ align-items: center;
133
+ justify-content: center;
134
+ font-size: var(--text-0);
135
+ transition: background 0.15s var(--ease-out-2), color 0.15s var(--ease-out-2), border-color 0.15s var(--ease-out-2);
136
+ }
137
+
138
+ .creative-actions-row .creative-header-actions .creative-header-outline-btn:hover {
139
+ background: var(--surface-hover);
140
+ color: var(--text-primary);
141
+ border-color: var(--text-muted);
142
+ }
143
+
144
+ /* === Overflow popup menu — GNB style === */
145
+ #creative-overflow-menu {
146
+ min-width: 200px;
147
+ border: 1px solid var(--border-color);
148
+ border-radius: var(--radius-3);
149
+ box-shadow: var(--shadow-2);
150
+ padding: var(--space-1);
151
+ background: var(--surface-section);
152
+ }
153
+
154
+ #creative-overflow-menu .popup-menu-item {
49
155
  display: flex;
50
- flex-wrap: wrap;
51
- gap: 10px;
52
- height: 48px;
53
156
  align-items: center;
157
+ gap: var(--space-2);
158
+ width: 100%;
159
+ text-align: left;
160
+ padding: var(--space-1) var(--space-2);
161
+ border-radius: var(--radius-2);
162
+ border: none;
163
+ background: none;
164
+ color: var(--text-primary);
165
+ font-size: var(--text-1);
166
+ cursor: pointer;
167
+ transition: background 0.15s var(--ease-out-2);
168
+ white-space: nowrap;
169
+ }
170
+
171
+ #creative-overflow-menu .popup-menu-item:hover {
172
+ background: var(--surface-hover);
54
173
  }
55
174
 
56
- .creative-actions-row button {
57
- height: 32px;
175
+ #creative-overflow-menu .popup-menu-item.active {
176
+ background: color-mix(in srgb, var(--color-active) 15%, transparent);
177
+ color: var(--color-active);
178
+ }
179
+
180
+ .creative-overflow-divider {
181
+ height: 1px;
182
+ background: var(--border-color);
183
+ margin: var(--space-1) 0;
58
184
  }
59
185
 
60
186
  .delete-options {
@@ -119,7 +245,6 @@ creative-tree-row.show-edit .creative-row {
119
245
  .creative-tree-li {
120
246
  display: flex;
121
247
  align-items: flex-start;
122
- /* Changed from center to flex-start */
123
248
  position: relative;
124
249
  }
125
250
 
@@ -187,8 +312,7 @@ creative-tree-row.show-edit .creative-row {
187
312
  margin-left: 4px;
188
313
  margin-right: 8px;
189
314
  flex: 0 0 var(--creative-bullet-size, 5px);
190
- margin-top: 10px;
191
- /* Align with first line of text (approx) */
315
+ margin-top: calc((1em * var(--creative-body-lh, 1.5) - var(--creative-bullet-size, 5px)) / 2);
192
316
  }
193
317
 
194
318
  .creative-row-end {
@@ -198,8 +322,13 @@ creative-tree-row.show-edit .creative-row {
198
322
  white-space: nowrap;
199
323
  display: flex;
200
324
  align-items: flex-start;
201
- /* Top align */
202
- /* padding-top: 4px; - Removed to align comments button with edit button */
325
+ }
326
+
327
+ /* Reset row-start vertical centering for buttons inside row-end.
328
+ Uses .creative-row to boost specificity above .level-N rules. */
329
+ .creative-row .creative-row-end .creative-action-btn,
330
+ .creative-row .creative-row-end .comments-btn {
331
+ margin-top: 0;
203
332
  }
204
333
 
205
334
  .creative-tags {
@@ -208,27 +337,84 @@ creative-tree-row.show-edit .creative-row {
208
337
  white-space: nowrap;
209
338
  display: flex;
210
339
  flex-wrap: wrap;
211
- padding-top: 4px;
212
- /* Restore baseline alignment */
213
340
  }
214
341
 
215
342
  .creative-progress-complete,
216
343
  .creative-progress-incomplete {
217
344
  color: var(--color-brand);
218
- padding-top: 4px;
219
- /* Restore baseline alignment */
220
345
  }
221
346
 
222
347
  .creative-progress-incomplete {
223
348
  color: var(--text-muted);
224
349
  }
225
350
 
226
- /* .creative-progress-incomplete {} - removed empty rule */
351
+ /* Progress toggle (hover-to-complete) */
352
+ .progress-toggle-wrap {
353
+ position: relative;
354
+ cursor: pointer;
355
+ display: inline-flex;
356
+ align-items: center;
357
+ padding-top: 4px;
358
+ }
359
+
360
+ .progress-toggle-wrap .creative-progress-complete,
361
+ .progress-toggle-wrap .creative-progress-incomplete {
362
+ padding-top: 0;
363
+ }
364
+
365
+ .progress-toggle-checkbox {
366
+ position: absolute;
367
+ inset: 0;
368
+ width: 100%;
369
+ height: 100%;
370
+ opacity: 0;
371
+ cursor: pointer;
372
+ margin: 0;
373
+ z-index: 1;
374
+ }
375
+
376
+ /* Desktop: show checkbox on row hover (same pattern as edit-btn, comments-btn) */
377
+ @media (hover: hover) {
378
+ .progress-toggle-checkbox {
379
+ width: 1em;
380
+ height: 1em;
381
+ visibility: hidden;
382
+ opacity: 0;
383
+ position: absolute;
384
+ margin: auto;
385
+ pointer-events: none;
386
+ }
387
+
388
+ .creative-row:hover .progress-toggle-wrap .creative-progress-complete,
389
+ .creative-row:hover .progress-toggle-wrap .creative-progress-incomplete {
390
+ display: none;
391
+ }
392
+
393
+ .creative-row:hover .progress-toggle-checkbox {
394
+ visibility: visible;
395
+ opacity: 1;
396
+ position: relative;
397
+ pointer-events: auto;
398
+ accent-color: var(--color-brand);
399
+ }
400
+ }
401
+
402
+ /* Touch devices: always show both text and clickable area */
403
+ @media (hover: none) {
404
+ .progress-toggle-checkbox {
405
+ opacity: 0;
406
+ pointer-events: auto;
407
+ }
408
+ }
409
+
410
+ .progress-toggle-saving {
411
+ opacity: 0.5;
412
+ pointer-events: none;
413
+ }
227
414
 
228
415
  .creative-row-start {
229
416
  display: flex;
230
417
  align-items: flex-start;
231
- /* Changed from center to flex-start */
232
418
  flex-grow: 1;
233
419
  }
234
420
 
@@ -274,8 +460,8 @@ creative-tree-row.show-edit .creative-row {
274
460
  line-height: 1;
275
461
  display: inline-flex;
276
462
  flex-shrink: 0;
277
- padding-top: 4px;
278
- /* Align with text */
463
+ /* Default vertical centering for body-text rows (level 4+) */
464
+ margin-top: calc((1em * var(--creative-body-lh, 1.5) - var(--creative-icon-size, 16px)) / 2);
279
465
  }
280
466
 
281
467
  /* Ensure inline editor actions use the correct text color */
@@ -313,13 +499,12 @@ creative-tree-row.show-edit .creative-row {
313
499
  width: 16px;
314
500
  height: 16px;
315
501
  margin-right: 4px;
316
- margin-top: 4px;
317
- /* Align with text */
502
+ margin-top: calc((1em * var(--creative-body-lh, 1.5) - var(--creative-icon-size, 16px)) / 2);
318
503
  visibility: hidden;
319
504
  /* Hidden by default on desktop */
320
505
  /* Total width is 20px (16 + 4) */
321
506
  color: var(--text-secondary);
322
- display: flex;
507
+ display: inline-flex;
323
508
  align-items: center;
324
509
  justify-content: center;
325
510
  }
@@ -419,17 +604,14 @@ creative-tree-row:not([expanded]) .creative-toggle-btn {
419
604
  height: 16px;
420
605
  cursor: pointer;
421
606
  visibility: visible;
422
- margin-top: 6px;
423
- /* Align with text */
607
+ margin-top: calc((1em * var(--creative-body-lh, 1.5) - var(--creative-icon-size, 16px)) / 2);
424
608
  }
425
609
 
426
610
  .creative-row {
427
611
  display: flex;
428
612
  justify-content: space-between;
429
613
  align-items: flex-start;
430
- /* Changed from center to flex-start */
431
614
  padding: 2px 0;
432
- /* Add some vertical padding */
433
615
  }
434
616
 
435
617
  .creative-row.selected {
@@ -454,8 +636,7 @@ creative-tree-row.chat-active .creative-row {
454
636
  color: var(--text-muted);
455
637
  font-size: 0.9em;
456
638
  margin-left: 10px;
457
- padding-top: 4px;
458
- /* Align with text */
639
+ margin-top: calc((1em * var(--creative-body-lh, 1.5) - 1em) / 2);
459
640
  }
460
641
 
461
642
  .page-title {
@@ -494,6 +675,7 @@ creative-tree-row.chat-active .creative-row {
494
675
  font-size: var(--creative-h1-size, 1.3em);
495
676
  font-weight: var(--creative-h1-weight, bold);
496
677
  color: var(--creative-h1-color, inherit);
678
+ line-height: var(--creative-h1-lh, 1.3);
497
679
  margin: 0;
498
680
  }
499
681
 
@@ -501,6 +683,7 @@ creative-tree-row.chat-active .creative-row {
501
683
  font-size: var(--creative-h2-size, 1.2em);
502
684
  font-weight: var(--creative-h2-weight, bold);
503
685
  color: var(--creative-h2-color, inherit);
686
+ line-height: var(--creative-h2-lh, 1.3);
504
687
  margin: 0;
505
688
  }
506
689
 
@@ -508,6 +691,7 @@ creative-tree-row.chat-active .creative-row {
508
691
  font-size: var(--creative-h3-size, 1.1em);
509
692
  font-weight: var(--creative-h3-weight, bold);
510
693
  color: var(--creative-h3-color, inherit);
694
+ line-height: var(--creative-h3-lh, 1.3);
511
695
  margin: 0;
512
696
  }
513
697
 
@@ -523,23 +707,38 @@ creative-tree-row.chat-active .creative-row {
523
707
  font-weight: var(--creative-childless-weight, 400);
524
708
  }
525
709
 
526
- /* Headings need specific alignment adjustments */
527
- .level-1 .creative-action-btn,
710
+ /* Dynamic vertical centering: calc((font-size × line-height − icon-size) / 2)
711
+ Scoped to .creative-row-start to avoid affecting .creative-row-end buttons. */
712
+ .level-1 .creative-row-start > .creative-action-btn,
528
713
  .level-1 .select-creative-checkbox,
529
714
  .level-1 .creative-toggle-btn {
530
- margin-top: 8px;
715
+ margin-top: calc((var(--creative-h1-size, 1.3em) * var(--creative-h1-lh, 1.3) - var(--creative-icon-size, 16px)) / 2);
531
716
  }
532
717
 
533
- .level-2 .creative-action-btn,
718
+ .level-2 .creative-row-start > .creative-action-btn,
534
719
  .level-2 .select-creative-checkbox,
535
720
  .level-2 .creative-toggle-btn {
536
- margin-top: 7px;
721
+ margin-top: calc((var(--creative-h2-size, 1.2em) * var(--creative-h2-lh, 1.3) - var(--creative-icon-size, 16px)) / 2);
537
722
  }
538
723
 
539
- .level-3 .creative-action-btn,
724
+ .level-3 .creative-row-start > .creative-action-btn,
540
725
  .level-3 .select-creative-checkbox,
541
726
  .level-3 .creative-toggle-btn {
542
- margin-top: 6px;
727
+ margin-top: calc((var(--creative-h3-size, 1.1em) * var(--creative-h3-lh, 1.3) - var(--creative-icon-size, 16px)) / 2);
728
+ }
729
+
730
+ /* Childless items (level 1-3 without children) use body font size.
731
+ Scoped to .creative-row-start to avoid affecting .creative-row-end buttons. */
732
+ .level-1:has(.creative-childless) .creative-row-start > .creative-action-btn,
733
+ .level-2:has(.creative-childless) .creative-row-start > .creative-action-btn,
734
+ .level-3:has(.creative-childless) .creative-row-start > .creative-action-btn,
735
+ .level-1:has(.creative-childless) .creative-toggle-btn,
736
+ .level-2:has(.creative-childless) .creative-toggle-btn,
737
+ .level-3:has(.creative-childless) .creative-toggle-btn,
738
+ .level-1:has(.creative-childless) .select-creative-checkbox,
739
+ .level-2:has(.creative-childless) .select-creative-checkbox,
740
+ .level-3:has(.creative-childless) .select-creative-checkbox {
741
+ margin-top: calc((1em * var(--creative-body-lh, 1.5) - var(--creative-icon-size, 16px)) / 2);
543
742
  }
544
743
 
545
744
 
@@ -549,7 +748,6 @@ creative-tree-row.chat-active .creative-row {
549
748
  display: flex;
550
749
  align-items: flex-start;
551
750
  margin-left: 0;
552
- /* Indentation now comes from tree lines; avoid extra left margin here */
553
751
  }
554
752
 
555
753
  .comments-btn {
@@ -557,15 +755,6 @@ creative-tree-row.chat-active .creative-row {
557
755
  height: 28px;
558
756
  position: relative;
559
757
  color: var(--text-primary);
560
- margin-top: -2px;
561
- /* Move higher as requested */
562
- }
563
-
564
- /* Ensure comments button stays high for headings, overriding generic action button margin */
565
- .level-1 .comments-btn,
566
- .level-2 .comments-btn,
567
- .level-3 .comments-btn {
568
- margin-top: -2px;
569
758
  }
570
759
 
571
760
  /* Add spacing for headings via row padding to preserve alignment */
@@ -717,3 +906,9 @@ creative-tree-row[archived] .creative-tree {
717
906
  creative-tree-row[archived] .creative-row {
718
907
  background-color: var(--surface-input);
719
908
  }
909
+
910
+ /* Import dropzone drag-over feedback */
911
+ #import-markdown-dropzone.dragover {
912
+ border-color: var(--color-accent-border);
913
+ background-color: var(--surface-input);
914
+ }
@@ -7,6 +7,8 @@ class TopicsChannel < ApplicationCable::Channel
7
7
  return reject unless @creative.has_permission?(current_user, :read)
8
8
 
9
9
  stream_for @creative
10
+ # User-specific stream for preference sync (e.g. last_topic_changed)
11
+ stream_for "user_#{current_user.id}_creative_#{@creative.id}"
10
12
  end
11
13
  end
12
14
  end
@@ -35,8 +35,12 @@ module Collavre
35
35
  scope = visible_scope.with_attached_images.includes(:topic, :comment_reactions, :comment_versions)
36
36
 
37
37
  if params[:search].present?
38
- search_term = ActiveRecord::Base.sanitize_sql_like(params[:search].to_s.strip.downcase)
39
- scope = scope.where("LOWER(comments.content) LIKE ?", "%#{search_term}%")
38
+ words = params[:search].to_s.strip.downcase.split(/\s+/)
39
+ .first(Creatives::Filters::SearchFilter::MAX_SEARCH_WORDS)
40
+ words.each do |word|
41
+ sanitized = "%#{ActiveRecord::Base.sanitize_sql_like(word)}%"
42
+ scope = scope.where("LOWER(comments.content) LIKE ?", sanitized)
43
+ end
40
44
  end
41
45
 
42
46
  # Filter by topic
@@ -70,7 +70,7 @@ module Collavre
70
70
 
71
71
  # HTTP caching disabled for children endpoint:
72
72
  # Response depends on child updates, permission changes (CreativeSharesCache),
73
- # and CreativeExpandedState. Tracking all dependencies reliably is expensive
73
+ # and UserCreativePreference. Tracking all dependencies reliably is expensive
74
74
  # (requires descendant_ids query). Stale 304 responses could leak data after
75
75
  # permission revocation. Re-enable when a cheap version key mechanism exists.
76
76
  # Use private + no-store to prevent any caching (proxy or browser).
@@ -117,7 +117,7 @@ module Collavre
117
117
  private
118
118
 
119
119
  def render_children_json(parent, user_id, allowed_ids, progress_map)
120
- expanded_state_map = CreativeExpandedState
120
+ expanded_state_map = UserCreativePreference
121
121
  .where(user_id: user_id, creative_id: parent.id)
122
122
  .first&.expanded_status || {}
123
123
  children = parent.children_with_permission(Current.user)
@@ -33,7 +33,7 @@ module Collavre
33
33
  end
34
34
 
35
35
  @expanded_state_map = if user_id_for_state
36
- CreativeExpandedState.where(user_id: user_id_for_state, creative_id: params[:id]).first&.expanded_status || {}
36
+ UserCreativePreference.where(user_id: user_id_for_state, creative_id: params[:id]).first&.expanded_status || {}
37
37
  else
38
38
  {}
39
39
  end
@@ -226,7 +226,27 @@ module Collavre
226
226
 
227
227
  if success
228
228
  format.html { redirect_to @creative }
229
- format.json { head :ok }
229
+ format.json do
230
+ base.reload
231
+ response_data = {
232
+ id: base.id,
233
+ progress: base.progress,
234
+ progress_html: view_context.render_creative_progress(base),
235
+ has_children: base.children.exists?
236
+ }
237
+ # Build ancestor chain for progress updates (closure_tree: 1 SELECT via hierarchy table)
238
+ ancestor_records = base.ancestors.order(:id)
239
+ if ancestor_records.any?
240
+ response_data[:ancestors] = ancestor_records.map do |anc|
241
+ {
242
+ id: anc.id,
243
+ progress: anc.progress,
244
+ progress_html: view_context.render_creative_progress(anc, has_children: true)
245
+ }
246
+ end
247
+ end
248
+ render json: response_data
249
+ end
230
250
  else
231
251
  format.html { render :edit, status: :unprocessable_entity }
232
252
  format.json { render json: { errors: @creative.errors.full_messages }, status: :unprocessable_entity }
@@ -11,11 +11,18 @@ module Collavre
11
11
  preload_primary_agents(active_topics)
12
12
  archived_topics = @creative.topics.archived.order(:created_at)
13
13
 
14
+ last_topic_id = if Current.user
15
+ UserCreativePreference
16
+ .where(user_id: Current.user.id, creative_id: @creative.id)
17
+ .pick(:last_topic_id)
18
+ end
19
+
14
20
  render json: {
15
21
  topics: active_topics.map { |t| topic_json(t) },
16
22
  archived_topics: archived_topics,
17
23
  can_manage: can_manage,
18
- can_create_topic: can_create_topic
24
+ can_create_topic: can_create_topic,
25
+ last_topic_id: last_topic_id
19
26
  }
20
27
  end
21
28
 
@@ -91,6 +98,8 @@ module Collavre
91
98
 
92
99
  topic = @creative.topics.find(params[:id])
93
100
  topic_id = topic.id
101
+
102
+ # last_topic_id is nullified by DB FK (on_delete: :nullify) and model dependent: :nullify
94
103
  topic.destroy
95
104
 
96
105
  TopicsChannel.broadcast_to(
@@ -0,0 +1,60 @@
1
+ module Collavre
2
+ class UserCreativePreferencesController < ApplicationController
3
+ def toggle
4
+ creative_id = params[:creative_id]
5
+ node_id = params[:node_id].to_s
6
+ expanded = ActiveModel::Type::Boolean.new.cast(params[:expanded])
7
+
8
+ record = UserCreativePreference.find_or_initialize_by(creative_id: creative_id, user_id: Current.user.id)
9
+ state = record.expanded_status || {}
10
+
11
+ if expanded
12
+ state[node_id] = true
13
+ else
14
+ state.delete(node_id)
15
+ end
16
+
17
+ record.expanded_status = state
18
+ if state.empty? && record.last_topic_id.nil?
19
+ record.destroy if record.persisted?
20
+ else
21
+ record.save!
22
+ end
23
+
24
+ render json: { success: true }
25
+ end
26
+
27
+ def update_last_topic
28
+ creative = Creative.find(params[:creative_id]).effective_origin
29
+
30
+ unless creative.has_permission?(Current.user, :read) || creative.user == Current.user
31
+ render json: { error: I18n.t("collavre.user_creative_preferences.no_permission") }, status: :forbidden and return
32
+ end
33
+
34
+ # Validate that the topic belongs to this creative
35
+ if params[:last_topic_id].present?
36
+ unless creative.topics.exists?(id: params[:last_topic_id])
37
+ render json: { error: I18n.t("collavre.user_creative_preferences.invalid_topic") }, status: :unprocessable_entity and return
38
+ end
39
+ end
40
+
41
+ record = UserCreativePreference.find_or_initialize_by(creative_id: creative.id, user_id: Current.user.id)
42
+ record.expanded_status ||= {}
43
+ record.last_topic_id = params[:last_topic_id].presence
44
+
45
+ if record.expanded_status.empty? && record.last_topic_id.nil?
46
+ record.destroy if record.persisted?
47
+ else
48
+ record.save!
49
+ end
50
+
51
+ # Broadcast only to the current user's sessions (not all creative subscribers)
52
+ TopicsChannel.broadcast_to(
53
+ "user_#{Current.user.id}_creative_#{creative.id}",
54
+ { action: "last_topic_changed", last_topic_id: record.last_topic_id }
55
+ )
56
+
57
+ render json: { success: true }
58
+ end
59
+ end
60
+ end
@@ -25,7 +25,7 @@ module Collavre
25
25
  end
26
26
  end
27
27
 
28
- def render_creative_progress(creative, select_mode: false)
28
+ def render_creative_progress(creative, select_mode: false, has_children: nil)
29
29
  progress_value = if params[:tags].present?
30
30
  tag_ids = Array(params[:tags]).map(&:to_s)
31
31
  creative.filtered_progress || creative.progress_for_tags(tag_ids) || 0
@@ -69,8 +69,16 @@ module Collavre
69
69
  else
70
70
  safe_join([])
71
71
  end
72
+ is_leaf = has_children.nil? ? !creative.children.exists? : !has_children
73
+ can_write = creative.has_permission?(Current.user, :write)
74
+ progress_part = if is_leaf && can_write && !select_mode
75
+ render_progress_toggle(creative, progress_value)
76
+ else
77
+ render_progress_value(progress_value)
78
+ end
79
+
72
80
  safe_join([
73
- render_progress_value(progress_value),
81
+ progress_part,
74
82
  comment_part,
75
83
  tag.br,
76
84
  (creative.tags ? render_creative_tags(creative) : safe_join([]))
@@ -78,14 +86,41 @@ module Collavre
78
86
  end
79
87
  end
80
88
 
89
+ def render_progress_toggle(creative, value)
90
+ complete = value == 1
91
+ new_value = complete ? 0 : 1
92
+ tooltip = complete ? t("collavre.creatives.index.mark_incomplete") : t("collavre.creatives.index.mark_complete")
93
+ content_tag(
94
+ :span,
95
+ class: "progress-toggle-wrap",
96
+ data: {
97
+ progress_toggle: true,
98
+ creative_id: creative.id,
99
+ current_progress: value,
100
+ new_progress: new_value
101
+ },
102
+ title: tooltip
103
+ ) do
104
+ checkbox = tag.input(
105
+ type: "checkbox",
106
+ checked: complete || nil,
107
+ class: "progress-toggle-checkbox",
108
+ tabindex: -1,
109
+ "aria-label": tooltip
110
+ )
111
+ safe_join([ render_progress_value(value), checkbox ])
112
+ end
113
+ end
114
+
81
115
  def render_progress_value(value)
82
116
  text = number_to_percentage(value * 100, precision: 0)
83
117
  if value == 1 && !Current.user&.completion_mark.nil?
84
118
  text = Current.user.completion_mark
85
119
  end
120
+ display_text = text.blank? ? "&nbsp;&nbsp;".html_safe : text
86
121
  content_tag(
87
122
  :span,
88
- text,
123
+ display_text,
89
124
  class: "creative-progress-#{value == 1 ? 'complete' : 'incomplete'}"
90
125
  )
91
126
  end