coplan-engine 0.2.0 → 1.0.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/coplan/application.css +350 -7
  3. data/app/channels/coplan/plan_presence_channel.rb +45 -0
  4. data/app/controllers/coplan/api/v1/comments_controller.rb +2 -2
  5. data/app/controllers/coplan/api/v1/operations_controller.rb +41 -3
  6. data/app/controllers/coplan/api/v1/plans_controller.rb +2 -2
  7. data/app/controllers/coplan/api/v1/sessions_controller.rb +2 -2
  8. data/app/controllers/coplan/plans_controller.rb +1 -0
  9. data/app/controllers/coplan/settings/tokens_controller.rb +1 -1
  10. data/app/helpers/coplan/application_helper.rb +57 -0
  11. data/app/helpers/coplan/comments_helper.rb +4 -3
  12. data/app/helpers/coplan/markdown_helper.rb +7 -1
  13. data/app/javascript/controllers/coplan/comment_nav_controller.js +126 -13
  14. data/app/javascript/controllers/coplan/content_nav_controller.js +252 -0
  15. data/app/javascript/controllers/coplan/presence_controller.js +44 -0
  16. data/app/javascript/controllers/coplan/text_selection_controller.js +203 -56
  17. data/app/models/coplan/comment.rb +4 -0
  18. data/app/models/coplan/comment_thread.rb +58 -16
  19. data/app/models/coplan/plan.rb +1 -0
  20. data/app/models/coplan/plan_viewer.rb +26 -0
  21. data/app/services/coplan/plans/apply_operations.rb +43 -0
  22. data/app/services/coplan/plans/commit_session.rb +26 -1
  23. data/app/services/coplan/plans/markdown_text_extractor.rb +142 -0
  24. data/app/services/coplan/plans/position_resolver.rb +111 -0
  25. data/app/views/coplan/comment_threads/_reply_form.html.erb +1 -1
  26. data/app/views/coplan/comment_threads/_thread_popover.html.erb +4 -4
  27. data/app/views/coplan/comments/_comment.html.erb +3 -0
  28. data/app/views/coplan/plans/_header.html.erb +1 -0
  29. data/app/views/coplan/plans/_viewers.html.erb +16 -0
  30. data/app/views/coplan/plans/show.html.erb +25 -3
  31. data/app/views/layouts/coplan/application.html.erb +2 -0
  32. data/db/migrate/20260327000000_create_coplan_plan_viewers.rb +15 -0
  33. data/lib/coplan/version.rb +1 -1
  34. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3e4bc136761076f3c7f12bf4e025f5bfd0c89831c718f50cd88a4e650bb402e8
4
- data.tar.gz: a7739fea373261118928c4c488043ca9d2547eac95d4ceeee91f004585e3b317
3
+ metadata.gz: 25dc4e49ba0c60553ed7d736f692711ed95908f904ced5ab0eab166a6afd5454
4
+ data.tar.gz: 9081eee65fbcadaef5bfb8af91a50bf67a1825382ff8763de0d3b1cb38a00ac4
5
5
  SHA512:
6
- metadata.gz: 0d984b75c02ce889706c1cf1080f61106f5e355d0626d69c502b7f29ec28ef99300ad249d0c57cd1823f46d064f62e641a2c733b60a3df6cab266fc17fef453d
7
- data.tar.gz: f70083d0f8ce612a2608e7863fe7606aecefa40e402c582596eccc68576f9cf60e546efd5f98d30c2a59a180d81a2c97e1b831ad9277ec21f54569df3ebf2325
6
+ metadata.gz: 3d75ee11d917e817192149b2a69a33b1089333312ebd8ac4f7b64142479d5108c5b70e801c6b4ae3b64f78e9190d123164f4e5b87f56ced1aad7df0d9b72cb93
7
+ data.tar.gz: c6b96bd3fe16455118551bd01ae3769914bcc2f41d0b2bd59bf159ee8eddd6e84a7fb24ebb74461d522d07802b242b08bc8f691acf9772dabcc0864ab4fdfaed
@@ -21,6 +21,8 @@
21
21
  --color-status-developing: #3b82f6;
22
22
  --color-status-live: #10b981;
23
23
  --color-status-abandoned: #6b7280;
24
+ --color-agent: #4338ca;
25
+ --color-agent-bg: #e0e7ff;
24
26
 
25
27
  /* Spacing */
26
28
  --space-xs: 0.25rem;
@@ -119,6 +121,17 @@ img, svg {
119
121
  border-radius: 6px;
120
122
  }
121
123
 
124
+ .env-badge {
125
+ font-size: 10px;
126
+ font-weight: 600;
127
+ color: #fff;
128
+ padding: 1px 6px;
129
+ border-radius: 4px;
130
+ text-transform: uppercase;
131
+ letter-spacing: 0.5px;
132
+ line-height: 1.4;
133
+ }
134
+
122
135
  .site-nav__brand:hover {
123
136
  text-decoration: none;
124
137
  color: var(--color-primary);
@@ -173,6 +186,87 @@ img, svg {
173
186
  font-weight: 700;
174
187
  }
175
188
 
189
+ /* Plan viewers / presence */
190
+ .plan-viewers {
191
+ display: flex;
192
+ align-items: center;
193
+ gap: var(--space-sm);
194
+ }
195
+
196
+ .plan-viewers__avatars {
197
+ display: flex;
198
+ flex-direction: row-reverse;
199
+ }
200
+
201
+ .plan-viewers__avatar {
202
+ display: flex;
203
+ align-items: center;
204
+ justify-content: center;
205
+ width: 2rem;
206
+ height: 2rem;
207
+ border-radius: 50%;
208
+ background: var(--color-primary);
209
+ color: #fff;
210
+ font-size: 0.7rem;
211
+ font-weight: 600;
212
+ border: 2px solid var(--color-surface);
213
+ margin-left: -0.5rem;
214
+ cursor: default;
215
+ position: relative;
216
+ }
217
+
218
+ .plan-viewers__avatar--you {
219
+ background: var(--color-success);
220
+ }
221
+
222
+ .plan-viewers__avatar::after {
223
+ content: attr(data-tooltip);
224
+ position: absolute;
225
+ bottom: calc(100% + 6px);
226
+ left: 50%;
227
+ transform: translateX(-50%);
228
+ background: var(--color-text);
229
+ color: var(--color-surface);
230
+ padding: 4px 8px;
231
+ border-radius: var(--radius);
232
+ font-size: 0.75rem;
233
+ font-weight: 500;
234
+ white-space: nowrap;
235
+ pointer-events: none;
236
+ opacity: 0;
237
+ transition: opacity 0.15s;
238
+ z-index: 10;
239
+ }
240
+
241
+ .plan-viewers__avatar:hover::after {
242
+ opacity: 1;
243
+ }
244
+
245
+ .plan-viewers__avatars > :last-child {
246
+ margin-left: 0;
247
+ }
248
+
249
+ .plan-viewers__overflow {
250
+ display: flex;
251
+ align-items: center;
252
+ justify-content: center;
253
+ width: 2rem;
254
+ height: 2rem;
255
+ border-radius: 50%;
256
+ background: var(--color-border);
257
+ color: var(--color-text-muted);
258
+ font-size: 0.7rem;
259
+ font-weight: 600;
260
+ border: 2px solid var(--color-surface);
261
+ margin-left: -0.5rem;
262
+ }
263
+
264
+ .plan-viewers__label {
265
+ font-size: var(--text-sm);
266
+ color: var(--color-text-muted);
267
+ white-space: nowrap;
268
+ }
269
+
176
270
  /* Flash messages */
177
271
  .flash {
178
272
  padding: var(--space-md) var(--space-lg);
@@ -268,6 +362,11 @@ img, svg {
268
362
  .badge--developing { background: #dbeafe; color: var(--color-status-developing); }
269
363
  .badge--live { background: #d1fae5; color: var(--color-status-live); }
270
364
  .badge--abandoned { background: #f3f4f6; color: var(--color-status-abandoned); }
365
+ .badge--pending { background: #fef3c7; color: var(--color-status-considering); }
366
+ .badge--todo { background: #dbeafe; color: var(--color-status-developing); }
367
+ .badge--discarded { background: #f3f4f6; color: var(--color-status-abandoned); }
368
+ .badge--resolved { background: #d1fae5; color: var(--color-status-live); }
369
+ .badge--agent { background: var(--color-agent-bg); color: var(--color-agent); font-size: 0.65rem; padding: 1px 6px; vertical-align: middle; }
271
370
 
272
371
  /* Forms */
273
372
  .form-group {
@@ -619,6 +718,24 @@ img, svg {
619
718
  background: rgba(108, 140, 255, 0.22);
620
719
  }
621
720
 
721
+ .anchor-highlight--pending {
722
+ background: rgba(245, 158, 11, 0.12);
723
+ border-bottom: 2px solid rgba(245, 158, 11, 0.6);
724
+ }
725
+
726
+ .anchor-highlight--pending:hover {
727
+ background: rgba(245, 158, 11, 0.22);
728
+ }
729
+
730
+ .anchor-highlight--todo {
731
+ background: rgba(59, 130, 246, 0.12);
732
+ border-bottom: 2px solid rgba(59, 130, 246, 0.6);
733
+ }
734
+
735
+ .anchor-highlight--todo:hover {
736
+ background: rgba(59, 130, 246, 0.22);
737
+ }
738
+
622
739
  .anchor-highlight--resolved {
623
740
  background: none;
624
741
  border-bottom: none;
@@ -689,6 +806,18 @@ img, svg {
689
806
  min-width: 0;
690
807
  }
691
808
 
809
+ #plan-threads {
810
+ position: absolute;
811
+ width: 0;
812
+ height: 0;
813
+ overflow: hidden;
814
+ pointer-events: none;
815
+ }
816
+
817
+ #plan-threads .thread-popover {
818
+ pointer-events: auto;
819
+ }
820
+
692
821
  /* Comment form */
693
822
  .comment-form {
694
823
  position: absolute;
@@ -1004,24 +1133,47 @@ img, svg {
1004
1133
  /* Margin dots */
1005
1134
  .margin-dot {
1006
1135
  position: absolute;
1007
- width: 12px;
1008
- height: 12px;
1136
+ width: 10px;
1137
+ height: 10px;
1009
1138
  border-radius: 50%;
1139
+ border: 2px solid transparent;
1140
+ background-clip: padding-box;
1010
1141
  cursor: pointer;
1011
1142
  left: 14px;
1012
- transition: transform 0.15s, box-shadow 0.15s;
1143
+ transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
1013
1144
  display: flex;
1014
1145
  align-items: center;
1015
1146
  justify-content: center;
1147
+ padding: 0;
1148
+ outline: none;
1149
+ }
1150
+
1151
+ .margin-dot::after {
1152
+ content: "";
1153
+ position: absolute;
1154
+ inset: -8px;
1155
+ border-radius: 50%;
1016
1156
  }
1017
1157
 
1018
1158
  .margin-dot:hover {
1019
- transform: scale(1.3);
1159
+ transform: scale(1.4);
1020
1160
  }
1021
1161
 
1022
- .margin-dot--open {
1023
- background: var(--color-primary);
1024
- box-shadow: 0 0 6px rgba(37, 99, 235, 0.3);
1162
+ .margin-dot:focus-visible {
1163
+ outline: 2px solid var(--color-primary);
1164
+ outline-offset: 2px;
1165
+ }
1166
+
1167
+ .margin-dot--pending {
1168
+ background: var(--color-status-considering);
1169
+ border-color: rgba(245, 158, 11, 0.3);
1170
+ box-shadow: 0 0 6px rgba(245, 158, 11, 0.25);
1171
+ }
1172
+
1173
+ .margin-dot--todo {
1174
+ background: var(--color-status-developing);
1175
+ border-color: rgba(59, 130, 246, 0.3);
1176
+ box-shadow: 0 0 6px rgba(59, 130, 246, 0.25);
1025
1177
  }
1026
1178
 
1027
1179
  .margin-dot--resolved {
@@ -1087,3 +1239,194 @@ body:has(.comment-toolbar) .main-content {
1087
1239
  .comment-toolbar__toggle input[type="checkbox"] {
1088
1240
  cursor: pointer;
1089
1241
  }
1242
+
1243
+ /* Content navigation sidebar */
1244
+ .content-nav {
1245
+ position: sticky;
1246
+ top: calc(var(--nav-height) + var(--space-lg));
1247
+ align-self: flex-start;
1248
+ width: 220px;
1249
+ flex-shrink: 0;
1250
+ max-height: calc(100vh - var(--nav-height) - var(--space-xl) * 2);
1251
+ transition: width 0.2s ease, opacity 0.2s ease;
1252
+ margin-right: var(--space-lg);
1253
+ }
1254
+
1255
+ .content-nav--hidden {
1256
+ width: 0;
1257
+ opacity: 0;
1258
+ overflow: hidden;
1259
+ margin-right: 0;
1260
+ }
1261
+
1262
+ .content-nav__header {
1263
+ display: flex;
1264
+ align-items: center;
1265
+ justify-content: space-between;
1266
+ padding-bottom: var(--space-sm);
1267
+ margin-bottom: var(--space-sm);
1268
+ border-bottom: 1px solid var(--color-border);
1269
+ }
1270
+
1271
+ .content-nav__title {
1272
+ font-size: var(--text-sm);
1273
+ font-weight: 600;
1274
+ color: var(--color-text-muted);
1275
+ text-transform: uppercase;
1276
+ letter-spacing: 0.05em;
1277
+ }
1278
+
1279
+ .content-nav__toggle {
1280
+ background: none;
1281
+ border: none;
1282
+ cursor: pointer;
1283
+ color: var(--color-text-muted);
1284
+ padding: 2px;
1285
+ font-size: var(--text-sm);
1286
+ line-height: 1;
1287
+ border-radius: var(--radius);
1288
+ transition: color 0.15s;
1289
+ }
1290
+
1291
+ .content-nav__toggle:hover {
1292
+ color: var(--color-text);
1293
+ }
1294
+
1295
+ .content-nav__list {
1296
+ list-style: none;
1297
+ padding: 0;
1298
+ margin: 0;
1299
+ overflow-y: auto;
1300
+ max-height: calc(100vh - var(--nav-height) - 8rem);
1301
+ scrollbar-width: thin;
1302
+ scrollbar-color: var(--color-border) transparent;
1303
+ }
1304
+
1305
+ .content-nav__item {
1306
+ margin: 0;
1307
+ }
1308
+
1309
+ .content-nav__item--h1 {
1310
+ /* top level — no indent */
1311
+ }
1312
+
1313
+ .content-nav__item--h2 {
1314
+ padding-left: var(--space-md);
1315
+ }
1316
+
1317
+ .content-nav__item--h3 {
1318
+ padding-left: var(--space-xl);
1319
+ }
1320
+
1321
+ .content-nav__link {
1322
+ display: flex;
1323
+ align-items: center;
1324
+ gap: var(--space-xs);
1325
+ padding: 3px var(--space-sm);
1326
+ font-size: 0.8rem;
1327
+ line-height: 1.4;
1328
+ color: var(--color-text-muted);
1329
+ text-decoration: none;
1330
+ border-radius: var(--radius);
1331
+ border-left: 2px solid transparent;
1332
+ transition: color 0.15s, border-color 0.15s, background 0.15s;
1333
+ word-break: break-word;
1334
+ }
1335
+
1336
+ .content-nav__link:hover {
1337
+ color: var(--color-text);
1338
+ background: var(--color-bg);
1339
+ text-decoration: none;
1340
+ }
1341
+
1342
+ .content-nav__link--active {
1343
+ color: var(--color-primary);
1344
+ border-left-color: var(--color-primary);
1345
+ font-weight: 600;
1346
+ }
1347
+
1348
+ .content-nav__link-text {
1349
+ flex: 1;
1350
+ min-width: 0;
1351
+ }
1352
+
1353
+ .content-nav__badge {
1354
+ flex-shrink: 0;
1355
+ font-size: 0.65rem;
1356
+ font-weight: 700;
1357
+ min-width: 16px;
1358
+ height: 16px;
1359
+ display: inline-flex;
1360
+ align-items: center;
1361
+ justify-content: center;
1362
+ border-radius: 8px;
1363
+ color: white;
1364
+ line-height: 1;
1365
+ }
1366
+
1367
+ .content-nav__badge--pending {
1368
+ background: var(--color-status-considering);
1369
+ }
1370
+
1371
+ .content-nav__badge--todo {
1372
+ background: var(--color-status-developing);
1373
+ }
1374
+
1375
+ /* Toggle button when sidebar is hidden — shown outside the sidebar */
1376
+ .content-nav-show-btn {
1377
+ position: sticky;
1378
+ top: calc(var(--nav-height) + var(--space-lg));
1379
+ align-self: flex-start;
1380
+ flex-shrink: 0;
1381
+ background: var(--color-surface);
1382
+ border: 1px solid var(--color-border);
1383
+ border-radius: var(--radius);
1384
+ padding: var(--space-xs) var(--space-sm);
1385
+ cursor: pointer;
1386
+ color: var(--color-text-muted);
1387
+ font-size: var(--text-sm);
1388
+ box-shadow: var(--shadow);
1389
+ transition: color 0.15s, box-shadow 0.15s;
1390
+ margin-right: var(--space-sm);
1391
+ display: none;
1392
+ white-space: nowrap;
1393
+ }
1394
+
1395
+ .content-nav-show-btn:hover {
1396
+ color: var(--color-text);
1397
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
1398
+ }
1399
+
1400
+ .content-nav-show-btn kbd {
1401
+ font-family: var(--font-mono);
1402
+ font-size: 0.7rem;
1403
+ background: var(--color-bg);
1404
+ border: 1px solid var(--color-border);
1405
+ border-radius: 3px;
1406
+ padding: 0 4px;
1407
+ color: var(--color-text-muted);
1408
+ }
1409
+
1410
+ .content-nav--hidden + .content-nav-show-btn {
1411
+ display: block;
1412
+ }
1413
+
1414
+ /* Add scroll-margin to headings so they clear the sticky nav */
1415
+ .markdown-rendered h1,
1416
+ .markdown-rendered h2,
1417
+ .markdown-rendered h3,
1418
+ .markdown-rendered h4,
1419
+ .markdown-rendered h5,
1420
+ .markdown-rendered h6 {
1421
+ scroll-margin-top: calc(var(--nav-height) + var(--space-lg));
1422
+ }
1423
+
1424
+ /* Hide sidebar on small screens */
1425
+ @media (max-width: 1024px) {
1426
+ .content-nav {
1427
+ display: none;
1428
+ }
1429
+ .content-nav-show-btn {
1430
+ display: none !important;
1431
+ }
1432
+ }
@@ -0,0 +1,45 @@
1
+ module CoPlan
2
+ class PlanPresenceChannel < ActionCable::Channel::Base
3
+ def subscribed
4
+ @plan = Plan.find_by(id: params[:plan_id])
5
+ policy = @plan && PlanPolicy.new(current_user, @plan)
6
+ unless @plan && policy.show?
7
+ reject
8
+ return
9
+ end
10
+
11
+ PlanViewer.track(plan: @plan, user: current_user)
12
+ broadcast_viewers
13
+ end
14
+
15
+ def unsubscribed
16
+ return unless @plan
17
+
18
+ PlanViewer.expire(plan: @plan, user: current_user)
19
+ broadcast_viewers
20
+ end
21
+
22
+ def ping
23
+ return unless @plan
24
+
25
+ PlanViewer.track(plan: @plan, user: current_user)
26
+ broadcast_viewers
27
+ end
28
+
29
+ private
30
+
31
+ def current_user
32
+ connection.current_user
33
+ end
34
+
35
+ def broadcast_viewers
36
+ viewers = PlanViewer.active_viewers_for(@plan)
37
+ Broadcaster.replace_to(
38
+ @plan,
39
+ target: "plan-viewers",
40
+ partial: "coplan/plans/viewers",
41
+ locals: { viewers: viewers }
42
+ )
43
+ end
44
+ end
45
+ end
@@ -34,7 +34,7 @@ module CoPlan
34
34
  }, status: :created
35
35
 
36
36
  rescue ActiveRecord::RecordInvalid => e
37
- render json: { error: e.message }, status: :unprocessable_entity
37
+ render json: { error: e.message }, status: :unprocessable_content
38
38
  end
39
39
 
40
40
  def resolve
@@ -98,7 +98,7 @@ module CoPlan
98
98
  }, status: :created
99
99
 
100
100
  rescue ActiveRecord::RecordInvalid => e
101
- render json: { error: e.message }, status: :unprocessable_entity
101
+ render json: { error: e.message }, status: :unprocessable_content
102
102
  end
103
103
 
104
104
  private
@@ -10,12 +10,12 @@ module CoPlan
10
10
  base_revision = params[:base_revision]&.to_i
11
11
 
12
12
  unless base_revision.present?
13
- render json: { error: "base_revision is required" }, status: :unprocessable_entity
13
+ render json: { error: "base_revision is required" }, status: :unprocessable_content
14
14
  return
15
15
  end
16
16
 
17
17
  unless operations.is_a?(Array) && operations.any?
18
- render json: { error: "operations must be a non-empty array" }, status: :unprocessable_entity
18
+ render json: { error: "operations must be a non-empty array" }, status: :unprocessable_content
19
19
  return
20
20
  end
21
21
 
@@ -27,7 +27,7 @@ module CoPlan
27
27
  apply_direct(operations, base_revision)
28
28
  end
29
29
  rescue Plans::OperationError => e
30
- render json: { error: e.message }, status: :unprocessable_entity
30
+ render json: { error: e.message }, status: :unprocessable_content
31
31
  end
32
32
 
33
33
  private
@@ -281,6 +281,44 @@ module CoPlan
281
281
  return
282
282
  end
283
283
  end
284
+ when "replace_section"
285
+ return unless op["heading"]
286
+ include_heading = op.fetch("include_heading", true)
287
+ include_heading = include_heading != false && include_heading != "false"
288
+
289
+ transformed_ranges.each do |tr|
290
+ if include_heading
291
+ # Verify the heading is the first line of the section range
292
+ first_line_end = content.index("\n", tr[0]) || tr[1]
293
+ first_line = content[tr[0]...[first_line_end, tr[1]].min]
294
+ unless first_line&.rstrip == op["heading"]&.rstrip
295
+ render json: {
296
+ error: "Conflict: section at target position has changed",
297
+ current_revision: @plan.current_revision,
298
+ expected_heading: op["heading"],
299
+ found: content[tr[0]...tr[1]]&.slice(0, 200)
300
+ }, status: :conflict
301
+ return
302
+ end
303
+ else
304
+ # Body-only: verify the heading appears on the line before tr[0].
305
+ # Walk backwards past any blank lines to find the heading text.
306
+ search_pos = tr[0]
307
+ search_pos -= 1 while search_pos > 0 && content[search_pos - 1] == "\n"
308
+ heading_line_end = search_pos
309
+ heading_line_start = search_pos > 0 ? (content.rindex("\n", search_pos - 1) || -1) + 1 : 0
310
+ heading_text = content[heading_line_start...heading_line_end]
311
+ unless heading_text == op["heading"]
312
+ render json: {
313
+ error: "Conflict: section heading before target position has changed",
314
+ current_revision: @plan.current_revision,
315
+ expected_heading: op["heading"],
316
+ found: heading_text
317
+ }, status: :conflict
318
+ return
319
+ end
320
+ end
321
+ end
284
322
  end
285
323
  end
286
324
 
@@ -32,7 +32,7 @@ module CoPlan
32
32
  current_revision: plan.current_revision
33
33
  ), status: :created
34
34
  rescue ActiveRecord::RecordInvalid => e
35
- render json: { error: e.message }, status: :unprocessable_entity
35
+ render json: { error: e.message }, status: :unprocessable_content
36
36
  end
37
37
 
38
38
  def update
@@ -61,7 +61,7 @@ module CoPlan
61
61
  current_revision: @plan.current_revision
62
62
  )
63
63
  rescue ActiveRecord::RecordInvalid => e
64
- render json: { error: e.record.errors.full_messages.join(", ") }, status: :unprocessable_entity
64
+ render json: { error: e.record.errors.full_messages.join(", ") }, status: :unprocessable_content
65
65
  end
66
66
 
67
67
  def versions
@@ -11,7 +11,7 @@ module CoPlan
11
11
  def create
12
12
  actor_type = params[:actor_type].presence || ApiToken::HOLDER_TYPE
13
13
  unless EditSession::ACTOR_TYPES.include?(actor_type)
14
- render json: { error: "Invalid actor_type" }, status: :unprocessable_entity
14
+ render json: { error: "Invalid actor_type" }, status: :unprocessable_content
15
15
  return
16
16
  end
17
17
  ttl = actor_type == "cloud_persona" ? EditSession::CLOUD_PERSONA_TTL : EditSession::LOCAL_AGENT_TTL
@@ -56,7 +56,7 @@ module CoPlan
56
56
 
57
57
  render json: response
58
58
  rescue Plans::CommitSession::SessionNotOpenError => e
59
- render json: { error: e.message }, status: :unprocessable_entity
59
+ render json: { error: e.message }, status: :unprocessable_content
60
60
  rescue Plans::CommitSession::StaleSessionError => e
61
61
  render json: { error: e.message }, status: :conflict
62
62
  rescue Plans::CommitSession::SessionConflictError => e
@@ -11,6 +11,7 @@ module CoPlan
11
11
  def show
12
12
  authorize!(@plan, :show?)
13
13
  @threads = @plan.comment_threads.includes(:comments, :created_by_user).order(:created_at)
14
+ PlanViewer.track(plan: @plan, user: current_user)
14
15
  end
15
16
 
16
17
  def edit
@@ -20,7 +20,7 @@ module CoPlan
20
20
  rescue ActiveRecord::RecordInvalid => e
21
21
  @api_tokens = current_user.api_tokens.order(created_at: :desc)
22
22
  flash.now[:alert] = e.message
23
- render :index, status: :unprocessable_entity
23
+ render :index, status: :unprocessable_content
24
24
  end
25
25
 
26
26
  def destroy
@@ -1,4 +1,61 @@
1
1
  module CoPlan
2
2
  module ApplicationHelper
3
+ include MarkdownHelper
4
+ FAVICON_COLORS = {
5
+ "production" => { start: "#3B82F6", stop: "#1E40AF" },
6
+ "staging" => { start: "#F59E0B", stop: "#D97706" },
7
+ "development" => { start: "#10B981", stop: "#047857" },
8
+ }.freeze
9
+
10
+ def coplan_favicon_tag
11
+ colors = FAVICON_COLORS.fetch(Rails.env, FAVICON_COLORS["development"])
12
+ svg = <<~SVG
13
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
14
+ <defs>
15
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
16
+ <stop offset="0%" stop-color="#{colors[:start]}"/>
17
+ <stop offset="100%" stop-color="#{colors[:stop]}"/>
18
+ </linearGradient>
19
+ </defs>
20
+ <rect width="100" height="100" rx="22" fill="url(#g)"/>
21
+ <g opacity=".25" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round">
22
+ <line x1="16" y1="18" x2="84" y2="18"/>
23
+ <line x1="16" y1="28" x2="84" y2="28"/>
24
+ <line x1="16" y1="38" x2="84" y2="38"/>
25
+ <line x1="16" y1="48" x2="84" y2="48"/>
26
+ <line x1="16" y1="58" x2="84" y2="58"/>
27
+ <line x1="16" y1="68" x2="84" y2="68"/>
28
+ <line x1="16" y1="78" x2="84" y2="78"/>
29
+ </g>
30
+ <circle cx="40" cy="46" r="22" fill="#fff" opacity=".55"/>
31
+ <path d="M26,62 L18,78 L36,66 Z" fill="#fff" opacity=".55"/>
32
+ <circle cx="62" cy="54" r="22" fill="#fff" opacity=".55"/>
33
+ <path d="M76,70 L84,84 L68,72 Z" fill="#fff" opacity=".55"/>
34
+ </svg>
35
+ SVG
36
+
37
+ data_uri = "data:image/svg+xml,#{ERB::Util.url_encode(svg.strip)}"
38
+ tag.link(rel: "icon", type: "image/svg+xml", href: data_uri)
39
+ end
40
+
41
+ def plan_og_description(plan)
42
+ status = plan.status.capitalize
43
+ author = plan.created_by_user.name
44
+ prefix = "#{status} · by #{author}"
45
+ content = plan.current_content
46
+ return prefix if content.blank?
47
+
48
+ plain = markdown_to_plain_text(content)
49
+ truncated = truncate(plain, length: 200, omission: "…")
50
+ "#{prefix} — #{truncated}"
51
+ end
52
+
53
+ def coplan_environment_badge
54
+ return if Rails.env.production?
55
+
56
+ label = Rails.env.capitalize
57
+ colors = FAVICON_COLORS.fetch(Rails.env, FAVICON_COLORS["development"])
58
+ tag.span(label, class: "env-badge", style: "background: #{colors[:start]};")
59
+ end
3
60
  end
4
61
  end