@striae-org/striae 5.5.0 → 5.5.2
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.
- package/.env.example +9 -1
- package/app/components/actions/export-audit-pdf.ts +2 -3
- package/app/components/sidebar/notes/class-details/class-details-modal.tsx +3 -3
- package/app/components/sidebar/notes/notes-editor-form.tsx +244 -12
- package/app/components/sidebar/notes/notes-editor-modal.tsx +181 -2
- package/app/components/sidebar/notes/notes.module.css +77 -0
- package/app/components/user/user.module.css +1 -1
- package/app/routes/auth/login.example.tsx +17 -5
- package/functions/api/_shared/registration-allowlist.ts +38 -0
- package/functions/api/auth/can-register.ts +59 -0
- package/functions/api/user/[[path]].ts +34 -0
- package/members.emails.example +11 -0
- package/package.json +12 -12
- package/scripts/deploy-all.sh +2 -2
- package/scripts/deploy-members-emails.sh +102 -0
- package/scripts/deploy-pages-secrets.sh +13 -70
- package/scripts/deploy-primershear-emails.sh +7 -73
- package/scripts/update-markdown-versions.cjs +58 -1
- package/worker-configuration.d.ts +2 -1
- package/workers/audit-worker/package.json +13 -18
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/package.json +13 -18
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/package.json +13 -18
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/package.json +14 -19
- package/workers/pdf-worker/src/audit-trail-report.ts +28 -6
- package/workers/pdf-worker/src/report-types.ts +1 -0
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/package.json +13 -18
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
package/.env.example
CHANGED
|
@@ -141,4 +141,12 @@ BROWSER_API_TOKEN=your_cloudflare_browser_rendering_api_token_here
|
|
|
141
141
|
# Comma-separated list of email addresses that will receive the primershear PDF format.
|
|
142
142
|
# Leave empty to disable the feature. Never commit this value to source control.
|
|
143
143
|
# Example: PRIMERSHEAR_EMAILS=analyst@org.com,user2@org.com
|
|
144
|
-
PRIMERSHEAR_EMAILS=
|
|
144
|
+
PRIMERSHEAR_EMAILS=
|
|
145
|
+
|
|
146
|
+
# ================================
|
|
147
|
+
# REGISTRATION EMAIL ALLOWLIST CONFIGURATION
|
|
148
|
+
# ================================
|
|
149
|
+
# Comma-separated list of email addresses that may register an account.
|
|
150
|
+
# Leave empty to disable the feature. Never commit this value to source control.
|
|
151
|
+
# Example: REGISTRATION_EMAILS=analyst@org.com,user2@org.com
|
|
152
|
+
REGISTRATION_EMAILS=
|
|
@@ -287,9 +287,7 @@ export const exportAuditPDF = async ({
|
|
|
287
287
|
const exportRangeStartIso = isBundledArchivedCase
|
|
288
288
|
? normalizeIsoDate(sortedEntries[0]?.timestamp) || caseCreatedAtIso
|
|
289
289
|
: caseCreatedAtIso;
|
|
290
|
-
const exportRangeEndIso =
|
|
291
|
-
? normalizeIsoDate(sortedEntries[sortedEntries.length - 1]?.timestamp) || nowIso
|
|
292
|
-
: nowIso;
|
|
290
|
+
const exportRangeEndIso = normalizeIsoDate(sortedEntries[sortedEntries.length - 1]?.timestamp) || nowIso;
|
|
293
291
|
|
|
294
292
|
const chunks = chunkEntries(sortedEntries);
|
|
295
293
|
const totalChunks = chunks.length;
|
|
@@ -313,6 +311,7 @@ export const exportAuditPDF = async ({
|
|
|
313
311
|
userFirstName,
|
|
314
312
|
userLastName,
|
|
315
313
|
userBadgeId,
|
|
314
|
+
userTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
316
315
|
auditTrailReport: buildAuditTrailPayload(
|
|
317
316
|
caseNumber,
|
|
318
317
|
chunkEntriesForPart,
|
|
@@ -118,10 +118,10 @@ const ClassDetailsModalContent = ({
|
|
|
118
118
|
/>
|
|
119
119
|
)}
|
|
120
120
|
</div>
|
|
121
|
-
<div className={styles.modalButtons}>
|
|
121
|
+
<div className={`${styles.modalButtons} ${styles.classDetailsModalButtons}`}>
|
|
122
122
|
<button
|
|
123
123
|
onClick={handleSave}
|
|
124
|
-
className={styles.saveButton}
|
|
124
|
+
className={`${styles.saveButton} ${styles.classDetailsModalAction}`}
|
|
125
125
|
disabled={isSaving || isReadOnly}
|
|
126
126
|
aria-busy={isSaving}
|
|
127
127
|
>
|
|
@@ -129,7 +129,7 @@ const ClassDetailsModalContent = ({
|
|
|
129
129
|
</button>
|
|
130
130
|
<button
|
|
131
131
|
onClick={requestClose}
|
|
132
|
-
className={styles.cancelButton}
|
|
132
|
+
className={`${styles.cancelButton} ${styles.classDetailsModalAction}`}
|
|
133
133
|
disabled={isSaving}
|
|
134
134
|
>
|
|
135
135
|
Cancel
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect } from 'react';
|
|
1
|
+
import { useState, useEffect, useCallback, useLayoutEffect } from 'react';
|
|
2
2
|
import type { User } from 'firebase/auth';
|
|
3
3
|
import { ColorSelector } from '~/components/colors/colors';
|
|
4
4
|
import { AddlNotesModal } from './addl-notes-modal';
|
|
@@ -18,13 +18,71 @@ interface NotesEditorFormProps {
|
|
|
18
18
|
originalFileName?: string;
|
|
19
19
|
isUploading?: boolean;
|
|
20
20
|
showNotification?: (message: string, type: 'success' | 'error' | 'warning') => void;
|
|
21
|
+
onDirtyChange?: (isDirty: boolean) => void;
|
|
22
|
+
onRegisterSaveHandler?: (saveHandler: (() => Promise<boolean>) | null) => void;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
type SupportLevel = 'ID' | 'Exclusion' | 'Inconclusive';
|
|
24
26
|
type ClassType = 'Bullet' | 'Cartridge Case' | 'Shotshell' | 'Other';
|
|
25
27
|
type IndexType = 'number' | 'color';
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
interface NotesFormSnapshot {
|
|
30
|
+
leftCase: string;
|
|
31
|
+
rightCase: string;
|
|
32
|
+
leftItem: string;
|
|
33
|
+
rightItem: string;
|
|
34
|
+
caseFontColor: string;
|
|
35
|
+
classType: ClassType | '';
|
|
36
|
+
customClass: string;
|
|
37
|
+
classNote: string;
|
|
38
|
+
hasSubclass: boolean;
|
|
39
|
+
bulletData: BulletAnnotationData | undefined;
|
|
40
|
+
cartridgeCaseData: CartridgeCaseAnnotationData | undefined;
|
|
41
|
+
shotshellData: ShotshellAnnotationData | undefined;
|
|
42
|
+
indexType: IndexType;
|
|
43
|
+
indexNumber: string;
|
|
44
|
+
indexColor: string;
|
|
45
|
+
supportLevel: SupportLevel | '';
|
|
46
|
+
includeConfirmation: boolean;
|
|
47
|
+
additionalNotes: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const hasMeaningfulValue = (value: unknown): boolean => {
|
|
51
|
+
if (value === undefined || value === null) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
return value.some(hasMeaningfulValue);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (typeof value === 'object') {
|
|
60
|
+
return Object.values(value as Record<string, unknown>).some(hasMeaningfulValue);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return true;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const normalizeNestedAnnotationData = <T extends object>(data: T | undefined): T | undefined => {
|
|
67
|
+
if (data === undefined || data === null) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return hasMeaningfulValue(data) ? data : undefined;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const normalizeNotesSnapshot = (snapshot: NotesFormSnapshot): NotesFormSnapshot => ({
|
|
75
|
+
...snapshot,
|
|
76
|
+
bulletData: normalizeNestedAnnotationData(snapshot.bulletData),
|
|
77
|
+
cartridgeCaseData: normalizeNestedAnnotationData(snapshot.cartridgeCaseData),
|
|
78
|
+
shotshellData: normalizeNestedAnnotationData(snapshot.shotshellData),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const serializeNotesSnapshot = (snapshot: NotesFormSnapshot): string => JSON.stringify(normalizeNotesSnapshot(snapshot));
|
|
82
|
+
const DIRTY_CHECK_DEBOUNCE_MS = 180;
|
|
83
|
+
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
|
84
|
+
|
|
85
|
+
export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefresh, originalFileName, isUploading = false, showNotification: externalShowNotification, onDirtyChange, onRegisterSaveHandler }: NotesEditorFormProps) => {
|
|
28
86
|
// Loading/Saving Notes States
|
|
29
87
|
const [isLoading, setIsLoading] = useState(false);
|
|
30
88
|
const [loadError, setLoadError] = useState<string>();
|
|
@@ -64,13 +122,72 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
|
|
|
64
122
|
const [isClassOpen, setIsClassOpen] = useState(true);
|
|
65
123
|
const [isIndexOpen, setIsIndexOpen] = useState(true);
|
|
66
124
|
const [isSupportOpen, setIsSupportOpen] = useState(true);
|
|
125
|
+
const [savedSnapshot, setSavedSnapshot] = useState<string>('');
|
|
126
|
+
const [hasLoadedSnapshot, setHasLoadedSnapshot] = useState(false);
|
|
127
|
+
const [isDirty, setIsDirty] = useState(false);
|
|
67
128
|
const areInputsDisabled = isUploading || isConfirmedImage;
|
|
68
129
|
|
|
69
|
-
const notificationHandler = (message: string, type: 'success' | 'error' | 'warning' = 'success') => {
|
|
130
|
+
const notificationHandler = useCallback((message: string, type: 'success' | 'error' | 'warning' = 'success') => {
|
|
70
131
|
if (externalShowNotification) {
|
|
71
132
|
externalShowNotification(message, type);
|
|
72
133
|
}
|
|
73
|
-
};
|
|
134
|
+
}, [externalShowNotification]);
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (!hasLoadedSnapshot) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const timeoutId = window.setTimeout(() => {
|
|
142
|
+
const nextSnapshot = serializeNotesSnapshot({
|
|
143
|
+
leftCase,
|
|
144
|
+
rightCase,
|
|
145
|
+
leftItem,
|
|
146
|
+
rightItem,
|
|
147
|
+
caseFontColor,
|
|
148
|
+
classType,
|
|
149
|
+
customClass,
|
|
150
|
+
classNote,
|
|
151
|
+
hasSubclass,
|
|
152
|
+
bulletData,
|
|
153
|
+
cartridgeCaseData,
|
|
154
|
+
shotshellData,
|
|
155
|
+
indexType,
|
|
156
|
+
indexNumber,
|
|
157
|
+
indexColor,
|
|
158
|
+
supportLevel,
|
|
159
|
+
includeConfirmation,
|
|
160
|
+
additionalNotes,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
setIsDirty(nextSnapshot !== savedSnapshot);
|
|
164
|
+
}, DIRTY_CHECK_DEBOUNCE_MS);
|
|
165
|
+
|
|
166
|
+
return () => {
|
|
167
|
+
window.clearTimeout(timeoutId);
|
|
168
|
+
};
|
|
169
|
+
}, [
|
|
170
|
+
additionalNotes,
|
|
171
|
+
bulletData,
|
|
172
|
+
cartridgeCaseData,
|
|
173
|
+
caseFontColor,
|
|
174
|
+
classNote,
|
|
175
|
+
classType,
|
|
176
|
+
customClass,
|
|
177
|
+
hasLoadedSnapshot,
|
|
178
|
+
hasSubclass,
|
|
179
|
+
includeConfirmation,
|
|
180
|
+
indexColor,
|
|
181
|
+
indexNumber,
|
|
182
|
+
indexType,
|
|
183
|
+
leftCase,
|
|
184
|
+
leftItem,
|
|
185
|
+
rightCase,
|
|
186
|
+
rightItem,
|
|
187
|
+
savedSnapshot,
|
|
188
|
+
shotshellData,
|
|
189
|
+
supportLevel,
|
|
190
|
+
]);
|
|
74
191
|
|
|
75
192
|
useEffect(() => {
|
|
76
193
|
const loadExistingNotes = async () => {
|
|
@@ -79,6 +196,9 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
|
|
|
79
196
|
setIsLoading(true);
|
|
80
197
|
setLoadError(undefined);
|
|
81
198
|
setIsConfirmedImage(false);
|
|
199
|
+
setHasLoadedSnapshot(false);
|
|
200
|
+
setIsDirty(false);
|
|
201
|
+
onDirtyChange?.(false);
|
|
82
202
|
|
|
83
203
|
try {
|
|
84
204
|
const existingNotes = await getNotes(user, currentCase, imageId);
|
|
@@ -106,19 +226,66 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
|
|
|
106
226
|
setSupportLevel(existingNotes.supportLevel || '');
|
|
107
227
|
setIncludeConfirmation(existingNotes.includeConfirmation);
|
|
108
228
|
setAdditionalNotes(existingNotes.additionalNotes || '');
|
|
229
|
+
|
|
230
|
+
setSavedSnapshot(serializeNotesSnapshot({
|
|
231
|
+
leftCase: existingNotes.leftCase || '',
|
|
232
|
+
rightCase: existingNotes.rightCase || '',
|
|
233
|
+
leftItem: existingNotes.leftItem || '',
|
|
234
|
+
rightItem: existingNotes.rightItem || '',
|
|
235
|
+
caseFontColor: existingNotes.caseFontColor || '',
|
|
236
|
+
classType: existingNotes.classType || '',
|
|
237
|
+
customClass: existingNotes.customClass || '',
|
|
238
|
+
classNote: existingNotes.classNote || '',
|
|
239
|
+
hasSubclass: existingNotes.hasSubclass ?? false,
|
|
240
|
+
bulletData: existingNotes.bulletData,
|
|
241
|
+
cartridgeCaseData: existingNotes.cartridgeCaseData,
|
|
242
|
+
shotshellData: existingNotes.shotshellData,
|
|
243
|
+
indexType: existingNotes.indexType || 'color',
|
|
244
|
+
indexNumber: existingNotes.indexNumber || '',
|
|
245
|
+
indexColor: existingNotes.indexColor || '',
|
|
246
|
+
supportLevel: existingNotes.supportLevel || '',
|
|
247
|
+
includeConfirmation: existingNotes.includeConfirmation,
|
|
248
|
+
additionalNotes: existingNotes.additionalNotes || ''
|
|
249
|
+
}));
|
|
109
250
|
} else {
|
|
110
251
|
setIsConfirmedImage(false);
|
|
252
|
+
|
|
253
|
+
setSavedSnapshot(serializeNotesSnapshot({
|
|
254
|
+
leftCase: '',
|
|
255
|
+
rightCase: '',
|
|
256
|
+
leftItem: '',
|
|
257
|
+
rightItem: '',
|
|
258
|
+
caseFontColor: '',
|
|
259
|
+
classType: '',
|
|
260
|
+
customClass: '',
|
|
261
|
+
classNote: '',
|
|
262
|
+
hasSubclass: false,
|
|
263
|
+
bulletData: undefined,
|
|
264
|
+
cartridgeCaseData: undefined,
|
|
265
|
+
shotshellData: undefined,
|
|
266
|
+
indexType: 'color',
|
|
267
|
+
indexNumber: '',
|
|
268
|
+
indexColor: '',
|
|
269
|
+
supportLevel: '',
|
|
270
|
+
includeConfirmation: false,
|
|
271
|
+
additionalNotes: ''
|
|
272
|
+
}));
|
|
111
273
|
}
|
|
112
274
|
} catch (error) {
|
|
113
275
|
setLoadError('Failed to load existing notes');
|
|
114
276
|
console.error('Error loading notes:', error);
|
|
115
277
|
} finally {
|
|
116
278
|
setIsLoading(false);
|
|
279
|
+
setHasLoadedSnapshot(true);
|
|
117
280
|
}
|
|
118
281
|
};
|
|
119
282
|
|
|
120
283
|
loadExistingNotes();
|
|
121
|
-
}, [imageId, currentCase, user]);
|
|
284
|
+
}, [imageId, currentCase, onDirtyChange, user]);
|
|
285
|
+
|
|
286
|
+
useIsomorphicLayoutEffect(() => {
|
|
287
|
+
onDirtyChange?.(isDirty);
|
|
288
|
+
}, [isDirty, onDirtyChange]);
|
|
122
289
|
|
|
123
290
|
useEffect(() => {
|
|
124
291
|
if (useCurrentCaseLeft) {
|
|
@@ -129,11 +296,11 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
|
|
|
129
296
|
}
|
|
130
297
|
}, [useCurrentCaseLeft, useCurrentCaseRight, currentCase]);
|
|
131
298
|
|
|
132
|
-
const handleSave = async () => {
|
|
299
|
+
const handleSave = useCallback(async (): Promise<boolean> => {
|
|
133
300
|
|
|
134
301
|
if (!imageId) {
|
|
135
302
|
console.error('No image selected');
|
|
136
|
-
return;
|
|
303
|
+
return false;
|
|
137
304
|
}
|
|
138
305
|
|
|
139
306
|
let existingData: AnnotationData | null = null;
|
|
@@ -145,8 +312,12 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
|
|
|
145
312
|
if (existingData?.confirmationData) {
|
|
146
313
|
setIsConfirmedImage(true);
|
|
147
314
|
notificationHandler('This image is confirmed. Notes cannot be modified.', 'error');
|
|
148
|
-
return;
|
|
315
|
+
return false;
|
|
149
316
|
}
|
|
317
|
+
|
|
318
|
+
const normalizedBulletData = normalizeNestedAnnotationData(bulletData);
|
|
319
|
+
const normalizedCartridgeCaseData = normalizeNestedAnnotationData(cartridgeCaseData);
|
|
320
|
+
const normalizedShotshellData = normalizeNestedAnnotationData(shotshellData);
|
|
150
321
|
|
|
151
322
|
// Create updated annotation data, preserving box annotations and earliest timestamp
|
|
152
323
|
const now = new Date().toISOString();
|
|
@@ -163,9 +334,9 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
|
|
|
163
334
|
customClass: customClass,
|
|
164
335
|
classNote: classNote || undefined,
|
|
165
336
|
hasSubclass: hasSubclass,
|
|
166
|
-
bulletData:
|
|
167
|
-
cartridgeCaseData:
|
|
168
|
-
shotshellData:
|
|
337
|
+
bulletData: normalizedBulletData,
|
|
338
|
+
cartridgeCaseData: normalizedCartridgeCaseData,
|
|
339
|
+
shotshellData: normalizedShotshellData,
|
|
169
340
|
|
|
170
341
|
// Index Information
|
|
171
342
|
indexType: indexType,
|
|
@@ -207,11 +378,36 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
|
|
|
207
378
|
);
|
|
208
379
|
|
|
209
380
|
notificationHandler('Notes saved successfully.', 'success');
|
|
381
|
+
|
|
382
|
+
setSavedSnapshot(serializeNotesSnapshot({
|
|
383
|
+
leftCase,
|
|
384
|
+
rightCase,
|
|
385
|
+
leftItem,
|
|
386
|
+
rightItem,
|
|
387
|
+
caseFontColor,
|
|
388
|
+
classType,
|
|
389
|
+
customClass,
|
|
390
|
+
classNote,
|
|
391
|
+
hasSubclass,
|
|
392
|
+
bulletData,
|
|
393
|
+
cartridgeCaseData,
|
|
394
|
+
shotshellData,
|
|
395
|
+
indexType,
|
|
396
|
+
indexNumber,
|
|
397
|
+
indexColor,
|
|
398
|
+
supportLevel,
|
|
399
|
+
includeConfirmation,
|
|
400
|
+
additionalNotes,
|
|
401
|
+
}));
|
|
402
|
+
setIsDirty(false);
|
|
403
|
+
onDirtyChange?.(false);
|
|
210
404
|
|
|
211
405
|
// Refresh annotation data after saving notes
|
|
212
406
|
if (onAnnotationRefresh) {
|
|
213
407
|
onAnnotationRefresh();
|
|
214
408
|
}
|
|
409
|
+
|
|
410
|
+
return true;
|
|
215
411
|
} catch (error) {
|
|
216
412
|
console.error('Failed to save notes:', error);
|
|
217
413
|
const errorMessage = error instanceof Error ? error.message : '';
|
|
@@ -237,8 +433,44 @@ export const NotesEditorForm = ({ currentCase, user, imageId, onAnnotationRefres
|
|
|
237
433
|
} catch (auditError) {
|
|
238
434
|
console.error('Failed to log annotation edit audit:', auditError);
|
|
239
435
|
}
|
|
436
|
+
|
|
437
|
+
return false;
|
|
240
438
|
}
|
|
241
|
-
}
|
|
439
|
+
}, [
|
|
440
|
+
additionalNotes,
|
|
441
|
+
bulletData,
|
|
442
|
+
cartridgeCaseData,
|
|
443
|
+
caseFontColor,
|
|
444
|
+
classNote,
|
|
445
|
+
classType,
|
|
446
|
+
currentCase,
|
|
447
|
+
customClass,
|
|
448
|
+
hasSubclass,
|
|
449
|
+
imageId,
|
|
450
|
+
includeConfirmation,
|
|
451
|
+
indexColor,
|
|
452
|
+
indexNumber,
|
|
453
|
+
indexType,
|
|
454
|
+
leftCase,
|
|
455
|
+
leftItem,
|
|
456
|
+
notificationHandler,
|
|
457
|
+
onAnnotationRefresh,
|
|
458
|
+
onDirtyChange,
|
|
459
|
+
originalFileName,
|
|
460
|
+
rightCase,
|
|
461
|
+
rightItem,
|
|
462
|
+
shotshellData,
|
|
463
|
+
supportLevel,
|
|
464
|
+
user,
|
|
465
|
+
]);
|
|
466
|
+
|
|
467
|
+
useEffect(() => {
|
|
468
|
+
onRegisterSaveHandler?.(handleSave);
|
|
469
|
+
|
|
470
|
+
return () => {
|
|
471
|
+
onRegisterSaveHandler?.(null);
|
|
472
|
+
};
|
|
473
|
+
}, [handleSave, onRegisterSaveHandler]);
|
|
242
474
|
|
|
243
475
|
return (
|
|
244
476
|
<div className={`${styles.notesEditorForm} ${styles.editorLayout}`}>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { User } from 'firebase/auth';
|
|
2
|
+
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
2
3
|
import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
|
|
3
4
|
import { NotesEditorForm } from './notes-editor-form';
|
|
4
5
|
import styles from './notes.module.css';
|
|
@@ -26,21 +27,151 @@ export const NotesEditorModal = ({
|
|
|
26
27
|
isUploading = false,
|
|
27
28
|
showNotification,
|
|
28
29
|
}: NotesEditorModalProps) => {
|
|
30
|
+
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
31
|
+
const [isCloseAlertOpen, setIsCloseAlertOpen] = useState(false);
|
|
32
|
+
const [isSavingBeforeClose, setIsSavingBeforeClose] = useState(false);
|
|
33
|
+
const [saveHandler, setSaveHandler] = useState<(() => Promise<boolean>) | null>(null);
|
|
34
|
+
const closeAlertRef = useRef<HTMLDivElement | null>(null);
|
|
35
|
+
const saveAndCloseButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
36
|
+
const closeAlertTitleId = useId();
|
|
37
|
+
const closeAlertDescriptionId = useId();
|
|
38
|
+
|
|
39
|
+
const handleCloseAttempt = useCallback(() => {
|
|
40
|
+
if (hasUnsavedChanges) {
|
|
41
|
+
setIsCloseAlertOpen(true);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
onClose();
|
|
46
|
+
}, [hasUnsavedChanges, onClose]);
|
|
47
|
+
|
|
48
|
+
const handleDirtyChange = useCallback((isDirty: boolean) => {
|
|
49
|
+
setHasUnsavedChanges((previous) => (previous === isDirty ? previous : isDirty));
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const handleRegisterSaveHandler = useCallback((handler: (() => Promise<boolean>) | null) => {
|
|
53
|
+
setSaveHandler((previous) => (previous === handler ? previous : handler));
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
29
56
|
const {
|
|
30
57
|
overlayProps,
|
|
31
58
|
getCloseButtonProps,
|
|
32
59
|
} = useOverlayDismiss({
|
|
33
60
|
isOpen,
|
|
34
|
-
onClose,
|
|
61
|
+
onClose: handleCloseAttempt,
|
|
62
|
+
closeOnEscape: !isCloseAlertOpen,
|
|
35
63
|
});
|
|
36
64
|
|
|
65
|
+
const handleDiscardAndClose = () => {
|
|
66
|
+
setHasUnsavedChanges(false);
|
|
67
|
+
setIsCloseAlertOpen(false);
|
|
68
|
+
onClose();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const handleCancelClose = () => {
|
|
72
|
+
setIsCloseAlertOpen(false);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const canSaveBeforeClose = !!saveHandler && !isSavingBeforeClose;
|
|
76
|
+
|
|
77
|
+
const handleSaveBeforeClose = async () => {
|
|
78
|
+
if (!saveHandler) {
|
|
79
|
+
showNotification?.('Save is not ready yet. Please wait a moment and try again.', 'warning');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
setIsSavingBeforeClose(true);
|
|
84
|
+
let didSave = false;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
didSave = await saveHandler();
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.error('Failed to save notes before closing:', error);
|
|
90
|
+
showNotification?.('Failed to save notes. Please try again.', 'error');
|
|
91
|
+
didSave = false;
|
|
92
|
+
} finally {
|
|
93
|
+
setIsSavingBeforeClose(false);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!didSave) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
setHasUnsavedChanges(false);
|
|
101
|
+
setIsCloseAlertOpen(false);
|
|
102
|
+
onClose();
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (!isCloseAlertOpen) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const previouslyFocused = document.activeElement as HTMLElement | null;
|
|
111
|
+
saveAndCloseButtonRef.current?.focus();
|
|
112
|
+
|
|
113
|
+
const handleAlertKeyDown = (event: KeyboardEvent) => {
|
|
114
|
+
if (event.key === 'Escape') {
|
|
115
|
+
event.preventDefault();
|
|
116
|
+
setIsCloseAlertOpen(false);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (event.key !== 'Tab') {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const focusContainer = closeAlertRef.current;
|
|
125
|
+
if (!focusContainer) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const focusableElements = Array.from(
|
|
130
|
+
focusContainer.querySelectorAll<HTMLElement>(
|
|
131
|
+
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
132
|
+
)
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
if (focusableElements.length === 0) {
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const first = focusableElements[0];
|
|
141
|
+
const last = focusableElements[focusableElements.length - 1];
|
|
142
|
+
const active = document.activeElement as HTMLElement | null;
|
|
143
|
+
|
|
144
|
+
if (event.shiftKey && active === first) {
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
last.focus();
|
|
147
|
+
} else if (!event.shiftKey && active === last) {
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
first.focus();
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
document.addEventListener('keydown', handleAlertKeyDown);
|
|
154
|
+
|
|
155
|
+
return () => {
|
|
156
|
+
document.removeEventListener('keydown', handleAlertKeyDown);
|
|
157
|
+
previouslyFocused?.focus();
|
|
158
|
+
};
|
|
159
|
+
}, [isCloseAlertOpen]);
|
|
160
|
+
|
|
37
161
|
if (!isOpen) {
|
|
38
162
|
return null;
|
|
39
163
|
}
|
|
40
164
|
|
|
41
165
|
return (
|
|
42
166
|
<div className={styles.overlay} aria-label="Close image notes dialog" {...overlayProps}>
|
|
43
|
-
<div
|
|
167
|
+
<div
|
|
168
|
+
className={styles.editorModal}
|
|
169
|
+
role="dialog"
|
|
170
|
+
aria-modal={isCloseAlertOpen ? undefined : true}
|
|
171
|
+
aria-label="Image Notes"
|
|
172
|
+
aria-hidden={isCloseAlertOpen}
|
|
173
|
+
inert={isCloseAlertOpen}
|
|
174
|
+
>
|
|
44
175
|
<div className={styles.editorModalHeader}>
|
|
45
176
|
<h2 className={styles.editorModalTitle}>Image Notes</h2>
|
|
46
177
|
<button className={styles.editorModalCloseButton} {...getCloseButtonProps({ ariaLabel: 'Close image notes dialog' })}>
|
|
@@ -56,9 +187,57 @@ export const NotesEditorModal = ({
|
|
|
56
187
|
originalFileName={originalFileName}
|
|
57
188
|
isUploading={isUploading}
|
|
58
189
|
showNotification={showNotification}
|
|
190
|
+
onDirtyChange={handleDirtyChange}
|
|
191
|
+
onRegisterSaveHandler={handleRegisterSaveHandler}
|
|
59
192
|
/>
|
|
60
193
|
</div>
|
|
61
194
|
</div>
|
|
195
|
+
{isCloseAlertOpen && (
|
|
196
|
+
<div className={styles.modalOverlay}>
|
|
197
|
+
<div
|
|
198
|
+
className={`${styles.modal} ${styles.unsavedChangesModal}`}
|
|
199
|
+
ref={closeAlertRef}
|
|
200
|
+
role="alertdialog"
|
|
201
|
+
aria-modal="true"
|
|
202
|
+
aria-labelledby={closeAlertTitleId}
|
|
203
|
+
aria-describedby={closeAlertDescriptionId}
|
|
204
|
+
>
|
|
205
|
+
<h3 id={closeAlertTitleId} className={styles.modalTitle}>You have unsaved notes!</h3>
|
|
206
|
+
<p id={closeAlertDescriptionId} className={styles.unsavedChangesMessage}>
|
|
207
|
+
You have unsaved changes to notes and data. Save before closing?
|
|
208
|
+
</p>
|
|
209
|
+
<div className={styles.unsavedChangesActions}>
|
|
210
|
+
<div className={styles.unsavedChangesPrimaryRow}>
|
|
211
|
+
<button
|
|
212
|
+
ref={saveAndCloseButtonRef}
|
|
213
|
+
type="button"
|
|
214
|
+
onClick={handleSaveBeforeClose}
|
|
215
|
+
className={`${styles.saveButton} ${styles.unsavedChangesPrimaryAction}`}
|
|
216
|
+
disabled={!canSaveBeforeClose}
|
|
217
|
+
>
|
|
218
|
+
{isSavingBeforeClose ? 'Saving...' : 'Save and Close'}
|
|
219
|
+
</button>
|
|
220
|
+
<button
|
|
221
|
+
type="button"
|
|
222
|
+
onClick={handleDiscardAndClose}
|
|
223
|
+
className={`${styles.cancelButton} ${styles.unsavedChangesPrimaryAction}`}
|
|
224
|
+
disabled={isSavingBeforeClose}
|
|
225
|
+
>
|
|
226
|
+
Close Without Saving
|
|
227
|
+
</button>
|
|
228
|
+
</div>
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
onClick={handleCancelClose}
|
|
232
|
+
className={styles.secondaryButton}
|
|
233
|
+
disabled={isSavingBeforeClose}
|
|
234
|
+
>
|
|
235
|
+
Continue Editing
|
|
236
|
+
</button>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
)}
|
|
62
241
|
</div>
|
|
63
242
|
);
|
|
64
243
|
};
|