@striae-org/striae 4.1.0 → 4.2.1

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 (124) hide show
  1. package/.env.example +8 -0
  2. package/LICENSE +1 -1
  3. package/app/components/actions/case-export/core-export.ts +14 -8
  4. package/app/components/actions/case-export/data-processing.ts +1 -0
  5. package/app/components/actions/case-export/download-handlers.ts +7 -0
  6. package/app/components/actions/case-export/metadata-helpers.ts +2 -1
  7. package/app/components/actions/case-import/confirmation-import.ts +12 -2
  8. package/app/components/actions/case-import/orchestrator.ts +78 -32
  9. package/app/components/actions/case-import/storage-operations.ts +97 -8
  10. package/app/components/actions/case-import/zip-processing.ts +159 -86
  11. package/app/components/actions/case-manage.ts +463 -8
  12. package/app/components/actions/confirm-export.ts +9 -2
  13. package/app/components/actions/image-manage.ts +77 -44
  14. package/app/components/audit/user-audit-viewer.tsx +19 -8
  15. package/app/components/audit/user-audit.module.css +21 -0
  16. package/app/components/audit/viewer/audit-entries-list.tsx +12 -2
  17. package/app/components/audit/viewer/audit-filters-panel.tsx +1 -0
  18. package/app/components/audit/viewer/audit-viewer-utils.ts +2 -0
  19. package/app/components/audit/viewer/use-audit-viewer-data.ts +24 -1
  20. package/app/components/audit/viewer/use-audit-viewer-export.ts +1 -1
  21. package/app/components/canvas/box-annotations/box-annotations.module.css +22 -18
  22. package/app/components/canvas/box-annotations/box-annotations.tsx +15 -0
  23. package/app/components/canvas/canvas.module.css +64 -54
  24. package/app/components/canvas/canvas.tsx +14 -16
  25. package/app/components/canvas/confirmation/confirmation.module.css +1 -0
  26. package/app/components/canvas/confirmation/confirmation.tsx +12 -14
  27. package/app/components/colors/colors.module.css +4 -3
  28. package/app/components/navbar/case-modals/archive-case-modal.module.css +110 -0
  29. package/app/components/navbar/case-modals/archive-case-modal.tsx +129 -0
  30. package/app/components/navbar/case-modals/open-case-modal.module.css +81 -0
  31. package/app/components/navbar/case-modals/open-case-modal.tsx +120 -0
  32. package/app/components/navbar/case-modals/rename-case-modal.module.css +81 -0
  33. package/app/components/navbar/case-modals/rename-case-modal.tsx +107 -0
  34. package/app/components/navbar/navbar.module.css +447 -0
  35. package/app/components/navbar/navbar.tsx +402 -0
  36. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +1 -0
  37. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +15 -16
  38. package/app/components/sidebar/case-export/case-export.module.css +1 -0
  39. package/app/components/sidebar/case-export/case-export.tsx +8 -46
  40. package/app/components/sidebar/case-import/case-import.module.css +23 -0
  41. package/app/components/sidebar/case-import/case-import.tsx +64 -16
  42. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +20 -1
  43. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +15 -0
  44. package/app/components/sidebar/cases/case-sidebar.tsx +68 -588
  45. package/app/components/sidebar/cases/cases-modal.module.css +1 -0
  46. package/app/components/sidebar/cases/cases-modal.tsx +82 -43
  47. package/app/components/sidebar/cases/cases.module.css +82 -21
  48. package/app/components/sidebar/files/files-modal.module.css +1 -0
  49. package/app/components/sidebar/files/files-modal.tsx +49 -52
  50. package/app/components/sidebar/notes/addl-notes-modal.tsx +82 -0
  51. package/app/components/sidebar/notes/{notes-sidebar.tsx → notes-editor-form.tsx} +187 -138
  52. package/app/components/sidebar/notes/notes-editor-modal.module.css +49 -0
  53. package/app/components/sidebar/notes/notes-editor-modal.tsx +64 -0
  54. package/app/components/sidebar/notes/notes.module.css +170 -1
  55. package/app/components/sidebar/sidebar-container.tsx +16 -28
  56. package/app/components/sidebar/sidebar.module.css +5 -69
  57. package/app/components/sidebar/sidebar.tsx +27 -125
  58. package/app/components/sidebar/upload/image-upload-zone.module.css +13 -13
  59. package/app/components/user/inactivity-warning.module.css +1 -0
  60. package/app/components/user/inactivity-warning.tsx +15 -2
  61. package/app/components/user/manage-profile.tsx +23 -10
  62. package/app/{tailwind.css → global.css} +1 -3
  63. package/app/hooks/useOverlayDismiss.ts +54 -4
  64. package/app/root.tsx +1 -1
  65. package/app/routes/auth/login.tsx +785 -774
  66. package/app/routes/striae/striae.module.css +10 -3
  67. package/app/routes/striae/striae.tsx +475 -30
  68. package/app/services/audit/audit.service.ts +173 -27
  69. package/app/services/audit/builders/audit-event-builders-case-file.ts +43 -0
  70. package/app/services/audit/builders/audit-event-builders-workflow.ts +2 -0
  71. package/app/services/audit/builders/index.ts +1 -0
  72. package/app/types/audit.ts +4 -1
  73. package/app/types/case.ts +29 -0
  74. package/app/types/import.ts +3 -0
  75. package/app/utils/data/confirmation-summary/summary-core.ts +279 -0
  76. package/app/utils/data/data-operations.ts +17 -861
  77. package/app/utils/data/index.ts +11 -1
  78. package/app/utils/data/operations/batch-operations.ts +113 -0
  79. package/app/utils/data/operations/case-operations.ts +168 -0
  80. package/app/utils/data/operations/confirmation-summary-operations.ts +301 -0
  81. package/app/utils/data/operations/file-annotation-operations.ts +196 -0
  82. package/app/utils/data/operations/index.ts +7 -0
  83. package/app/utils/data/operations/signing-operations.ts +225 -0
  84. package/app/utils/data/operations/types.ts +42 -0
  85. package/app/utils/data/operations/validation-operations.ts +48 -0
  86. package/app/utils/data/permissions.ts +16 -1
  87. package/app/utils/forensics/audit-export-signature.ts +5 -1
  88. package/app/utils/forensics/confirmation-signature.ts +3 -0
  89. package/app/utils/forensics/export-verification.ts +426 -22
  90. package/functions/api/_shared/firebase-auth.ts +2 -7
  91. package/functions/api/image/[[path]].ts +20 -23
  92. package/functions/api/pdf/[[path]].ts +27 -8
  93. package/package.json +7 -12
  94. package/scripts/deploy-primershear-emails.sh +2 -1
  95. package/worker-configuration.d.ts +3 -3
  96. package/workers/audit-worker/package.json +1 -1
  97. package/workers/audit-worker/worker-configuration.d.ts +7448 -11323
  98. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  99. package/workers/data-worker/package.json +1 -1
  100. package/workers/data-worker/worker-configuration.d.ts +7448 -11323
  101. package/workers/data-worker/wrangler.jsonc.example +1 -1
  102. package/workers/image-worker/package.json +1 -1
  103. package/workers/image-worker/src/image-worker.example.ts +16 -5
  104. package/workers/image-worker/worker-configuration.d.ts +7447 -11322
  105. package/workers/image-worker/wrangler.jsonc.example +1 -1
  106. package/workers/keys-worker/package.json +1 -1
  107. package/workers/keys-worker/worker-configuration.d.ts +7447 -11322
  108. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  109. package/workers/pdf-worker/package.json +1 -1
  110. package/workers/pdf-worker/src/formats/format-striae.ts +9 -14
  111. package/workers/pdf-worker/src/pdf-worker.example.ts +37 -58
  112. package/workers/pdf-worker/src/report-types.ts +3 -3
  113. package/workers/pdf-worker/worker-configuration.d.ts +7448 -11323
  114. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  115. package/workers/user-worker/package.json +1 -1
  116. package/workers/user-worker/src/user-worker.example.ts +17 -0
  117. package/workers/user-worker/worker-configuration.d.ts +7448 -11323
  118. package/workers/user-worker/wrangler.jsonc.example +1 -1
  119. package/wrangler.toml.example +1 -1
  120. package/NOTICE +0 -13
  121. package/app/components/sidebar/notes/notes-modal.tsx +0 -53
  122. package/postcss.config.js +0 -6
  123. package/public/.well-known/keybase.txt +0 -56
  124. package/tailwind.config.ts +0 -22
@@ -12,12 +12,21 @@
12
12
  }
13
13
 
14
14
  .imageContainer {
15
- position: relative;
15
+ flex: 1;
16
+ min-width: 0;
16
17
  display: flex;
17
18
  justify-content: center;
18
19
  align-items: center;
20
+ overflow: visible;
21
+ }
22
+
23
+ /* Tight wrapper sized to the rendered image — all overlays position relative to this */
24
+ .imageWrapper {
25
+ position: relative;
26
+ display: inline-block;
27
+ line-height: 0;
19
28
  max-width: 100%;
20
- max-height: 100%;
29
+ max-height: 80vh;
21
30
  }
22
31
 
23
32
  .toolbarWrapper {
@@ -34,7 +43,7 @@
34
43
  top: 1rem;
35
44
  z-index: 15;
36
45
  color: #e0e0e0;
37
- font-family: 'Inter', sans-serif;
46
+ font-family: "Inter", sans-serif;
38
47
  font-size: 1rem;
39
48
  font-weight: 500;
40
49
  pointer-events: none;
@@ -47,7 +56,7 @@
47
56
 
48
57
  .confirmationIncluded {
49
58
  font-size: 0.8rem;
50
- color: #FFDE21;
59
+ color: #ffde21;
51
60
  }
52
61
 
53
62
  .confirmationConfirmed {
@@ -103,31 +112,15 @@
103
112
  box-shadow: 0 2px 6px rgba(0, 123, 255, 0.4);
104
113
  }
105
114
 
106
- /* Company Display */
107
- .companyDisplay {
108
- position: absolute;
109
- right: 2rem;
110
- top: 1rem;
111
- z-index: 15;
112
- color: #e0e0e0;
113
- font-family: 'Inter', sans-serif;
114
- font-size: 1.5rem;
115
- font-weight: 500;
116
- pointer-events: none;
117
- text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
118
- white-space: nowrap;
119
- overflow: hidden;
120
- text-overflow: ellipsis;
121
- max-width: calc(100vw - 16rem);
122
- }
123
-
124
115
  .image {
116
+ display: block;
125
117
  max-width: 100%;
126
118
  max-height: 80vh;
127
119
  object-fit: contain;
128
120
  }
129
121
 
130
- .placeholder, .loading {
122
+ .placeholder,
123
+ .loading {
131
124
  color: #e0e0e0;
132
125
  font-size: 1.1rem;
133
126
  text-align: center;
@@ -155,7 +148,7 @@
155
148
  .leftAnnotation,
156
149
  .rightAnnotation {
157
150
  position: absolute;
158
- padding: 0.75rem 1rem;
151
+ padding: 1rem 1.4rem;
159
152
  background: rgba(0, 0, 0, 0.7);
160
153
  border-radius: 6px;
161
154
  backdrop-filter: blur(4px);
@@ -177,7 +170,7 @@
177
170
  position: absolute;
178
171
  bottom: 1rem;
179
172
  left: 1rem;
180
- padding: 0.75rem 1rem;
173
+ padding: 1rem 1.4rem;
181
174
  background: rgba(0, 0, 0, 0.7);
182
175
  border-radius: 6px;
183
176
  backdrop-filter: blur(4px);
@@ -186,7 +179,7 @@
186
179
  }
187
180
 
188
181
  .caseText {
189
- font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
182
+ font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
190
183
  font-size: 1.1rem;
191
184
  font-weight: 700;
192
185
  text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
@@ -197,7 +190,7 @@
197
190
  /* Class Characteristics Display */
198
191
  .classCharacteristics {
199
192
  position: absolute;
200
- top: -3rem;
193
+ bottom: calc(100% + 0.5rem);
201
194
  left: 50%;
202
195
  transform: translateX(-50%);
203
196
  z-index: 15;
@@ -205,14 +198,14 @@
205
198
  }
206
199
 
207
200
  .classText {
208
- padding: 0.75rem 1.5rem;
201
+ padding: 1rem 2rem;
209
202
  background: rgba(0, 0, 0, 0.8);
210
203
  color: #ffffff;
211
204
  border-radius: 8px;
212
205
  backdrop-filter: blur(6px);
213
206
  border: 2px solid rgba(255, 255, 255, 0.2);
214
207
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
215
- font-family: 'Inter', sans-serif;
208
+ font-family: "Inter", sans-serif;
216
209
  font-size: 1.1rem;
217
210
  font-weight: 600;
218
211
  text-align: center;
@@ -237,7 +230,7 @@
237
230
  backdrop-filter: blur(6px);
238
231
  border: 2px solid rgba(255, 255, 255, 0.2);
239
232
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
240
- font-family: 'Inter', sans-serif;
233
+ font-family: "Inter", sans-serif;
241
234
  font-size: 1.1rem;
242
235
  font-weight: 700;
243
236
  text-align: center;
@@ -249,8 +242,8 @@
249
242
  /* Subclass Warning Display */
250
243
  .subclassWarning {
251
244
  position: absolute;
252
- bottom: 1rem;
253
- right: 2rem;
245
+ bottom: 1rem;
246
+ right: 2rem;
254
247
  z-index: 20;
255
248
  pointer-events: none;
256
249
  }
@@ -263,7 +256,7 @@
263
256
  backdrop-filter: blur(6px);
264
257
  border: 2px solid rgba(255, 255, 255, 0.3);
265
258
  box-shadow: 0 4px 16px rgba(220, 53, 69, 0.4);
266
- font-family: 'Inter', sans-serif;
259
+ font-family: "Inter", sans-serif;
267
260
  font-size: 1rem;
268
261
  font-weight: 700;
269
262
  text-align: center;
@@ -282,33 +275,50 @@
282
275
  /* Image and Notes Container */
283
276
  .imageAndNotesContainer {
284
277
  display: flex;
285
- flex-direction: column;
286
- align-items: center;
287
- gap: 1rem;
288
- max-width: 100%;
289
- max-height: 100%;
278
+ flex-direction: row;
279
+ align-items: stretch;
280
+ align-self: stretch;
281
+ width: 100%;
290
282
  }
291
283
 
292
- /* Additional Notes Display */
293
- .additionalNotesContainer {
284
+ /* Notes Panel - fixed-width right-side column */
285
+ .notesPanel {
286
+ width: 260px;
287
+ flex-shrink: 0;
288
+ /* Opt out of stretch so the explicit height takes effect, stopping the panel
289
+ before the Subclass badge (bottom:1rem, ~2.5rem tall = 3.5rem from canvas bottom).
290
+ calc(100% - 4rem) lands ~4rem from the canvas bottom, clearing the badge. */
291
+ align-self: flex-start;
292
+ height: calc(100% - 4rem);
294
293
  display: flex;
295
- justify-content: center;
296
- width: 100%;
297
- max-width: 600px;
294
+ flex-direction: column;
295
+ background: rgba(20, 20, 20, 0.55);
296
+ border-left: 1px solid rgba(255, 255, 255, 0.1);
297
+ overflow: hidden;
298
+ }
299
+
300
+ .notesPanelHeader {
301
+ padding: 0.625rem 1rem;
302
+ color: #adb5bd;
303
+ font-size: 0.75rem;
304
+ font-weight: 600;
305
+ text-transform: uppercase;
306
+ letter-spacing: 0.5px;
307
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
308
+ flex-shrink: 0;
309
+ font-family: "Inter", sans-serif;
298
310
  }
299
311
 
300
312
  .additionalNotesBox {
301
- background: #ffffff;
302
- color: #000000;
303
- padding: 1rem 1.5rem;
304
- border-radius: 8px;
305
- border: 2px solid #d0d0d0;
306
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
307
- font-family: 'Inter', sans-serif;
308
- font-size: 0.95rem;
309
- line-height: 1.4;
313
+ flex: 1;
314
+ overflow-y: auto;
315
+ background: transparent;
316
+ color: #e0e0e0;
317
+ padding: 1rem;
318
+ font-family: "Inter", sans-serif;
319
+ font-size: 0.875rem;
320
+ line-height: 1.5;
310
321
  text-align: left;
311
322
  white-space: pre-wrap;
312
323
  word-wrap: break-word;
313
- max-width: 100%;
314
- }
324
+ }
@@ -19,6 +19,7 @@ interface CanvasProps {
19
19
  isBoxAnnotationMode?: boolean;
20
20
  boxAnnotationColor?: string;
21
21
  isReadOnly?: boolean;
22
+ isArchivedCase?: boolean;
22
23
  // Confirmation data for storing case-level confirmations
23
24
  caseNumber: string; // Required for audit logging
24
25
  currentImageId?: string;
@@ -32,7 +33,7 @@ type ImageLoadError = {
32
33
  export const Canvas = ({
33
34
  imageUrl,
34
35
  filename,
35
- company,
36
+ company,
36
37
  badgeId,
37
38
  firstName,
38
39
  error,
@@ -42,6 +43,7 @@ export const Canvas = ({
42
43
  isBoxAnnotationMode = false,
43
44
  boxAnnotationColor = '#FF0000',
44
45
  isReadOnly = false,
46
+ isArchivedCase = false,
45
47
  caseNumber,
46
48
  currentImageId
47
49
  }: CanvasProps) => {
@@ -276,7 +278,7 @@ export const Canvas = ({
276
278
  <div className={styles.confirmationIncluded}>
277
279
  {isReadOnly ? 'Confirmation Requested' : 'Confirmation Field Included'}
278
280
  </div>
279
- {isReadOnly && (
281
+ {isReadOnly && !isArchivedCase && (
280
282
  <button
281
283
  className={styles.confirmButton}
282
284
  onClick={() => setIsConfirmationModalOpen(true)}
@@ -291,13 +293,6 @@ export const Canvas = ({
291
293
  </div>
292
294
  )}
293
295
 
294
- {/* Company Display - Upper Right */}
295
- {company && (
296
- <div className={styles.companyDisplay}>
297
- {isReadOnly ? 'CASE REVIEW ONLY' : company}
298
- </div>
299
- )}
300
-
301
296
  {(loadError || error) ? (
302
297
  <p className={styles.error}>{getErrorMessage()}</p>
303
298
  ) : isLoading ? (
@@ -305,6 +300,7 @@ export const Canvas = ({
305
300
  ) : imageUrl && imageUrl !== '/clear.jpg' ? (
306
301
  <div className={styles.imageAndNotesContainer}>
307
302
  <div className={styles.imageContainer}>
303
+ <div className={styles.imageWrapper}>
308
304
  {/* Class Characteristics - Above Image */}
309
305
  {activeAnnotations?.has('class') && annotationData && (annotationData.customClass || annotationData.classType) && (
310
306
  <div className={styles.classCharacteristics}>
@@ -333,7 +329,7 @@ export const Canvas = ({
333
329
  draggable={false}
334
330
  />
335
331
 
336
- {/* Box Annotations Component - Show when box tool is active for visibility */}
332
+ {/* Box Annotations Component - contained within imageWrapper */}
337
333
  {activeAnnotations?.has('box') && (
338
334
  <BoxAnnotations
339
335
  imageRef={imageRef}
@@ -350,7 +346,7 @@ export const Canvas = ({
350
346
  />
351
347
  )}
352
348
 
353
- {/* Annotations Overlay */}
349
+ {/* Annotations Overlay - contained within imageWrapper */}
354
350
  {activeAnnotations?.has('number') && annotationData && (
355
351
  <div className={styles.annotationsOverlay}>
356
352
  {/* Left side case and item numbers */}
@@ -385,7 +381,7 @@ export const Canvas = ({
385
381
  </div>
386
382
  )}
387
383
 
388
- {/* Index Number Overlay */}
384
+ {/* Index Number Overlay - contained within imageWrapper */}
389
385
  {activeAnnotations?.has('index') && annotationData?.indexType === 'number' && annotationData?.indexNumber && (
390
386
  <div className={styles.annotationsOverlay}>
391
387
  <div
@@ -402,15 +398,17 @@ export const Canvas = ({
402
398
  </div>
403
399
  </div>
404
400
  )}
405
- </div>
401
+ </div>{/* end imageWrapper */}
402
+ </div>{/* end imageContainer */}
406
403
 
407
- {/* Additional Notes - Below Image */}
404
+ {/* Additional Notes - Right Panel */}
408
405
  {activeAnnotations?.has('notes') && annotationData?.additionalNotes && (
409
- <div className={styles.additionalNotesContainer}>
406
+ <aside className={styles.notesPanel} aria-label="Additional notes">
407
+ <div className={styles.notesPanelHeader}>Notes</div>
410
408
  <div className={styles.additionalNotesBox}>
411
409
  {annotationData.additionalNotes}
412
410
  </div>
413
- </div>
411
+ </aside>
414
412
  )}
415
413
  </div>
416
414
  ) : (
@@ -11,6 +11,7 @@
11
11
  }
12
12
 
13
13
  .modal {
14
+ position: relative;
14
15
  background: var(--backgroundLight);
15
16
  border-radius: var(--spaceXS);
16
17
  width: 90%;
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useContext } from 'react';
1
+ import { useState, useEffect, useContext, useRef } from 'react';
2
2
  import { type ConfirmationData } from '~/types/annotations';
3
3
  import { AuthContext } from '~/contexts/auth.context';
4
4
  import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
@@ -33,6 +33,7 @@ export const ConfirmationModal = ({ isOpen, onClose, onConfirm, company, default
33
33
  const [badgeId, setBadgeId] = useState('');
34
34
  const [error, setError] = useState('');
35
35
  const [isConfirming, setIsConfirming] = useState(false);
36
+ const wasOpenRef = useRef(false);
36
37
 
37
38
  const fullName = user?.displayName || user?.email || 'Unknown User';
38
39
  const userEmail = user?.email || 'No email available';
@@ -41,8 +42,9 @@ export const ConfirmationModal = ({ isOpen, onClose, onConfirm, company, default
41
42
  const confirmationId = generateConfirmationId();
42
43
 
43
44
  const {
44
- handleOverlayMouseDown,
45
- handleOverlayKeyDown
45
+ requestClose,
46
+ overlayProps,
47
+ getCloseButtonProps
46
48
  } = useOverlayDismiss({
47
49
  isOpen,
48
50
  onClose
@@ -53,7 +55,10 @@ export const ConfirmationModal = ({ isOpen, onClose, onConfirm, company, default
53
55
 
54
56
  // Reset form when modal opens
55
57
  useEffect(() => {
56
- if (isOpen) {
58
+ const justOpened = isOpen && !wasOpenRef.current;
59
+ wasOpenRef.current = isOpen;
60
+
61
+ if (justOpened) {
57
62
  if (existingConfirmation) {
58
63
  setBadgeId(existingConfirmation.badgeId);
59
64
  } else {
@@ -100,22 +105,15 @@ export const ConfirmationModal = ({ isOpen, onClose, onConfirm, company, default
100
105
  return (
101
106
  <div
102
107
  className={styles.overlay}
103
- onMouseDown={handleOverlayMouseDown}
104
- onKeyDown={handleOverlayKeyDown}
105
- role="button"
106
- tabIndex={0}
107
108
  aria-label="Close confirmation dialog"
109
+ {...overlayProps}
108
110
  >
109
111
  <div className={styles.modal}>
110
112
  <div className={styles.header}>
111
113
  <h2 className={styles.title}>
112
114
  {hasExistingConfirmation ? 'Confirmation Details' : 'Confirm Identification'}
113
115
  </h2>
114
- <button
115
- className={styles.closeButton}
116
- onClick={onClose}
117
- aria-label="Close modal"
118
- >
116
+ <button {...getCloseButtonProps({ ariaLabel: 'Close confirmation dialog' })}>
119
117
  ×
120
118
  </button>
121
119
  </div>
@@ -186,7 +184,7 @@ export const ConfirmationModal = ({ isOpen, onClose, onConfirm, company, default
186
184
  <div className={styles.footer}>
187
185
  <button
188
186
  className={styles.cancelButton}
189
- onClick={onClose}
187
+ onClick={requestClose}
190
188
  disabled={isConfirming}
191
189
  >
192
190
  {hasExistingConfirmation ? 'Close' : 'Cancel'}
@@ -2,6 +2,7 @@
2
2
  display: flex;
3
3
  flex-direction: column;
4
4
  gap: 0.75rem;
5
+ width: fit-content;
5
6
  }
6
7
 
7
8
  .colorHeader {
@@ -26,7 +27,7 @@
26
27
  }
27
28
 
28
29
  .colorWheel {
29
- width: 100%;
30
+ width: 180px;
30
31
  height: 40px;
31
32
  padding: 0;
32
33
  border: 2px solid #ced4da;
@@ -55,5 +56,5 @@
55
56
 
56
57
  .colorSwatch.selected {
57
58
  border-color: #0d6efd;
58
- box-shadow: 0 0 0 2px rgba(13,110,253,.25);
59
- }
59
+ box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.25);
60
+ }
@@ -0,0 +1,110 @@
1
+ .overlay {
2
+ position: fixed;
3
+ inset: 0;
4
+ background: rgba(0, 0, 0, 0.45);
5
+ display: flex;
6
+ align-items: center;
7
+ justify-content: center;
8
+ z-index: 120;
9
+ }
10
+
11
+ .modal {
12
+ position: relative;
13
+ width: min(560px, calc(100vw - 2rem));
14
+ background: #ffffff;
15
+ border-radius: 12px;
16
+ border: 1px solid #d9e0e7;
17
+ box-shadow: 0 14px 36px rgba(0, 0, 0, 0.2);
18
+ padding: 1.1rem;
19
+ }
20
+
21
+ .title {
22
+ margin: 0;
23
+ color: #212529;
24
+ font-size: 1.02rem;
25
+ }
26
+
27
+ .subtitle {
28
+ margin: 0.4rem 0 0.9rem;
29
+ color: #6c757d;
30
+ font-size: 0.85rem;
31
+ }
32
+
33
+ .warningPanel {
34
+ border: 1px solid color-mix(in lab, #dc3545 25%, transparent);
35
+ background: color-mix(in lab, #dc3545 7%, #ffffff);
36
+ border-radius: 10px;
37
+ padding: 0.75rem;
38
+ margin-bottom: 0.8rem;
39
+ }
40
+
41
+ .warningPanel p {
42
+ margin: 0;
43
+ color: #3f2a2e;
44
+ font-size: 0.86rem;
45
+ line-height: 1.35;
46
+ }
47
+
48
+ .warningPanel p + p {
49
+ margin-top: 0.45rem;
50
+ }
51
+
52
+ .reasonLabel {
53
+ display: block;
54
+ margin-bottom: 0.35rem;
55
+ color: #495057;
56
+ font-size: 0.8rem;
57
+ font-weight: 600;
58
+ }
59
+
60
+ .reasonInput {
61
+ width: 100%;
62
+ box-sizing: border-box;
63
+ border: 1px solid #cdd5dd;
64
+ border-radius: 8px;
65
+ padding: 0.6rem 0.75rem;
66
+ font-size: 0.9rem;
67
+ font-family: inherit;
68
+ resize: vertical;
69
+ }
70
+
71
+ .reasonInput:focus {
72
+ outline: none;
73
+ border-color: #1f6feb;
74
+ box-shadow: 0 0 0 2px rgba(31, 111, 235, 0.2);
75
+ }
76
+
77
+ .actions {
78
+ display: flex;
79
+ justify-content: flex-end;
80
+ gap: 0.65rem;
81
+ margin-top: 1rem;
82
+ }
83
+
84
+ .cancelButton,
85
+ .confirmButton {
86
+ border: 1px solid transparent;
87
+ border-radius: 8px;
88
+ padding: 0.55rem 0.9rem;
89
+ font-size: 0.86rem;
90
+ font-weight: 500;
91
+ cursor: pointer;
92
+ }
93
+
94
+ .cancelButton {
95
+ background: #f3f4f6;
96
+ color: #3c4651;
97
+ border-color: #d6dce2;
98
+ }
99
+
100
+ .confirmButton {
101
+ background: #dc3545;
102
+ color: #ffffff;
103
+ border-color: #c82333;
104
+ }
105
+
106
+ .cancelButton:disabled,
107
+ .confirmButton:disabled {
108
+ cursor: not-allowed;
109
+ opacity: 0.6;
110
+ }
@@ -0,0 +1,129 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
3
+ import styles from './archive-case-modal.module.css';
4
+
5
+ interface ArchiveCaseModalProps {
6
+ isOpen: boolean;
7
+ currentCase: string;
8
+ isSubmitting?: boolean;
9
+ onClose: () => void;
10
+ onSubmit: (archiveReason: string) => Promise<void>;
11
+ }
12
+
13
+ export const ArchiveCaseModal = ({
14
+ isOpen,
15
+ currentCase,
16
+ isSubmitting = false,
17
+ onClose,
18
+ onSubmit,
19
+ }: ArchiveCaseModalProps) => {
20
+ const [archiveReason, setArchiveReason] = useState('');
21
+ const reasonRef = useRef<HTMLTextAreaElement>(null);
22
+ const isCloseBlocked = isSubmitting;
23
+
24
+ const handleClose = () => {
25
+ if (isSubmitting) {
26
+ return;
27
+ }
28
+
29
+ setArchiveReason('');
30
+ onClose();
31
+ };
32
+
33
+ const {
34
+ requestClose,
35
+ overlayProps,
36
+ getCloseButtonProps,
37
+ } = useOverlayDismiss({
38
+ isOpen,
39
+ onClose: handleClose,
40
+ canDismiss: !isCloseBlocked,
41
+ });
42
+
43
+ useEffect(() => {
44
+ if (!isOpen) {
45
+ return;
46
+ }
47
+
48
+ const focusId = window.requestAnimationFrame(() => {
49
+ reasonRef.current?.focus();
50
+ });
51
+
52
+ return () => {
53
+ window.cancelAnimationFrame(focusId);
54
+ };
55
+ }, [isOpen]);
56
+
57
+ if (!isOpen) {
58
+ return null;
59
+ }
60
+
61
+ const handleSubmit = async () => {
62
+ await onSubmit(archiveReason.trim());
63
+ setArchiveReason('');
64
+ };
65
+
66
+ return (
67
+ <div
68
+ className={styles.overlay}
69
+ aria-label="Close archive case dialog"
70
+ {...overlayProps}
71
+ >
72
+ <div className={styles.modal} role="dialog" aria-modal="true" aria-label="Archive Case">
73
+ <button {...getCloseButtonProps({ ariaLabel: 'Close archive case dialog' })}>
74
+ ×
75
+ </button>
76
+ <h3 className={styles.title}>Archive Case</h3>
77
+ <p className={styles.subtitle}>Case: {currentCase}</p>
78
+
79
+ <div className={styles.warningPanel}>
80
+ <p>
81
+ Archiving a case permanently renders it read-only.
82
+ </p>
83
+ <p>
84
+ The archive will be in JSON format and include all images.
85
+ </p>
86
+ <p>
87
+ The full audit trail is packaged with Striae&apos;s current public key and forensic signatures.
88
+ </p>
89
+ <p>
90
+ You can import the archived package back into Striae for future review.
91
+ </p>
92
+ </div>
93
+
94
+ <label htmlFor="archiveReason" className={styles.reasonLabel}>Archive reason (recommended)</label>
95
+ <textarea
96
+ id="archiveReason"
97
+ ref={reasonRef}
98
+ value={archiveReason}
99
+ onChange={(event) => setArchiveReason(event.target.value)}
100
+ className={styles.reasonInput}
101
+ placeholder="Optional chain-of-custody note"
102
+ disabled={isSubmitting}
103
+ rows={3}
104
+ />
105
+
106
+ <div className={styles.actions}>
107
+ <button
108
+ type="button"
109
+ className={styles.cancelButton}
110
+ onClick={requestClose}
111
+ disabled={isCloseBlocked}
112
+ >
113
+ Cancel
114
+ </button>
115
+ <button
116
+ type="button"
117
+ className={styles.confirmButton}
118
+ onClick={() => {
119
+ void handleSubmit();
120
+ }}
121
+ disabled={isSubmitting}
122
+ >
123
+ {isSubmitting ? 'Archiving...' : 'Confirm Archive'}
124
+ </button>
125
+ </div>
126
+ </div>
127
+ </div>
128
+ );
129
+ };