@striae-org/striae 4.2.1 → 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 (47) hide show
  1. package/app/components/navbar/case-modals/archive-case-modal.module.css +0 -76
  2. package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -8
  3. package/app/components/navbar/case-modals/case-modal-shared.module.css +94 -0
  4. package/app/components/navbar/case-modals/delete-case-modal.module.css +9 -0
  5. package/app/components/navbar/case-modals/delete-case-modal.tsx +79 -0
  6. package/app/components/navbar/case-modals/open-case-modal.module.css +2 -1
  7. package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -72
  8. package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -8
  9. package/app/components/sidebar/cases/case-sidebar.tsx +49 -3
  10. package/app/components/sidebar/cases/cases-modal.module.css +312 -10
  11. package/app/components/sidebar/cases/cases-modal.tsx +690 -110
  12. package/app/components/sidebar/cases/cases.module.css +23 -0
  13. package/app/components/sidebar/files/delete-files-modal.module.css +26 -0
  14. package/app/components/sidebar/files/delete-files-modal.tsx +94 -0
  15. package/app/components/sidebar/files/files-modal.module.css +285 -44
  16. package/app/components/sidebar/files/files-modal.tsx +452 -145
  17. package/app/components/sidebar/notes/class-details-fields.tsx +146 -0
  18. package/app/components/sidebar/notes/class-details-modal.tsx +147 -0
  19. package/app/components/sidebar/notes/class-details-sections.tsx +561 -0
  20. package/app/components/sidebar/notes/class-details-shared.ts +239 -0
  21. package/app/components/sidebar/notes/notes-editor-form.tsx +43 -5
  22. package/app/components/sidebar/notes/notes.module.css +236 -4
  23. package/app/components/sidebar/notes/use-class-details-state.ts +371 -0
  24. package/app/components/sidebar/sidebar-container.tsx +1 -0
  25. package/app/components/sidebar/sidebar.tsx +12 -1
  26. package/app/hooks/useCaseListPreferences.ts +99 -0
  27. package/app/hooks/useFileListPreferences.ts +106 -0
  28. package/app/routes/striae/striae.tsx +1 -0
  29. package/app/types/annotations.ts +48 -1
  30. package/app/utils/data/case-filters.ts +127 -0
  31. package/app/utils/data/confirmation-summary/summary-core.ts +18 -2
  32. package/app/utils/data/file-filters.ts +201 -0
  33. package/functions/api/image/[[path]].ts +4 -0
  34. package/package.json +3 -4
  35. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  36. package/workers/data-worker/wrangler.jsonc.example +1 -1
  37. package/workers/image-worker/wrangler.jsonc.example +1 -1
  38. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  39. package/workers/pdf-worker/src/formats/format-striae.ts +84 -118
  40. package/workers/pdf-worker/src/pdf-worker.example.ts +28 -10
  41. package/workers/pdf-worker/src/report-layout.ts +227 -0
  42. package/workers/pdf-worker/src/report-types.ts +20 -0
  43. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  44. package/workers/user-worker/wrangler.jsonc.example +1 -1
  45. package/wrangler.toml.example +1 -1
  46. package/workers/pdf-worker/src/assets/icon-256.png +0 -0
  47. /package/workers/pdf-worker/src/assets/{generated-assets.ts → generated-assets.example.ts} +0 -0
@@ -0,0 +1,561 @@
1
+ import { useState } from 'react';
2
+ import styles from './notes.module.css';
3
+ import {
4
+ BULLET_BARREL_TYPE_OPTIONS,
5
+ BULLET_CORE_METAL_OPTIONS,
6
+ BULLET_JACKET_METAL_OPTIONS,
7
+ BULLET_TYPE_OPTIONS,
8
+ CARTRIDGE_APERTURE_SHAPE_OPTIONS,
9
+ CARTRIDGE_FPI_SHAPE_OPTIONS,
10
+ CARTRIDGE_METAL_OPTIONS,
11
+ CARTRIDGE_PRIMER_TYPE_OPTIONS,
12
+ SHOTSHELL_BIRDSHOT_OPTIONS,
13
+ SHOTSHELL_BUCKSHOT_OPTIONS,
14
+ PISTOL_CALIBERS,
15
+ RIFLE_CALIBERS,
16
+ SHOTSHELL_GAUGES,
17
+ SHOTSHELL_STEEL_WATERFOWL_OPTIONS,
18
+ formatCalculatedDiameter,
19
+ } from './class-details-shared';
20
+ import { CheckboxField, SelectField, SelectWithCustomField, TextField } from './class-details-fields';
21
+ import type { BulletDetailsState, CartridgeCaseDetailsState, ShotshellDetailsState } from './use-class-details-state';
22
+
23
+ interface BulletSectionProps {
24
+ showHeader: boolean;
25
+ isReadOnly: boolean;
26
+ bullet: BulletDetailsState;
27
+ }
28
+
29
+ interface CartridgeSectionProps {
30
+ showHeader: boolean;
31
+ isReadOnly: boolean;
32
+ cartridgeCase: CartridgeCaseDetailsState;
33
+ }
34
+
35
+ interface ShotshellSectionProps {
36
+ showHeader: boolean;
37
+ isReadOnly: boolean;
38
+ shotshell: ShotshellDetailsState;
39
+ }
40
+
41
+ type SelectOption = {
42
+ value: string;
43
+ label?: string;
44
+ };
45
+
46
+ type SelectOptionGroup = {
47
+ groupLabel: string;
48
+ options: SelectOption[];
49
+ };
50
+
51
+ type FieldOption = SelectOption | SelectOptionGroup;
52
+
53
+ type ConfiguredField =
54
+ | {
55
+ key: string;
56
+ kind: 'text';
57
+ label: string;
58
+ value: string;
59
+ onChange: (value: string) => void;
60
+ placeholder: string;
61
+ fullWidth?: boolean;
62
+ type?: 'text' | 'number';
63
+ min?: number;
64
+ }
65
+ | {
66
+ key: string;
67
+ kind: 'select';
68
+ label: string;
69
+ value: string;
70
+ onChange: (value: string) => void;
71
+ placeholder: string;
72
+ options: FieldOption[];
73
+ fullWidth?: boolean;
74
+ }
75
+ | {
76
+ key: string;
77
+ kind: 'select-custom';
78
+ label: string;
79
+ value: string;
80
+ isCustom: boolean;
81
+ onChange: (value: string) => void;
82
+ onCustomChange: (value: boolean) => void;
83
+ placeholder: string;
84
+ customPlaceholder: string;
85
+ options: FieldOption[];
86
+ fullWidth?: boolean;
87
+ };
88
+
89
+ type CheckboxConfig = {
90
+ key: string;
91
+ label: string;
92
+ checked: boolean;
93
+ onChange: (value: boolean) => void;
94
+ };
95
+
96
+ const CALIBER_GROUPED_OPTIONS: FieldOption[] = [
97
+ {
98
+ groupLabel: 'Pistol',
99
+ options: PISTOL_CALIBERS.map((caliber) => ({ value: caliber })),
100
+ },
101
+ {
102
+ groupLabel: 'Rifle',
103
+ options: RIFLE_CALIBERS.map((caliber) => ({ value: caliber })),
104
+ },
105
+ ];
106
+
107
+ const isOptionGroup = (option: FieldOption): option is SelectOptionGroup => 'groupLabel' in option;
108
+
109
+ const renderOptions = (options: FieldOption[]) => options.map((option) => {
110
+ if (isOptionGroup(option)) {
111
+ return (
112
+ <optgroup key={option.groupLabel} label={option.groupLabel}>
113
+ {option.options.map((groupOption) => (
114
+ <option key={groupOption.value} value={groupOption.value}>
115
+ {groupOption.label || groupOption.value}
116
+ </option>
117
+ ))}
118
+ </optgroup>
119
+ );
120
+ }
121
+
122
+ return (
123
+ <option key={option.value} value={option.value}>
124
+ {option.label || option.value}
125
+ </option>
126
+ );
127
+ });
128
+
129
+ const renderConfiguredField = (field: ConfiguredField, isReadOnly: boolean) => {
130
+ if (field.kind === 'text') {
131
+ return (
132
+ <TextField
133
+ key={field.key}
134
+ label={field.label}
135
+ value={field.value}
136
+ onChange={field.onChange}
137
+ disabled={isReadOnly}
138
+ placeholder={field.placeholder}
139
+ fullWidth={field.fullWidth}
140
+ type={field.type}
141
+ min={field.min}
142
+ />
143
+ );
144
+ }
145
+
146
+ if (field.kind === 'select') {
147
+ return (
148
+ <SelectField
149
+ key={field.key}
150
+ label={field.label}
151
+ value={field.value}
152
+ onChange={field.onChange}
153
+ placeholder={field.placeholder}
154
+ disabled={isReadOnly}
155
+ fullWidth={field.fullWidth}
156
+ >
157
+ {renderOptions(field.options)}
158
+ </SelectField>
159
+ );
160
+ }
161
+
162
+ return (
163
+ <SelectWithCustomField
164
+ key={field.key}
165
+ label={field.label}
166
+ value={field.value}
167
+ isCustom={field.isCustom}
168
+ onChange={field.onChange}
169
+ onCustomChange={field.onCustomChange}
170
+ placeholder={field.placeholder}
171
+ customPlaceholder={field.customPlaceholder}
172
+ disabled={isReadOnly}
173
+ fullWidth={field.fullWidth}
174
+ >
175
+ {renderOptions(field.options)}
176
+ </SelectWithCustomField>
177
+ );
178
+ };
179
+
180
+ const renderCheckboxes = (items: CheckboxConfig[], isReadOnly: boolean) => items.map((item) => (
181
+ <CheckboxField
182
+ key={item.key}
183
+ label={item.label}
184
+ checked={item.checked}
185
+ onChange={item.onChange}
186
+ disabled={isReadOnly}
187
+ />
188
+ ));
189
+
190
+ export const BulletSection = ({
191
+ showHeader,
192
+ isReadOnly,
193
+ bullet,
194
+ }: BulletSectionProps) => {
195
+ const [showCalcExplanation, setShowCalcExplanation] = useState(false);
196
+ const bulletFields: ConfiguredField[] = [
197
+ {
198
+ key: 'caliber',
199
+ kind: 'select-custom',
200
+ label: 'Caliber',
201
+ value: bullet.caliber,
202
+ isCustom: bullet.caliberIsCustom,
203
+ onChange: bullet.setCaliber,
204
+ onCustomChange: bullet.setCaliberIsCustom,
205
+ placeholder: 'Select caliber...',
206
+ customPlaceholder: 'Enter caliber...',
207
+ options: CALIBER_GROUPED_OPTIONS,
208
+ },
209
+ {
210
+ key: 'mass',
211
+ kind: 'text',
212
+ label: 'Mass',
213
+ value: bullet.mass,
214
+ onChange: bullet.setMass,
215
+ placeholder: 'e.g. 158 gr',
216
+ },
217
+ {
218
+ key: 'diameter',
219
+ kind: 'text',
220
+ label: 'Diameter',
221
+ value: bullet.diameter,
222
+ onChange: bullet.setDiameter,
223
+ placeholder: 'e.g. 0.357 in',
224
+ },
225
+ {
226
+ key: 'lgNumber',
227
+ kind: 'text',
228
+ label: 'L/G Count',
229
+ value: bullet.lgNumber,
230
+ onChange: bullet.setLgNumber,
231
+ placeholder: 'e.g. 6',
232
+ type: 'number',
233
+ min: 0,
234
+ },
235
+ {
236
+ key: 'lgDirection',
237
+ kind: 'select',
238
+ label: 'L/G Direction',
239
+ value: bullet.lgDirection,
240
+ onChange: bullet.setLgDirection,
241
+ placeholder: 'Select direction...',
242
+ options: [{ value: 'Left' }, { value: 'Right' }],
243
+ },
244
+ {
245
+ key: 'jacketMetal',
246
+ kind: 'select-custom',
247
+ label: 'Jacket Metal',
248
+ value: bullet.jacketMetal,
249
+ isCustom: bullet.jacketMetalIsCustom,
250
+ onChange: bullet.setJacketMetal,
251
+ onCustomChange: bullet.setJacketMetalIsCustom,
252
+ placeholder: 'Select jacket metal...',
253
+ customPlaceholder: 'Enter jacket metal...',
254
+ options: BULLET_JACKET_METAL_OPTIONS.map((option) => ({ value: option })),
255
+ },
256
+ {
257
+ key: 'coreMetal',
258
+ kind: 'select-custom',
259
+ label: 'Core Metal',
260
+ value: bullet.coreMetal,
261
+ isCustom: bullet.coreMetalIsCustom,
262
+ onChange: bullet.setCoreMetal,
263
+ onCustomChange: bullet.setCoreMetalIsCustom,
264
+ placeholder: 'Select core metal...',
265
+ customPlaceholder: 'Enter core metal...',
266
+ options: BULLET_CORE_METAL_OPTIONS.map((option) => ({ value: option })),
267
+ },
268
+ {
269
+ key: 'bulletType',
270
+ kind: 'select-custom',
271
+ label: 'Bullet Type',
272
+ value: bullet.bulletType,
273
+ isCustom: bullet.bulletTypeIsCustom,
274
+ onChange: bullet.setBulletType,
275
+ onCustomChange: bullet.setBulletTypeIsCustom,
276
+ placeholder: 'Select bullet type...',
277
+ customPlaceholder: 'Enter bullet type...',
278
+ options: BULLET_TYPE_OPTIONS.map((option) => ({ value: option })),
279
+ fullWidth: true,
280
+ },
281
+ {
282
+ key: 'barrelType',
283
+ kind: 'select-custom',
284
+ label: 'Barrel Type',
285
+ value: bullet.barrelType,
286
+ isCustom: bullet.barrelTypeIsCustom,
287
+ onChange: bullet.setBarrelType,
288
+ onCustomChange: bullet.setBarrelTypeIsCustom,
289
+ placeholder: 'Select barrel type...',
290
+ customPlaceholder: 'Enter barrel type...',
291
+ options: BULLET_BARREL_TYPE_OPTIONS.map((option) => ({ value: option })),
292
+ fullWidth: true,
293
+ },
294
+ ];
295
+
296
+ return (
297
+ <div className={styles.classDetailsSection}>
298
+ {showHeader && <h6 className={styles.classDetailsSectionHeader}>Bullet</h6>}
299
+ <div className={styles.classDetailsFieldGrid}>
300
+ {bulletFields.map((field) => renderConfiguredField(field, isReadOnly))}
301
+ </div>
302
+ {bullet.lgCount > 0 && (
303
+ <div className={styles.lgWidthsSection}>
304
+ <h6 className={styles.classDetailsSectionHeader}>L / G Widths</h6>
305
+ <div className={styles.lgWidthsLayout}>
306
+ <div className={styles.lgWidthsColumn}>
307
+ {Array.from({ length: bullet.lgCount }, (_, index) => (
308
+ <TextField
309
+ key={`l-${index}`}
310
+ label={`L${index + 1}`}
311
+ value={bullet.lWidths[index] || ''}
312
+ onChange={(value) => bullet.updateLWidth(index, value)}
313
+ disabled={isReadOnly}
314
+ placeholder="e.g. 0.075"
315
+ />
316
+ ))}
317
+ </div>
318
+ <div className={styles.lgWidthsColumn}>
319
+ {Array.from({ length: bullet.lgCount }, (_, index) => (
320
+ <TextField
321
+ key={`g-${index}`}
322
+ label={`G${index + 1}`}
323
+ value={bullet.gWidths[index] || ''}
324
+ onChange={(value) => bullet.updateGWidth(index, value)}
325
+ disabled={isReadOnly}
326
+ placeholder="e.g. 0.111"
327
+ />
328
+ ))}
329
+ </div>
330
+ </div>
331
+ {bullet.calculatedDiameter !== null && (
332
+ <div className={styles.calculatedDiameterWrapper}>
333
+ <div className={styles.calculatedDiameterDisplay}>
334
+ <span className={styles.classDetailsLabel}>Calculated Diameter</span>
335
+ <span className={styles.calculatedDiameterValue}>{formatCalculatedDiameter(bullet.calculatedDiameter)}</span>
336
+ </div>
337
+ <button
338
+ type="button"
339
+ className={styles.calcExplanationToggle}
340
+ onClick={() => setShowCalcExplanation((prev) => !prev)}
341
+ aria-expanded={showCalcExplanation}
342
+ >
343
+ {showCalcExplanation ? 'Hide explanation' : 'How is this calculated?'}
344
+ </button>
345
+ {showCalcExplanation && (
346
+ <div className={styles.calcExplanationPanel}>
347
+ <p className={styles.calcExplanationFormula}>
348
+ diameter&nbsp;=&nbsp;(L&#772;&nbsp;+&nbsp;G&#772;)&nbsp;&times;&nbsp;n&nbsp;&divide;&nbsp;&pi;
349
+ </p>
350
+ <ul className={styles.calcExplanationList}>
351
+ <li><strong>L&#772;</strong> — average land width</li>
352
+ <li><strong>G&#772;</strong> — average groove width</li>
353
+ <li><strong>n</strong> — L/G count</li>
354
+ <li><strong>&pi;</strong> — 3.14159&hellip;</li>
355
+ </ul>
356
+ <p className={styles.calcExplanationNote}>
357
+ The bullet&rsquo;s circumference approximates the sum of all land and groove
358
+ widths. Dividing by &pi; converts circumference to diameter.
359
+ </p>
360
+ <p className={styles.calcExplanationExample}>
361
+ <strong>Example:</strong> 6 L/G with L&#772;&nbsp;=&nbsp;0.076&Prime; and
362
+ G&#772;&nbsp;=&nbsp;0.111&Prime;&nbsp;&rarr;&nbsp;(0.076&nbsp;+&nbsp;0.111)&nbsp;&times;&nbsp;6&nbsp;&divide;&nbsp;&pi;&nbsp;&asymp;&nbsp;0.357&Prime;
363
+ </p>
364
+ </div>
365
+ )}
366
+ </div>
367
+ )}
368
+ </div>
369
+ )}
370
+ </div>
371
+ );
372
+ };
373
+
374
+ export const CartridgeCaseSection = ({
375
+ showHeader,
376
+ isReadOnly,
377
+ cartridgeCase,
378
+ }: CartridgeSectionProps) => {
379
+ const cartridgeFields: ConfiguredField[] = [
380
+ {
381
+ key: 'caliber',
382
+ kind: 'select-custom',
383
+ label: 'Caliber',
384
+ value: cartridgeCase.caliber,
385
+ isCustom: cartridgeCase.caliberIsCustom,
386
+ onChange: cartridgeCase.setCaliber,
387
+ onCustomChange: cartridgeCase.setCaliberIsCustom,
388
+ placeholder: 'Select caliber...',
389
+ customPlaceholder: 'Enter caliber...',
390
+ options: CALIBER_GROUPED_OPTIONS,
391
+ },
392
+ {
393
+ key: 'brand',
394
+ kind: 'text',
395
+ label: 'Brand',
396
+ value: cartridgeCase.brand,
397
+ onChange: cartridgeCase.setBrand,
398
+ placeholder: 'e.g. Federal',
399
+ },
400
+ {
401
+ key: 'metal',
402
+ kind: 'select-custom',
403
+ label: 'Metal',
404
+ value: cartridgeCase.metal,
405
+ isCustom: cartridgeCase.metalIsCustom,
406
+ onChange: cartridgeCase.setMetal,
407
+ onCustomChange: cartridgeCase.setMetalIsCustom,
408
+ placeholder: 'Select metal...',
409
+ customPlaceholder: 'Enter metal...',
410
+ options: CARTRIDGE_METAL_OPTIONS.map((option) => ({ value: option })),
411
+ },
412
+ {
413
+ key: 'primerType',
414
+ kind: 'select-custom',
415
+ label: 'Primer Type',
416
+ value: cartridgeCase.primerType,
417
+ isCustom: cartridgeCase.primerTypeIsCustom,
418
+ onChange: cartridgeCase.setPrimerType,
419
+ onCustomChange: cartridgeCase.setPrimerTypeIsCustom,
420
+ placeholder: 'Select primer type...',
421
+ customPlaceholder: 'Enter primer type...',
422
+ options: CARTRIDGE_PRIMER_TYPE_OPTIONS.map((option) => ({ value: option })),
423
+ },
424
+ {
425
+ key: 'fpiShape',
426
+ kind: 'select-custom',
427
+ label: 'FPI Shape',
428
+ value: cartridgeCase.fpiShape,
429
+ isCustom: cartridgeCase.fpiShapeIsCustom,
430
+ onChange: cartridgeCase.setFpiShape,
431
+ onCustomChange: cartridgeCase.setFpiShapeIsCustom,
432
+ placeholder: 'Select FPI shape...',
433
+ customPlaceholder: 'Enter FPI shape...',
434
+ options: CARTRIDGE_FPI_SHAPE_OPTIONS.map((option) => ({ value: option })),
435
+ },
436
+ {
437
+ key: 'apertureShape',
438
+ kind: 'select-custom',
439
+ label: 'Aperture Shape',
440
+ value: cartridgeCase.apertureShape,
441
+ isCustom: cartridgeCase.apertureShapeIsCustom,
442
+ onChange: cartridgeCase.setApertureShape,
443
+ onCustomChange: cartridgeCase.setApertureShapeIsCustom,
444
+ placeholder: 'Select aperture shape...',
445
+ customPlaceholder: 'Enter aperture shape...',
446
+ options: CARTRIDGE_APERTURE_SHAPE_OPTIONS.map((option) => ({ value: option })),
447
+ },
448
+ ];
449
+
450
+ return (
451
+ <div className={styles.classDetailsSection}>
452
+ {showHeader && <h6 className={styles.classDetailsSectionHeader}>Cartridge Case</h6>}
453
+ <div className={styles.classDetailsFieldGrid}>
454
+ {cartridgeFields.map((field) => renderConfiguredField(field, isReadOnly))}
455
+ </div>
456
+ <div className={styles.classDetailsCheckboxGroup}>
457
+ {renderCheckboxes([
458
+ { key: 'fpDrag', label: 'FP Drag', checked: cartridgeCase.hasFpDrag, onChange: cartridgeCase.setHasFpDrag },
459
+ { key: 'extractor', label: 'Extractor Marks', checked: cartridgeCase.hasExtractorMarks, onChange: cartridgeCase.setHasExtractorMarks },
460
+ { key: 'ejector', label: 'Ejector Marks', checked: cartridgeCase.hasEjectorMarks, onChange: cartridgeCase.setHasEjectorMarks },
461
+ { key: 'chamber', label: 'Chamber Marks', checked: cartridgeCase.hasChamberMarks, onChange: cartridgeCase.setHasChamberMarks },
462
+ { key: 'magazineLip', label: 'Magazine Lip Marks', checked: cartridgeCase.hasMagazineLipMarks, onChange: cartridgeCase.setHasMagazineLipMarks },
463
+ { key: 'primerShear', label: 'Primer Shear', checked: cartridgeCase.hasPrimerShear, onChange: cartridgeCase.setHasPrimerShear },
464
+ { key: 'ejectionPort', label: 'Ejection Port Marks', checked: cartridgeCase.hasEjectionPortMarks, onChange: cartridgeCase.setHasEjectionPortMarks },
465
+ ], isReadOnly)}
466
+ </div>
467
+ </div>
468
+ );
469
+ };
470
+
471
+ export const ShotshellSection = ({
472
+ showHeader,
473
+ isReadOnly,
474
+ shotshell,
475
+ }: ShotshellSectionProps) => {
476
+ const shotshellFields: ConfiguredField[] = [
477
+ {
478
+ key: 'gauge',
479
+ kind: 'select-custom',
480
+ label: 'Gauge',
481
+ value: shotshell.gauge,
482
+ isCustom: shotshell.gaugeIsCustom,
483
+ onChange: shotshell.setGauge,
484
+ onCustomChange: shotshell.setGaugeIsCustom,
485
+ placeholder: 'Select gauge...',
486
+ customPlaceholder: 'Enter gauge...',
487
+ options: SHOTSHELL_GAUGES.map((option) => ({ value: option })),
488
+ },
489
+ {
490
+ key: 'shotSize',
491
+ kind: 'select',
492
+ label: 'Shot Size',
493
+ value: shotshell.shotSize,
494
+ onChange: shotshell.setShotSize,
495
+ placeholder: 'Select shot size...',
496
+ options: [
497
+ {
498
+ groupLabel: 'Birdshot',
499
+ options: SHOTSHELL_BIRDSHOT_OPTIONS.map((option) => ({ value: option })),
500
+ },
501
+ {
502
+ groupLabel: 'Steel/Waterfowl Shot',
503
+ options: SHOTSHELL_STEEL_WATERFOWL_OPTIONS.map((option) => ({ value: option })),
504
+ },
505
+ {
506
+ groupLabel: 'Buckshot',
507
+ options: SHOTSHELL_BUCKSHOT_OPTIONS.map((option) => ({ value: option })),
508
+ },
509
+ ],
510
+ },
511
+ {
512
+ key: 'metal',
513
+ kind: 'select-custom',
514
+ label: 'Metal',
515
+ value: shotshell.metal,
516
+ isCustom: shotshell.metalIsCustom,
517
+ onChange: shotshell.setMetal,
518
+ onCustomChange: shotshell.setMetalIsCustom,
519
+ placeholder: 'Select metal...',
520
+ customPlaceholder: 'Enter metal...',
521
+ options: CARTRIDGE_METAL_OPTIONS.map((option) => ({ value: option })),
522
+ },
523
+ {
524
+ key: 'brand',
525
+ kind: 'text',
526
+ label: 'Brand',
527
+ value: shotshell.brand,
528
+ onChange: shotshell.setBrand,
529
+ placeholder: 'e.g. Winchester',
530
+ },
531
+ {
532
+ key: 'fpiShape',
533
+ kind: 'select-custom',
534
+ label: 'FPI Shape',
535
+ value: shotshell.fpiShape,
536
+ isCustom: shotshell.fpiShapeIsCustom,
537
+ onChange: shotshell.setFpiShape,
538
+ onCustomChange: shotshell.setFpiShapeIsCustom,
539
+ placeholder: 'Select FPI shape...',
540
+ customPlaceholder: 'Enter FPI shape...',
541
+ options: CARTRIDGE_FPI_SHAPE_OPTIONS.map((option) => ({ value: option })),
542
+ fullWidth: true,
543
+ },
544
+ ];
545
+
546
+ return (
547
+ <div className={styles.classDetailsSection}>
548
+ {showHeader && <h6 className={styles.classDetailsSectionHeader}>Shotshell</h6>}
549
+ <div className={styles.classDetailsFieldGrid}>
550
+ {shotshellFields.map((field) => renderConfiguredField(field, isReadOnly))}
551
+ </div>
552
+ <div className={styles.classDetailsCheckboxGroup}>
553
+ {renderCheckboxes([
554
+ { key: 'extractor', label: 'Extractor Marks', checked: shotshell.hasExtractorMarks, onChange: shotshell.setHasExtractorMarks },
555
+ { key: 'ejector', label: 'Ejector Marks', checked: shotshell.hasEjectorMarks, onChange: shotshell.setHasEjectorMarks },
556
+ { key: 'chamber', label: 'Chamber Marks', checked: shotshell.hasChamberMarks, onChange: shotshell.setHasChamberMarks },
557
+ ], isReadOnly)}
558
+ </div>
559
+ </div>
560
+ );
561
+ };