@striae-org/striae 4.2.0 → 4.3.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 (90) hide show
  1. package/LICENSE +1 -1
  2. package/app/components/actions/case-manage.ts +50 -17
  3. package/app/components/audit/viewer/audit-entries-list.tsx +5 -2
  4. package/app/components/audit/viewer/use-audit-viewer-data.ts +6 -3
  5. package/app/components/audit/viewer/use-audit-viewer-export.ts +1 -1
  6. package/app/components/canvas/confirmation/confirmation.tsx +6 -2
  7. package/app/components/colors/colors.module.css +4 -3
  8. package/app/components/navbar/case-modals/archive-case-modal.module.css +0 -76
  9. package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -8
  10. package/app/components/navbar/case-modals/case-modal-shared.module.css +94 -0
  11. package/app/components/navbar/case-modals/delete-case-modal.module.css +9 -0
  12. package/app/components/navbar/case-modals/delete-case-modal.tsx +79 -0
  13. package/app/components/navbar/case-modals/open-case-modal.module.css +2 -1
  14. package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -72
  15. package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -8
  16. package/app/components/navbar/navbar.tsx +34 -9
  17. package/app/components/sidebar/cases/case-sidebar.tsx +93 -73
  18. package/app/components/sidebar/cases/cases-modal.module.css +312 -10
  19. package/app/components/sidebar/cases/cases-modal.tsx +737 -116
  20. package/app/components/sidebar/cases/cases.module.css +43 -0
  21. package/app/components/sidebar/files/delete-files-modal.module.css +26 -0
  22. package/app/components/sidebar/files/delete-files-modal.tsx +94 -0
  23. package/app/components/sidebar/files/files-modal.module.css +285 -44
  24. package/app/components/sidebar/files/files-modal.tsx +482 -177
  25. package/app/components/sidebar/notes/addl-notes-modal.tsx +82 -0
  26. package/app/components/sidebar/notes/class-details-fields.tsx +146 -0
  27. package/app/components/sidebar/notes/class-details-modal.tsx +147 -0
  28. package/app/components/sidebar/notes/class-details-sections.tsx +561 -0
  29. package/app/components/sidebar/notes/class-details-shared.ts +239 -0
  30. package/app/components/sidebar/notes/{notes-sidebar.tsx → notes-editor-form.tsx} +77 -76
  31. package/app/components/sidebar/notes/notes-editor-modal.tsx +5 -7
  32. package/app/components/sidebar/notes/notes.module.css +262 -14
  33. package/app/components/sidebar/notes/use-class-details-state.ts +371 -0
  34. package/app/components/sidebar/sidebar-container.tsx +2 -0
  35. package/app/components/sidebar/sidebar.tsx +15 -1
  36. package/app/{tailwind.css → global.css} +1 -3
  37. package/app/hooks/useCaseListPreferences.ts +99 -0
  38. package/app/hooks/useFileListPreferences.ts +106 -0
  39. package/app/hooks/useOverlayDismiss.ts +6 -4
  40. package/app/root.tsx +1 -1
  41. package/app/routes/striae/striae.tsx +7 -0
  42. package/app/services/audit/audit.service.ts +2 -2
  43. package/app/services/audit/builders/audit-event-builders-case-file.ts +1 -1
  44. package/app/types/annotations.ts +48 -1
  45. package/app/types/audit.ts +1 -0
  46. package/app/utils/data/case-filters.ts +127 -0
  47. package/app/utils/data/confirmation-summary/summary-core.ts +295 -0
  48. package/app/utils/data/data-operations.ts +17 -861
  49. package/app/utils/data/file-filters.ts +201 -0
  50. package/app/utils/data/index.ts +11 -1
  51. package/app/utils/data/operations/batch-operations.ts +113 -0
  52. package/app/utils/data/operations/case-operations.ts +168 -0
  53. package/app/utils/data/operations/confirmation-summary-operations.ts +301 -0
  54. package/app/utils/data/operations/file-annotation-operations.ts +196 -0
  55. package/app/utils/data/operations/index.ts +7 -0
  56. package/app/utils/data/operations/signing-operations.ts +225 -0
  57. package/app/utils/data/operations/types.ts +42 -0
  58. package/app/utils/data/operations/validation-operations.ts +48 -0
  59. package/app/utils/forensics/export-verification.ts +40 -111
  60. package/functions/api/_shared/firebase-auth.ts +2 -7
  61. package/functions/api/image/[[path]].ts +23 -22
  62. package/functions/api/pdf/[[path]].ts +27 -8
  63. package/package.json +7 -13
  64. package/scripts/deploy-primershear-emails.sh +1 -1
  65. package/worker-configuration.d.ts +2 -2
  66. package/workers/audit-worker/package.json +1 -1
  67. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  68. package/workers/data-worker/package.json +1 -1
  69. package/workers/data-worker/wrangler.jsonc.example +1 -1
  70. package/workers/image-worker/package.json +1 -1
  71. package/workers/image-worker/src/image-worker.example.ts +16 -5
  72. package/workers/image-worker/wrangler.jsonc.example +1 -1
  73. package/workers/keys-worker/package.json +1 -1
  74. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  75. package/workers/pdf-worker/package.json +1 -1
  76. package/workers/pdf-worker/src/formats/format-striae.ts +84 -124
  77. package/workers/pdf-worker/src/pdf-worker.example.ts +58 -61
  78. package/workers/pdf-worker/src/report-layout.ts +227 -0
  79. package/workers/pdf-worker/src/report-types.ts +23 -3
  80. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  81. package/workers/user-worker/package.json +1 -1
  82. package/workers/user-worker/src/user-worker.example.ts +17 -0
  83. package/workers/user-worker/wrangler.jsonc.example +1 -1
  84. package/wrangler.toml.example +1 -1
  85. package/NOTICE +0 -13
  86. package/app/components/sidebar/notes/notes-modal.tsx +0 -52
  87. package/postcss.config.js +0 -6
  88. package/tailwind.config.ts +0 -22
  89. package/workers/pdf-worker/src/assets/icon-256.png +0 -0
  90. /package/workers/pdf-worker/src/assets/{generated-assets.ts → generated-assets.example.ts} +0 -0
@@ -1,20 +1,20 @@
1
- .notesSidebar {
1
+ .notesEditorForm {
2
2
  padding: 0.3rem;
3
3
  }
4
4
 
5
- .compactLayout .caseNumbers {
5
+ .editorLayout .caseNumbers {
6
6
  display: grid;
7
7
  grid-template-columns: repeat(2, minmax(0, 1fr));
8
8
  gap: 1.25rem;
9
9
  align-items: start;
10
10
  }
11
11
 
12
- .compactLayout .caseNumbers > .inputGroup + .inputGroup {
12
+ .editorLayout .caseNumbers > .inputGroup + .inputGroup {
13
13
  border-left: 1px solid #dee2e6;
14
14
  padding-left: 1.25rem;
15
15
  }
16
16
 
17
- .compactLayout .inputGroup {
17
+ .editorLayout .inputGroup {
18
18
  margin-bottom: 0;
19
19
  }
20
20
 
@@ -35,11 +35,11 @@
35
35
  margin-bottom: 1.5rem;
36
36
  }
37
37
 
38
- .compactLayout .notesActionBarSticky {
38
+ .editorLayout .notesActionBarSticky {
39
39
  margin-top: 0.25rem;
40
40
  }
41
41
 
42
- .compactLayout .compactSectionGrid > .compactHalfSection + .compactHalfSection {
42
+ .editorLayout .compactSectionGrid > .compactHalfSection + .compactHalfSection {
43
43
  border-left: 1px solid #dee2e6;
44
44
  padding-left: 1.25rem;
45
45
  }
@@ -49,22 +49,19 @@
49
49
  padding-left: 1.25rem;
50
50
  }
51
51
 
52
- .compactLayout .additionalNotesRow {
52
+ .editorLayout .additionalNotesRow {
53
53
  border-top: 1px solid #dee2e6;
54
54
  padding-top: 1.25rem;
55
55
  }
56
56
 
57
57
  @media (max-width: 980px) {
58
- .compactLayout .caseNumbers,
58
+ .editorLayout .caseNumbers,
59
59
  .compactSectionGrid {
60
60
  grid-template-columns: 1fr;
61
61
  }
62
62
 
63
- .compactLayout .caseNumbers > .inputGroup + .inputGroup,
64
- .compactLayout
65
- .compactSectionGrid
66
- > .compactHalfSection
67
- + .compactHalfSection,
63
+ .editorLayout .caseNumbers > .inputGroup + .inputGroup,
64
+ .editorLayout .compactSectionGrid > .compactHalfSection + .compactHalfSection,
68
65
  .classCharacteristicsColumns > .characteristicsPlaceholder {
69
66
  border-left: none;
70
67
  padding-left: 0;
@@ -152,9 +149,25 @@ textarea:focus {
152
149
  }
153
150
 
154
151
  .caseNumbers {
152
+ display: grid;
153
+ grid-template-columns: 1fr 1fr;
154
+ gap: 1.5rem;
155
+ margin-bottom: 2rem;
156
+ }
157
+
158
+ .fontColorRow {
159
+ display: flex;
160
+ flex-direction: column;
161
+ gap: 0.75rem;
155
162
  margin-bottom: 2rem;
156
163
  }
157
164
 
165
+ .fontColorRow label {
166
+ font-size: 0.95rem;
167
+ font-weight: 600;
168
+ color: #212529;
169
+ }
170
+
158
171
  .caseInput {
159
172
  display: flex;
160
173
  flex-direction: column;
@@ -234,6 +247,241 @@ textarea:focus {
234
247
  line-height: 1.4;
235
248
  }
236
249
 
250
+ /* Class Details Panel — replaces placeholder in right column */
251
+
252
+ .classDetailsPanel {
253
+ display: flex;
254
+ flex-direction: column;
255
+ gap: 0.75rem;
256
+ min-height: 150px;
257
+ }
258
+
259
+ .classDetailsButton {
260
+ width: 100%;
261
+ padding: 0.65rem 1rem;
262
+ background: transparent;
263
+ color: var(--primary);
264
+ border: 1.5px solid var(--primary);
265
+ border-radius: 6px;
266
+ font-size: 0.88rem;
267
+ font-weight: 500;
268
+ cursor: pointer;
269
+ transition: all 0.2s;
270
+ text-align: center;
271
+ }
272
+
273
+ .classDetailsButton:hover:not(:disabled) {
274
+ background-color: color-mix(in lab, var(--primary) 10%, transparent);
275
+ }
276
+
277
+ .classDetailsButton:disabled {
278
+ opacity: 0.5;
279
+ cursor: not-allowed;
280
+ }
281
+
282
+ /* Class Details Modal */
283
+
284
+ .classDetailsModal {
285
+ max-width: 680px;
286
+ max-height: 80vh;
287
+ display: flex;
288
+ flex-direction: column;
289
+ gap: 1rem;
290
+ }
291
+
292
+ .classDetailsContent {
293
+ overflow-y: auto;
294
+ flex: 1;
295
+ min-height: 0;
296
+ display: flex;
297
+ flex-direction: column;
298
+ gap: 1.25rem;
299
+ padding-right: 0.25rem;
300
+ padding-bottom: 0.5rem;
301
+ scrollbar-width: thin;
302
+ }
303
+
304
+ .classDetailsSection {
305
+ display: flex;
306
+ flex-direction: column;
307
+ gap: 1rem;
308
+ }
309
+
310
+ .classDetailsSectionHeader {
311
+ margin: 0;
312
+ font-size: 0.95rem;
313
+ font-weight: 700;
314
+ color: #343a40;
315
+ padding-bottom: 0.4rem;
316
+ border-bottom: 1.5px solid #dee2e6;
317
+ }
318
+
319
+ .classDetailsFieldGrid {
320
+ display: grid;
321
+ grid-template-columns: 1fr 1fr;
322
+ gap: 0.85rem 1.25rem;
323
+ }
324
+
325
+ .classDetailsField {
326
+ display: flex;
327
+ flex-direction: column;
328
+ gap: 0.25rem;
329
+ }
330
+
331
+ .classDetailsFieldFull {
332
+ grid-column: 1 / -1;
333
+ }
334
+
335
+ .classDetailsLabel {
336
+ font-size: 0.8rem;
337
+ font-weight: 600;
338
+ color: #495057;
339
+ }
340
+
341
+ .classDetailsInput {
342
+ padding: 0.5rem 0.65rem;
343
+ border: 1.5px solid #ced4da;
344
+ border-radius: 6px;
345
+ font-size: 0.88rem;
346
+ transition: border-color 0.2s;
347
+ width: 100%;
348
+ box-sizing: border-box;
349
+ }
350
+
351
+ .classDetailsInput:focus {
352
+ border-color: var(--primary);
353
+ outline: none;
354
+ }
355
+
356
+ .classDetailsInput:disabled {
357
+ background-color: #f8f9fa;
358
+ cursor: not-allowed;
359
+ }
360
+
361
+ .classDetailsCheckboxGroup {
362
+ display: flex;
363
+ flex-wrap: wrap;
364
+ gap: 0.4rem 1.25rem;
365
+ }
366
+
367
+ .classDetailsCheckboxLabel {
368
+ display: flex;
369
+ align-items: center;
370
+ gap: 0.4rem;
371
+ font-size: 0.85rem;
372
+ color: #495057;
373
+ cursor: pointer;
374
+ }
375
+
376
+ .classDetailsCheckboxLabel input[type="checkbox"] {
377
+ cursor: pointer;
378
+ }
379
+
380
+ .classDetailsCheckboxLabel input[type="checkbox"]:disabled {
381
+ cursor: not-allowed;
382
+ }
383
+
384
+ .lgWidthsSection {
385
+ display: flex;
386
+ flex-direction: column;
387
+ gap: 0.75rem;
388
+ }
389
+
390
+ .lgWidthsLayout {
391
+ display: flex;
392
+ gap: 1.25rem;
393
+ }
394
+
395
+ .lgWidthsColumn {
396
+ flex: 1;
397
+ display: flex;
398
+ flex-direction: column;
399
+ gap: 0.85rem;
400
+ }
401
+
402
+ .calculatedDiameterWrapper {
403
+ display: flex;
404
+ flex-direction: column;
405
+ gap: 0.4rem;
406
+ }
407
+
408
+ .calculatedDiameterDisplay {
409
+ display: flex;
410
+ justify-content: space-between;
411
+ align-items: center;
412
+ gap: 1rem;
413
+ padding: 0.85rem 1rem;
414
+ border: 1px solid #dee2e6;
415
+ border-radius: 8px;
416
+ background: #f8f9fa;
417
+ }
418
+
419
+ .calculatedDiameterValue {
420
+ font-size: 0.95rem;
421
+ font-weight: 700;
422
+ color: #343a40;
423
+ }
424
+
425
+ .calcExplanationToggle {
426
+ all: unset;
427
+ cursor: pointer;
428
+ font-size: 0.8rem;
429
+ color: var(--color-primary, #4f6ef7);
430
+ text-decoration: underline;
431
+ text-underline-offset: 2px;
432
+ padding: 0.1rem 0;
433
+ align-self: flex-start;
434
+ }
435
+
436
+ .calcExplanationToggle:hover {
437
+ color: var(--color-primary-hover, #3a56d4);
438
+ }
439
+
440
+ .calcExplanationPanel {
441
+ padding: 0.85rem 1rem;
442
+ border: 1px solid #dee2e6;
443
+ border-radius: 8px;
444
+ background: #f8f9fa;
445
+ font-size: 0.82rem;
446
+ color: #495057;
447
+ display: flex;
448
+ flex-direction: column;
449
+ gap: 0.55rem;
450
+ }
451
+
452
+ .calcExplanationFormula {
453
+ margin: 0;
454
+ font-family: monospace;
455
+ font-size: 0.88rem;
456
+ font-weight: 600;
457
+ color: #343a40;
458
+ letter-spacing: 0.01em;
459
+ }
460
+
461
+ .calcExplanationList {
462
+ margin: 0;
463
+ padding-left: 1.15rem;
464
+ display: flex;
465
+ flex-direction: column;
466
+ gap: 0.2rem;
467
+ }
468
+
469
+ .calcExplanationList li {
470
+ line-height: 1.5;
471
+ }
472
+
473
+ .calcExplanationNote {
474
+ margin: 0;
475
+ color: #6c757d;
476
+ font-style: italic;
477
+ }
478
+
479
+ .calcExplanationExample {
480
+ margin: 0;
481
+ padding-top: 0.35rem;
482
+ border-top: 1px solid #dee2e6;
483
+ }
484
+
237
485
  .classCharacteristics input {
238
486
  width: 100%;
239
487
  padding: 0.75rem;
@@ -382,7 +630,7 @@ textarea:focus {
382
630
  width: 100%;
383
631
  }
384
632
 
385
- .compactLayout .additionalNotesRow {
633
+ .editorLayout .additionalNotesRow {
386
634
  grid-column: 1 / -1;
387
635
  }
388
636
 
@@ -0,0 +1,371 @@
1
+ import { useState } from 'react';
2
+ import type {
3
+ BulletAnnotationData,
4
+ CartridgeCaseAnnotationData,
5
+ ShotshellAnnotationData,
6
+ } from '~/types/annotations';
7
+ import {
8
+ ALL_CALIBERS,
9
+ BULLET_BARREL_TYPE_OPTIONS,
10
+ BULLET_CORE_METAL_OPTIONS,
11
+ BULLET_JACKET_METAL_OPTIONS,
12
+ BULLET_TYPE_OPTIONS,
13
+ CARTRIDGE_APERTURE_SHAPE_OPTIONS,
14
+ CARTRIDGE_FPI_SHAPE_OPTIONS,
15
+ CARTRIDGE_METAL_OPTIONS,
16
+ CARTRIDGE_PRIMER_TYPE_OPTIONS,
17
+ SHOTSHELL_GAUGES,
18
+ calculateBulletDiameter,
19
+ formatCalculatedDiameter,
20
+ isCustomValue,
21
+ } from './class-details-shared';
22
+
23
+ interface UseClassDetailsStateParams {
24
+ bulletData?: BulletAnnotationData;
25
+ cartridgeCaseData?: CartridgeCaseAnnotationData;
26
+ shotshellData?: ShotshellAnnotationData;
27
+ }
28
+
29
+ interface BuildSaveDataParams {
30
+ showBullet: boolean;
31
+ showCartridge: boolean;
32
+ showShotshell: boolean;
33
+ }
34
+
35
+ interface BuildSaveDataResult {
36
+ bulletData: BulletAnnotationData | undefined;
37
+ cartridgeCaseData: CartridgeCaseAnnotationData | undefined;
38
+ shotshellData: ShotshellAnnotationData | undefined;
39
+ }
40
+
41
+ export interface BulletDetailsState {
42
+ caliber: string;
43
+ caliberIsCustom: boolean;
44
+ mass: string;
45
+ diameter: string;
46
+ lgNumber: string;
47
+ lgDirection: string;
48
+ lWidths: string[];
49
+ gWidths: string[];
50
+ jacketMetal: string;
51
+ jacketMetalIsCustom: boolean;
52
+ coreMetal: string;
53
+ coreMetalIsCustom: boolean;
54
+ bulletType: string;
55
+ bulletTypeIsCustom: boolean;
56
+ barrelType: string;
57
+ barrelTypeIsCustom: boolean;
58
+ lgCount: number;
59
+ calculatedDiameter: number | null;
60
+ setCaliber: (value: string) => void;
61
+ setCaliberIsCustom: (value: boolean) => void;
62
+ setMass: (value: string) => void;
63
+ setDiameter: (value: string) => void;
64
+ setLgNumber: (value: string) => void;
65
+ setLgDirection: (value: string) => void;
66
+ updateLWidth: (index: number, value: string) => void;
67
+ updateGWidth: (index: number, value: string) => void;
68
+ setJacketMetal: (value: string) => void;
69
+ setJacketMetalIsCustom: (value: boolean) => void;
70
+ setCoreMetal: (value: string) => void;
71
+ setCoreMetalIsCustom: (value: boolean) => void;
72
+ setBulletType: (value: string) => void;
73
+ setBulletTypeIsCustom: (value: boolean) => void;
74
+ setBarrelType: (value: string) => void;
75
+ setBarrelTypeIsCustom: (value: boolean) => void;
76
+ }
77
+
78
+ export interface CartridgeCaseDetailsState {
79
+ caliber: string;
80
+ caliberIsCustom: boolean;
81
+ brand: string;
82
+ metal: string;
83
+ metalIsCustom: boolean;
84
+ primerType: string;
85
+ primerTypeIsCustom: boolean;
86
+ fpiShape: string;
87
+ fpiShapeIsCustom: boolean;
88
+ apertureShape: string;
89
+ apertureShapeIsCustom: boolean;
90
+ hasFpDrag: boolean;
91
+ hasExtractorMarks: boolean;
92
+ hasEjectorMarks: boolean;
93
+ hasChamberMarks: boolean;
94
+ hasMagazineLipMarks: boolean;
95
+ hasPrimerShear: boolean;
96
+ hasEjectionPortMarks: boolean;
97
+ setCaliber: (value: string) => void;
98
+ setCaliberIsCustom: (value: boolean) => void;
99
+ setBrand: (value: string) => void;
100
+ setMetal: (value: string) => void;
101
+ setMetalIsCustom: (value: boolean) => void;
102
+ setPrimerType: (value: string) => void;
103
+ setPrimerTypeIsCustom: (value: boolean) => void;
104
+ setFpiShape: (value: string) => void;
105
+ setFpiShapeIsCustom: (value: boolean) => void;
106
+ setApertureShape: (value: string) => void;
107
+ setApertureShapeIsCustom: (value: boolean) => void;
108
+ setHasFpDrag: (value: boolean) => void;
109
+ setHasExtractorMarks: (value: boolean) => void;
110
+ setHasEjectorMarks: (value: boolean) => void;
111
+ setHasChamberMarks: (value: boolean) => void;
112
+ setHasMagazineLipMarks: (value: boolean) => void;
113
+ setHasPrimerShear: (value: boolean) => void;
114
+ setHasEjectionPortMarks: (value: boolean) => void;
115
+ }
116
+
117
+ export interface ShotshellDetailsState {
118
+ gauge: string;
119
+ gaugeIsCustom: boolean;
120
+ shotSize: string;
121
+ metal: string;
122
+ metalIsCustom: boolean;
123
+ brand: string;
124
+ fpiShape: string;
125
+ fpiShapeIsCustom: boolean;
126
+ hasExtractorMarks: boolean;
127
+ hasEjectorMarks: boolean;
128
+ hasChamberMarks: boolean;
129
+ setGauge: (value: string) => void;
130
+ setGaugeIsCustom: (value: boolean) => void;
131
+ setShotSize: (value: string) => void;
132
+ setMetal: (value: string) => void;
133
+ setMetalIsCustom: (value: boolean) => void;
134
+ setBrand: (value: string) => void;
135
+ setFpiShape: (value: string) => void;
136
+ setFpiShapeIsCustom: (value: boolean) => void;
137
+ setHasExtractorMarks: (value: boolean) => void;
138
+ setHasEjectorMarks: (value: boolean) => void;
139
+ setHasChamberMarks: (value: boolean) => void;
140
+ }
141
+
142
+ export const useClassDetailsState = ({
143
+ bulletData,
144
+ cartridgeCaseData,
145
+ shotshellData,
146
+ }: UseClassDetailsStateParams) => {
147
+ const [bCaliber, setBCaliber] = useState(() => bulletData?.caliber || '');
148
+ const [bCaliberIsCustom, setBCaliberIsCustom] = useState(() => isCustomValue(bulletData?.caliber, ALL_CALIBERS));
149
+ const [bMass, setBMass] = useState(() => bulletData?.mass || '');
150
+ const [bDiameter, setBDiameter] = useState(() => bulletData?.diameter || '');
151
+ const [bLgNumber, setBLgNumber] = useState(() => bulletData?.lgNumber !== undefined ? String(bulletData.lgNumber) : '');
152
+ const [bLgDirection, setBLgDirection] = useState(() => bulletData?.lgDirection || '');
153
+ const [bLWidths, setBLWidths] = useState<string[]>(() => bulletData?.lWidths || []);
154
+ const [bGWidths, setBGWidths] = useState<string[]>(() => bulletData?.gWidths || []);
155
+ const [bJacketMetal, setBJacketMetal] = useState(() => bulletData?.jacketMetal || '');
156
+ const [bJacketMetalIsCustom, setBJacketMetalIsCustom] = useState(() => isCustomValue(bulletData?.jacketMetal, BULLET_JACKET_METAL_OPTIONS));
157
+ const [bCoreMetal, setBCoreMetal] = useState(() => bulletData?.coreMetal || '');
158
+ const [bCoreMetalIsCustom, setBCoreMetalIsCustom] = useState(() => isCustomValue(bulletData?.coreMetal, BULLET_CORE_METAL_OPTIONS));
159
+ const [bBulletType, setBBulletType] = useState(() => bulletData?.bulletType || '');
160
+ const [bBulletTypeIsCustom, setBBulletTypeIsCustom] = useState(() => isCustomValue(bulletData?.bulletType, BULLET_TYPE_OPTIONS));
161
+ const [bBarrelType, setBBarrelType] = useState(() => bulletData?.barrelType || '');
162
+ const [bBarrelTypeIsCustom, setBBarrelTypeIsCustom] = useState(() => isCustomValue(bulletData?.barrelType, BULLET_BARREL_TYPE_OPTIONS));
163
+
164
+ const [cCaliber, setCCaliber] = useState(() => cartridgeCaseData?.caliber || '');
165
+ const [cCaliberIsCustom, setCCaliberIsCustom] = useState(() => isCustomValue(cartridgeCaseData?.caliber, ALL_CALIBERS));
166
+ const [cBrand, setCBrand] = useState(() => cartridgeCaseData?.brand || '');
167
+ const [cMetal, setCMetal] = useState(() => cartridgeCaseData?.metal || '');
168
+ const [cMetalIsCustom, setCMetalIsCustom] = useState(() => isCustomValue(cartridgeCaseData?.metal, CARTRIDGE_METAL_OPTIONS));
169
+ const [cPrimerType, setCPrimerType] = useState(() => cartridgeCaseData?.primerType || '');
170
+ const [cPrimerTypeIsCustom, setCPrimerTypeIsCustom] = useState(() => isCustomValue(cartridgeCaseData?.primerType, CARTRIDGE_PRIMER_TYPE_OPTIONS));
171
+ const [cFpiShape, setCFpiShape] = useState(() => cartridgeCaseData?.fpiShape || '');
172
+ const [cFpiShapeIsCustom, setCFpiShapeIsCustom] = useState(() => isCustomValue(cartridgeCaseData?.fpiShape, CARTRIDGE_FPI_SHAPE_OPTIONS));
173
+ const [cApertureShape, setCApertureShape] = useState(() => cartridgeCaseData?.apertureShape || '');
174
+ const [cApertureShapeIsCustom, setCApertureShapeIsCustom] = useState(() => isCustomValue(cartridgeCaseData?.apertureShape, CARTRIDGE_APERTURE_SHAPE_OPTIONS));
175
+ const [cHasFpDrag, setCHasFpDrag] = useState(() => cartridgeCaseData?.hasFpDrag ?? false);
176
+ const [cHasExtractorMarks, setCHasExtractorMarks] = useState(() => cartridgeCaseData?.hasExtractorMarks ?? false);
177
+ const [cHasEjectorMarks, setCHasEjectorMarks] = useState(() => cartridgeCaseData?.hasEjectorMarks ?? false);
178
+ const [cHasChamberMarks, setCHasChamberMarks] = useState(() => cartridgeCaseData?.hasChamberMarks ?? false);
179
+ const [cHasMagazineLipMarks, setCHasMagazineLipMarks] = useState(() => cartridgeCaseData?.hasMagazineLipMarks ?? false);
180
+ const [cHasPrimerShear, setCHasPrimerShear] = useState(() => cartridgeCaseData?.hasPrimerShear ?? false);
181
+ const [cHasEjectionPortMarks, setCHasEjectionPortMarks] = useState(() => cartridgeCaseData?.hasEjectionPortMarks ?? false);
182
+
183
+ const [sGauge, setSGauge] = useState(() => shotshellData?.gauge || '');
184
+ const [sGaugeIsCustom, setSGaugeIsCustom] = useState(() => isCustomValue(shotshellData?.gauge, SHOTSHELL_GAUGES));
185
+ const [sShotSize, setSShotSize] = useState(() => shotshellData?.shotSize || '');
186
+ const [sMetal, setSMetal] = useState(() => shotshellData?.metal || '');
187
+ const [sMetalIsCustom, setSMetalIsCustom] = useState(() => isCustomValue(shotshellData?.metal, CARTRIDGE_METAL_OPTIONS));
188
+ const [sBrand, setSBrand] = useState(() => shotshellData?.brand || '');
189
+ const [sFpiShape, setSFpiShape] = useState(() => shotshellData?.fpiShape || '');
190
+ const [sFpiShapeIsCustom, setSFpiShapeIsCustom] = useState(() => isCustomValue(shotshellData?.fpiShape, CARTRIDGE_FPI_SHAPE_OPTIONS));
191
+ const [sHasExtractorMarks, setSHasExtractorMarks] = useState(() => shotshellData?.hasExtractorMarks ?? false);
192
+ const [sHasEjectorMarks, setSHasEjectorMarks] = useState(() => shotshellData?.hasEjectorMarks ?? false);
193
+ const [sHasChamberMarks, setSHasChamberMarks] = useState(() => shotshellData?.hasChamberMarks ?? false);
194
+
195
+ const [isSaving, setIsSaving] = useState(false);
196
+
197
+ const lgCount = Math.max(0, Math.min(30, Number(bLgNumber) || 0));
198
+ const calculatedDiameter = calculateBulletDiameter(lgCount, bLWidths, bGWidths);
199
+
200
+ const updateLWidth = (index: number, value: string) => {
201
+ setBLWidths((previous) => {
202
+ const next = [...previous];
203
+ next[index] = value;
204
+ return next;
205
+ });
206
+ };
207
+
208
+ const updateGWidth = (index: number, value: string) => {
209
+ setBGWidths((previous) => {
210
+ const next = [...previous];
211
+ next[index] = value;
212
+ return next;
213
+ });
214
+ };
215
+
216
+ const buildSaveData = ({
217
+ showBullet,
218
+ showCartridge,
219
+ showShotshell,
220
+ }: BuildSaveDataParams): BuildSaveDataResult => ({
221
+ bulletData: showBullet ? {
222
+ caliber: bCaliber || undefined,
223
+ mass: bMass || undefined,
224
+ diameter: bDiameter || undefined,
225
+ calcDiameter: calculatedDiameter !== null ? formatCalculatedDiameter(calculatedDiameter) : undefined,
226
+ lgNumber: bLgNumber ? Number(bLgNumber) : undefined,
227
+ lgDirection: bLgDirection || undefined,
228
+ lWidths: bLWidths.some(Boolean) ? bLWidths : undefined,
229
+ gWidths: bGWidths.some(Boolean) ? bGWidths : undefined,
230
+ jacketMetal: bJacketMetal || undefined,
231
+ coreMetal: bCoreMetal || undefined,
232
+ bulletType: bBulletType || undefined,
233
+ barrelType: bBarrelType || undefined,
234
+ } : undefined,
235
+ cartridgeCaseData: showCartridge ? {
236
+ caliber: cCaliber || undefined,
237
+ brand: cBrand || undefined,
238
+ metal: cMetal || undefined,
239
+ primerType: cPrimerType || undefined,
240
+ fpiShape: cFpiShape || undefined,
241
+ apertureShape: cApertureShape || undefined,
242
+ hasFpDrag: cHasFpDrag || undefined,
243
+ hasExtractorMarks: cHasExtractorMarks || undefined,
244
+ hasEjectorMarks: cHasEjectorMarks || undefined,
245
+ hasChamberMarks: cHasChamberMarks || undefined,
246
+ hasMagazineLipMarks: cHasMagazineLipMarks || undefined,
247
+ hasPrimerShear: cHasPrimerShear || undefined,
248
+ hasEjectionPortMarks: cHasEjectionPortMarks || undefined,
249
+ } : undefined,
250
+ shotshellData: showShotshell ? {
251
+ gauge: sGauge || undefined,
252
+ shotSize: sShotSize || undefined,
253
+ metal: sMetal || undefined,
254
+ brand: sBrand || undefined,
255
+ fpiShape: sFpiShape || undefined,
256
+ hasExtractorMarks: sHasExtractorMarks || undefined,
257
+ hasEjectorMarks: sHasEjectorMarks || undefined,
258
+ hasChamberMarks: sHasChamberMarks || undefined,
259
+ } : undefined,
260
+ });
261
+
262
+ const bullet: BulletDetailsState = {
263
+ caliber: bCaliber,
264
+ caliberIsCustom: bCaliberIsCustom,
265
+ mass: bMass,
266
+ diameter: bDiameter,
267
+ lgNumber: bLgNumber,
268
+ lgDirection: bLgDirection,
269
+ lWidths: bLWidths,
270
+ gWidths: bGWidths,
271
+ jacketMetal: bJacketMetal,
272
+ jacketMetalIsCustom: bJacketMetalIsCustom,
273
+ coreMetal: bCoreMetal,
274
+ coreMetalIsCustom: bCoreMetalIsCustom,
275
+ bulletType: bBulletType,
276
+ bulletTypeIsCustom: bBulletTypeIsCustom,
277
+ barrelType: bBarrelType,
278
+ barrelTypeIsCustom: bBarrelTypeIsCustom,
279
+ lgCount,
280
+ calculatedDiameter,
281
+ setCaliber: setBCaliber,
282
+ setCaliberIsCustom: setBCaliberIsCustom,
283
+ setMass: setBMass,
284
+ setDiameter: setBDiameter,
285
+ setLgNumber: setBLgNumber,
286
+ setLgDirection: setBLgDirection,
287
+ updateLWidth,
288
+ updateGWidth,
289
+ setJacketMetal: setBJacketMetal,
290
+ setJacketMetalIsCustom: setBJacketMetalIsCustom,
291
+ setCoreMetal: setBCoreMetal,
292
+ setCoreMetalIsCustom: setBCoreMetalIsCustom,
293
+ setBulletType: setBBulletType,
294
+ setBulletTypeIsCustom: setBBulletTypeIsCustom,
295
+ setBarrelType: setBBarrelType,
296
+ setBarrelTypeIsCustom: setBBarrelTypeIsCustom,
297
+ };
298
+
299
+ const cartridgeCase: CartridgeCaseDetailsState = {
300
+ caliber: cCaliber,
301
+ caliberIsCustom: cCaliberIsCustom,
302
+ brand: cBrand,
303
+ metal: cMetal,
304
+ metalIsCustom: cMetalIsCustom,
305
+ primerType: cPrimerType,
306
+ primerTypeIsCustom: cPrimerTypeIsCustom,
307
+ fpiShape: cFpiShape,
308
+ fpiShapeIsCustom: cFpiShapeIsCustom,
309
+ apertureShape: cApertureShape,
310
+ apertureShapeIsCustom: cApertureShapeIsCustom,
311
+ hasFpDrag: cHasFpDrag,
312
+ hasExtractorMarks: cHasExtractorMarks,
313
+ hasEjectorMarks: cHasEjectorMarks,
314
+ hasChamberMarks: cHasChamberMarks,
315
+ hasMagazineLipMarks: cHasMagazineLipMarks,
316
+ hasPrimerShear: cHasPrimerShear,
317
+ hasEjectionPortMarks: cHasEjectionPortMarks,
318
+ setCaliber: setCCaliber,
319
+ setCaliberIsCustom: setCCaliberIsCustom,
320
+ setBrand: setCBrand,
321
+ setMetal: setCMetal,
322
+ setMetalIsCustom: setCMetalIsCustom,
323
+ setPrimerType: setCPrimerType,
324
+ setPrimerTypeIsCustom: setCPrimerTypeIsCustom,
325
+ setFpiShape: setCFpiShape,
326
+ setFpiShapeIsCustom: setCFpiShapeIsCustom,
327
+ setApertureShape: setCApertureShape,
328
+ setApertureShapeIsCustom: setCApertureShapeIsCustom,
329
+ setHasFpDrag: setCHasFpDrag,
330
+ setHasExtractorMarks: setCHasExtractorMarks,
331
+ setHasEjectorMarks: setCHasEjectorMarks,
332
+ setHasChamberMarks: setCHasChamberMarks,
333
+ setHasMagazineLipMarks: setCHasMagazineLipMarks,
334
+ setHasPrimerShear: setCHasPrimerShear,
335
+ setHasEjectionPortMarks: setCHasEjectionPortMarks,
336
+ };
337
+
338
+ const shotshell: ShotshellDetailsState = {
339
+ gauge: sGauge,
340
+ gaugeIsCustom: sGaugeIsCustom,
341
+ shotSize: sShotSize,
342
+ metal: sMetal,
343
+ metalIsCustom: sMetalIsCustom,
344
+ brand: sBrand,
345
+ fpiShape: sFpiShape,
346
+ fpiShapeIsCustom: sFpiShapeIsCustom,
347
+ hasExtractorMarks: sHasExtractorMarks,
348
+ hasEjectorMarks: sHasEjectorMarks,
349
+ hasChamberMarks: sHasChamberMarks,
350
+ setGauge: setSGauge,
351
+ setGaugeIsCustom: setSGaugeIsCustom,
352
+ setShotSize: setSShotSize,
353
+ setMetal: setSMetal,
354
+ setMetalIsCustom: setSMetalIsCustom,
355
+ setBrand: setSBrand,
356
+ setFpiShape: setSFpiShape,
357
+ setFpiShapeIsCustom: setSFpiShapeIsCustom,
358
+ setHasExtractorMarks: setSHasExtractorMarks,
359
+ setHasEjectorMarks: setSHasEjectorMarks,
360
+ setHasChamberMarks: setSHasChamberMarks,
361
+ };
362
+
363
+ return {
364
+ bullet,
365
+ cartridgeCase,
366
+ shotshell,
367
+ isSaving,
368
+ setIsSaving,
369
+ buildSaveData,
370
+ };
371
+ };