@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.
- package/app/components/navbar/case-modals/archive-case-modal.module.css +0 -76
- package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -8
- package/app/components/navbar/case-modals/case-modal-shared.module.css +94 -0
- package/app/components/navbar/case-modals/delete-case-modal.module.css +9 -0
- package/app/components/navbar/case-modals/delete-case-modal.tsx +79 -0
- package/app/components/navbar/case-modals/open-case-modal.module.css +2 -1
- package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -72
- package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -8
- package/app/components/sidebar/cases/case-sidebar.tsx +49 -3
- package/app/components/sidebar/cases/cases-modal.module.css +312 -10
- package/app/components/sidebar/cases/cases-modal.tsx +690 -110
- package/app/components/sidebar/cases/cases.module.css +23 -0
- package/app/components/sidebar/files/delete-files-modal.module.css +26 -0
- package/app/components/sidebar/files/delete-files-modal.tsx +94 -0
- package/app/components/sidebar/files/files-modal.module.css +285 -44
- package/app/components/sidebar/files/files-modal.tsx +452 -145
- package/app/components/sidebar/notes/class-details-fields.tsx +146 -0
- package/app/components/sidebar/notes/class-details-modal.tsx +147 -0
- package/app/components/sidebar/notes/class-details-sections.tsx +561 -0
- package/app/components/sidebar/notes/class-details-shared.ts +239 -0
- package/app/components/sidebar/notes/notes-editor-form.tsx +43 -5
- package/app/components/sidebar/notes/notes.module.css +236 -4
- package/app/components/sidebar/notes/use-class-details-state.ts +371 -0
- package/app/components/sidebar/sidebar-container.tsx +1 -0
- package/app/components/sidebar/sidebar.tsx +12 -1
- package/app/hooks/useCaseListPreferences.ts +99 -0
- package/app/hooks/useFileListPreferences.ts +106 -0
- package/app/routes/striae/striae.tsx +1 -0
- package/app/types/annotations.ts +48 -1
- package/app/utils/data/case-filters.ts +127 -0
- package/app/utils/data/confirmation-summary/summary-core.ts +18 -2
- package/app/utils/data/file-filters.ts +201 -0
- package/functions/api/image/[[path]].ts +4 -0
- package/package.json +3 -4
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/src/formats/format-striae.ts +84 -118
- package/workers/pdf-worker/src/pdf-worker.example.ts +28 -10
- package/workers/pdf-worker/src/report-layout.ts +227 -0
- package/workers/pdf-worker/src/report-types.ts +20 -0
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
- package/workers/pdf-worker/src/assets/icon-256.png +0 -0
- /package/workers/pdf-worker/src/assets/{generated-assets.ts → generated-assets.example.ts} +0 -0
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import type React from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { useContext, useEffect, useMemo, useState } from 'react';
|
|
3
3
|
import { AuthContext } from '~/contexts/auth.context';
|
|
4
4
|
import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
|
|
5
|
+
import {
|
|
6
|
+
useFileListPreferences,
|
|
7
|
+
DEFAULT_FILES_MODAL_PREFERENCES,
|
|
8
|
+
} from '~/hooks/useFileListPreferences';
|
|
9
|
+
import {
|
|
10
|
+
type FilesModalSortBy,
|
|
11
|
+
type FilesModalConfirmationFilter,
|
|
12
|
+
type FilesModalClassTypeFilter,
|
|
13
|
+
getFilesForModal,
|
|
14
|
+
} from '~/utils/data/file-filters';
|
|
5
15
|
import { deleteFile } from '~/components/actions/image-manage';
|
|
6
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
ensureCaseConfirmationSummary,
|
|
18
|
+
type FileConfirmationSummary,
|
|
19
|
+
} from '~/utils/data';
|
|
7
20
|
import { type FileData } from '~/types';
|
|
21
|
+
import { DeleteFilesModal } from './delete-files-modal';
|
|
8
22
|
import styles from './files-modal.module.css';
|
|
9
23
|
|
|
10
24
|
interface FilesModalProps {
|
|
@@ -19,14 +33,48 @@ interface FilesModalProps {
|
|
|
19
33
|
confirmationSaveVersion?: number;
|
|
20
34
|
}
|
|
21
35
|
|
|
36
|
+
interface ActionNotice {
|
|
37
|
+
type: 'success' | 'warning' | 'error';
|
|
38
|
+
message: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
22
41
|
const FILES_PER_PAGE = 10;
|
|
23
42
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
43
|
+
const CLEAR_SELECTION_FILE: FileData = {
|
|
44
|
+
id: 'clear',
|
|
45
|
+
originalFilename: '/clear.jpg',
|
|
46
|
+
uploadedAt: '',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const DEFAULT_CONFIRMATION_SUMMARY: FileConfirmationSummary = {
|
|
50
|
+
includeConfirmation: false,
|
|
51
|
+
isConfirmed: false,
|
|
52
|
+
updatedAt: '',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function formatDate(dateString: string): string {
|
|
56
|
+
const parsed = Date.parse(dateString);
|
|
57
|
+
if (Number.isNaN(parsed)) {
|
|
58
|
+
return 'Unknown';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return new Date(parsed).toLocaleDateString();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getClassTypeLabel(classType?: FileConfirmationSummary['classType']): string {
|
|
65
|
+
if (!classType) {
|
|
66
|
+
return 'Unset';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return classType;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getConfirmationLabel(summary: FileConfirmationSummary): string {
|
|
73
|
+
if (!summary.includeConfirmation) {
|
|
74
|
+
return 'None Requested';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return summary.isConfirmed ? 'Confirmed' : 'Pending';
|
|
30
78
|
}
|
|
31
79
|
|
|
32
80
|
export const FilesModal = ({
|
|
@@ -38,28 +86,82 @@ export const FilesModal = ({
|
|
|
38
86
|
setFiles,
|
|
39
87
|
isReadOnly = false,
|
|
40
88
|
selectedFileId,
|
|
41
|
-
confirmationSaveVersion = 0
|
|
89
|
+
confirmationSaveVersion = 0,
|
|
42
90
|
}: FilesModalProps) => {
|
|
43
91
|
const { user } = useContext(AuthContext);
|
|
44
|
-
const [error, setError] = useState<string | null>(null);
|
|
45
92
|
const [currentPage, setCurrentPage] = useState(0);
|
|
46
|
-
const [
|
|
47
|
-
const [
|
|
93
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
94
|
+
const [openSelectedFileId, setOpenSelectedFileId] = useState<string | null>(selectedFileId || null);
|
|
95
|
+
const [deleteSelectedFileIds, setDeleteSelectedFileIds] = useState<Set<string>>(new Set());
|
|
96
|
+
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
|
97
|
+
const [isDeletingSelected, setIsDeletingSelected] = useState(false);
|
|
98
|
+
const [actionNotice, setActionNotice] = useState<ActionNotice | null>(null);
|
|
99
|
+
const [fileConfirmationStatus, setFileConfirmationStatus] = useState<Record<string, FileConfirmationSummary>>({});
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!actionNotice) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const timer = window.setTimeout(() => setActionNotice(null), 5000);
|
|
107
|
+
return () => window.clearTimeout(timer);
|
|
108
|
+
}, [actionNotice]);
|
|
109
|
+
const {
|
|
110
|
+
preferences,
|
|
111
|
+
setSortBy,
|
|
112
|
+
setConfirmationFilter,
|
|
113
|
+
setClassTypeFilter,
|
|
114
|
+
resetPreferences,
|
|
115
|
+
} = useFileListPreferences();
|
|
48
116
|
const {
|
|
49
117
|
requestClose,
|
|
50
118
|
overlayProps,
|
|
51
|
-
getCloseButtonProps
|
|
119
|
+
getCloseButtonProps,
|
|
52
120
|
} = useOverlayDismiss({
|
|
53
121
|
isOpen,
|
|
54
|
-
onClose
|
|
122
|
+
onClose,
|
|
55
123
|
});
|
|
56
124
|
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
125
|
+
const hasCustomPreferences =
|
|
126
|
+
preferences.sortBy !== DEFAULT_FILES_MODAL_PREFERENCES.sortBy ||
|
|
127
|
+
preferences.confirmationFilter !== DEFAULT_FILES_MODAL_PREFERENCES.confirmationFilter ||
|
|
128
|
+
preferences.classTypeFilter !== DEFAULT_FILES_MODAL_PREFERENCES.classTypeFilter;
|
|
129
|
+
|
|
130
|
+
const existingFileIdSet = useMemo(
|
|
131
|
+
() => new Set(files.map((file) => file.id)),
|
|
132
|
+
[files]
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const effectiveDeleteSelectedFileIds = useMemo(
|
|
136
|
+
() => new Set(Array.from(deleteSelectedFileIds).filter((fileId) => existingFileIdSet.has(fileId))),
|
|
137
|
+
[deleteSelectedFileIds, existingFileIdSet]
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const effectiveOpenSelectedFileId = useMemo(() => {
|
|
141
|
+
if (openSelectedFileId && existingFileIdSet.has(openSelectedFileId)) {
|
|
142
|
+
return openSelectedFileId;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (selectedFileId && existingFileIdSet.has(selectedFileId)) {
|
|
146
|
+
return selectedFileId;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return null;
|
|
150
|
+
}, [openSelectedFileId, selectedFileId, existingFileIdSet]);
|
|
151
|
+
|
|
152
|
+
const visibleFiles = useMemo(
|
|
153
|
+
() => getFilesForModal(files, preferences, fileConfirmationStatus, searchQuery),
|
|
154
|
+
[files, preferences, fileConfirmationStatus, searchQuery]
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const totalPages = Math.max(1, Math.ceil(visibleFiles.length / FILES_PER_PAGE));
|
|
158
|
+
const effectiveCurrentPage = Math.min(currentPage, totalPages - 1);
|
|
159
|
+
|
|
160
|
+
const paginatedFiles = visibleFiles.slice(
|
|
161
|
+
effectiveCurrentPage * FILES_PER_PAGE,
|
|
162
|
+
(effectiveCurrentPage + 1) * FILES_PER_PAGE
|
|
163
|
+
);
|
|
61
164
|
|
|
62
|
-
// Hydrate confirmation status from shared summary document.
|
|
63
165
|
useEffect(() => {
|
|
64
166
|
let isCancelled = false;
|
|
65
167
|
|
|
@@ -83,175 +185,380 @@ export const FilesModal = ({
|
|
|
83
185
|
setFileConfirmationStatus(caseSummary.filesById);
|
|
84
186
|
};
|
|
85
187
|
|
|
86
|
-
fetchConfirmationStatuses();
|
|
188
|
+
void fetchConfirmationStatuses();
|
|
87
189
|
|
|
88
190
|
return () => {
|
|
89
191
|
isCancelled = true;
|
|
90
192
|
};
|
|
91
193
|
}, [isOpen, currentCase, files, user, confirmationSaveVersion]);
|
|
92
194
|
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
195
|
+
const toggleDeleteSelection = (fileId: string) => {
|
|
196
|
+
setDeleteSelectedFileIds((previous) => {
|
|
197
|
+
const next = new Set(previous);
|
|
198
|
+
if (next.has(fileId)) {
|
|
199
|
+
next.delete(fileId);
|
|
200
|
+
} else {
|
|
201
|
+
next.add(fileId);
|
|
202
|
+
}
|
|
203
|
+
return next;
|
|
204
|
+
});
|
|
96
205
|
};
|
|
97
206
|
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
207
|
+
const selectAllVisibleFiles = () => {
|
|
208
|
+
setDeleteSelectedFileIds((previous) => {
|
|
209
|
+
const next = new Set(previous);
|
|
210
|
+
visibleFiles.forEach((file) => {
|
|
211
|
+
next.add(file.id);
|
|
212
|
+
});
|
|
213
|
+
return next;
|
|
214
|
+
});
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const clearDeleteSelection = () => {
|
|
218
|
+
setDeleteSelectedFileIds(new Set());
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const handleOpenSelectedFile = () => {
|
|
222
|
+
if (!openSelectedFileId) {
|
|
103
223
|
return;
|
|
104
224
|
}
|
|
105
|
-
|
|
106
|
-
|
|
225
|
+
|
|
226
|
+
const targetFile = files.find((file) => file.id === openSelectedFileId);
|
|
227
|
+
if (!targetFile) {
|
|
228
|
+
setActionNotice({
|
|
229
|
+
type: 'error',
|
|
230
|
+
message: 'Selected file is no longer available.',
|
|
231
|
+
});
|
|
107
232
|
return;
|
|
108
233
|
}
|
|
109
234
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
235
|
+
onFileSelect?.(targetFile);
|
|
236
|
+
requestClose();
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const handleDeleteSelectedFiles = async () => {
|
|
240
|
+
if (!user || !currentCase || isReadOnly || deleteSelectedFileIds.size === 0) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
setIsDeletingSelected(true);
|
|
245
|
+
setActionNotice(null);
|
|
246
|
+
|
|
247
|
+
const selectedIds = Array.from(deleteSelectedFileIds);
|
|
248
|
+
const failedFiles: string[] = [];
|
|
249
|
+
const deletedIds: string[] = [];
|
|
250
|
+
let missingImages = 0;
|
|
251
|
+
|
|
252
|
+
for (const fileId of selectedIds) {
|
|
253
|
+
try {
|
|
254
|
+
const result = await deleteFile(user, currentCase, fileId);
|
|
255
|
+
deletedIds.push(fileId);
|
|
256
|
+
if (result.imageMissing) {
|
|
257
|
+
missingImages += 1;
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
const failedFile = files.find((file) => file.id === fileId);
|
|
261
|
+
failedFiles.push(failedFile?.originalFilename || fileId);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const updatedFiles = files.filter((file) => !deletedIds.includes(file.id));
|
|
266
|
+
setFiles(updatedFiles);
|
|
267
|
+
|
|
268
|
+
setFileConfirmationStatus((previous) => {
|
|
269
|
+
const next = { ...previous };
|
|
270
|
+
deletedIds.forEach((fileId) => {
|
|
119
271
|
delete next[fileId];
|
|
120
|
-
return next;
|
|
121
272
|
});
|
|
273
|
+
return next;
|
|
274
|
+
});
|
|
122
275
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
// Adjust page if needed
|
|
129
|
-
const newTotalPages = Math.ceil(updatedFiles.length / FILES_PER_PAGE);
|
|
130
|
-
if (currentPage >= newTotalPages && newTotalPages > 0) {
|
|
131
|
-
setCurrentPage(newTotalPages - 1);
|
|
132
|
-
}
|
|
133
|
-
} catch (err) {
|
|
134
|
-
console.error('Error deleting file:', err);
|
|
135
|
-
setError('Failed to delete file');
|
|
136
|
-
setTimeout(() => setError(null), 3000);
|
|
137
|
-
} finally {
|
|
138
|
-
setDeletingFileId(null);
|
|
276
|
+
setDeleteSelectedFileIds(new Set());
|
|
277
|
+
setIsDeleteModalOpen(false);
|
|
278
|
+
|
|
279
|
+
if (selectedFileId && deletedIds.includes(selectedFileId)) {
|
|
280
|
+
onFileSelect?.(CLEAR_SELECTION_FILE);
|
|
139
281
|
}
|
|
140
|
-
};
|
|
141
282
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return new Date(dateString).toLocaleDateString();
|
|
145
|
-
} catch {
|
|
146
|
-
return 'Unknown';
|
|
283
|
+
if (effectiveOpenSelectedFileId && deletedIds.includes(effectiveOpenSelectedFileId)) {
|
|
284
|
+
setOpenSelectedFileId(null);
|
|
147
285
|
}
|
|
148
|
-
};
|
|
149
286
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
287
|
+
if (failedFiles.length > 0) {
|
|
288
|
+
setActionNotice({
|
|
289
|
+
type: 'warning',
|
|
290
|
+
message: `Deleted ${deletedIds.length} file(s). Failed: ${failedFiles.join(', ')}`,
|
|
291
|
+
});
|
|
292
|
+
} else if (missingImages > 0) {
|
|
293
|
+
setActionNotice({
|
|
294
|
+
type: 'warning',
|
|
295
|
+
message: `Deleted ${deletedIds.length} file(s). ${missingImages} image asset(s) were missing and skipped.`,
|
|
296
|
+
});
|
|
297
|
+
} else {
|
|
298
|
+
setActionNotice({
|
|
299
|
+
type: 'success',
|
|
300
|
+
message: `Deleted ${deletedIds.length} file(s) successfully.`,
|
|
301
|
+
});
|
|
155
302
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const maxNameLength = 27 - ext.length;
|
|
159
|
-
if (name.length <= maxNameLength) return filename;
|
|
160
|
-
return name.substring(0, maxNameLength) + '…' + ext;
|
|
303
|
+
|
|
304
|
+
setIsDeletingSelected(false);
|
|
161
305
|
};
|
|
162
306
|
|
|
163
|
-
|
|
307
|
+
const canDeleteSelected = !isReadOnly && effectiveDeleteSelectedFileIds.size > 0 && !isDeletingSelected;
|
|
308
|
+
|
|
309
|
+
if (!isOpen) {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
164
312
|
|
|
165
313
|
return (
|
|
166
|
-
<div
|
|
167
|
-
className={styles.modalOverlay}
|
|
168
|
-
aria-label="Close files dialog"
|
|
169
|
-
{...overlayProps}
|
|
170
|
-
>
|
|
314
|
+
<div className={styles.modalOverlay} aria-label="Close files dialog" {...overlayProps}>
|
|
171
315
|
<div className={styles.modal}>
|
|
172
|
-
<
|
|
173
|
-
<h2>
|
|
316
|
+
<header className={styles.modalHeader}>
|
|
317
|
+
<h2>File Management {currentCase ? `- ${currentCase}` : ''}</h2>
|
|
174
318
|
<button className={styles.closeButton} {...getCloseButtonProps({ ariaLabel: 'Close files dialog' })}>
|
|
175
319
|
×
|
|
176
320
|
</button>
|
|
177
|
-
</
|
|
178
|
-
|
|
321
|
+
</header>
|
|
322
|
+
|
|
179
323
|
<div className={styles.modalContent}>
|
|
180
|
-
{
|
|
181
|
-
<
|
|
182
|
-
) : files.length === 0 ? (
|
|
183
|
-
<div className={styles.emptyState}>No files in this case</div>
|
|
324
|
+
{files.length === 0 ? (
|
|
325
|
+
<p className={styles.emptyState}>No files found in this case</p>
|
|
184
326
|
) : (
|
|
185
|
-
|
|
186
|
-
{
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
return (
|
|
197
|
-
<div
|
|
198
|
-
key={file.id}
|
|
199
|
-
className={`${styles.fileItem} ${selectedFileId === file.id ? styles.active : ''} ${confirmationClass}`}
|
|
200
|
-
onClick={() => handleFileSelect(file)}
|
|
201
|
-
onKeyDown={(event) => {
|
|
202
|
-
if (event.key === 'Enter' || event.key === ' ') {
|
|
203
|
-
event.preventDefault();
|
|
204
|
-
handleFileSelect(file);
|
|
205
|
-
}
|
|
327
|
+
<>
|
|
328
|
+
<section className={styles.controlsSection} aria-label="File list controls">
|
|
329
|
+
<div className={styles.controlGroup}>
|
|
330
|
+
<label htmlFor="files-sort">Sort</label>
|
|
331
|
+
<select
|
|
332
|
+
id="files-sort"
|
|
333
|
+
value={preferences.sortBy}
|
|
334
|
+
onChange={(event) => {
|
|
335
|
+
setSortBy(event.target.value as FilesModalSortBy);
|
|
336
|
+
setCurrentPage(0);
|
|
206
337
|
}}
|
|
207
|
-
role="button"
|
|
208
|
-
tabIndex={0}
|
|
209
338
|
>
|
|
210
|
-
<
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
</
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
339
|
+
<option value="recent">Date Uploaded</option>
|
|
340
|
+
<option value="filename">File Name</option>
|
|
341
|
+
<option value="confirmation">Confirmation Status</option>
|
|
342
|
+
<option value="classType">Class Type</option>
|
|
343
|
+
</select>
|
|
344
|
+
</div>
|
|
345
|
+
|
|
346
|
+
<div className={styles.controlGroup}>
|
|
347
|
+
<label htmlFor="files-confirmation-filter">Confirmation Status</label>
|
|
348
|
+
<select
|
|
349
|
+
id="files-confirmation-filter"
|
|
350
|
+
value={preferences.confirmationFilter}
|
|
351
|
+
onChange={(event) => {
|
|
352
|
+
setConfirmationFilter(event.target.value as FilesModalConfirmationFilter);
|
|
353
|
+
setCurrentPage(0);
|
|
354
|
+
}}
|
|
355
|
+
>
|
|
356
|
+
<option value="all">All</option>
|
|
357
|
+
<option value="pending">Pending</option>
|
|
358
|
+
<option value="confirmed">Confirmed</option>
|
|
359
|
+
<option value="none-requested">None Requested</option>
|
|
360
|
+
</select>
|
|
361
|
+
</div>
|
|
362
|
+
|
|
363
|
+
<div className={styles.controlGroup}>
|
|
364
|
+
<label htmlFor="files-class-filter">Class Type</label>
|
|
365
|
+
<select
|
|
366
|
+
id="files-class-filter"
|
|
367
|
+
value={preferences.classTypeFilter}
|
|
368
|
+
onChange={(event) => {
|
|
369
|
+
setClassTypeFilter(event.target.value as FilesModalClassTypeFilter);
|
|
370
|
+
setCurrentPage(0);
|
|
371
|
+
}}
|
|
372
|
+
>
|
|
373
|
+
<option value="all">All</option>
|
|
374
|
+
<option value="Bullet">Bullet</option>
|
|
375
|
+
<option value="Cartridge Case">Cartridge Case</option>
|
|
376
|
+
<option value="Shotshell">Shotshell</option>
|
|
377
|
+
<option value="Other">Other</option>
|
|
378
|
+
</select>
|
|
379
|
+
</div>
|
|
380
|
+
|
|
381
|
+
<button
|
|
382
|
+
type="button"
|
|
383
|
+
className={styles.resetButton}
|
|
384
|
+
onClick={() => {
|
|
385
|
+
setSearchQuery('');
|
|
386
|
+
resetPreferences();
|
|
387
|
+
setCurrentPage(0);
|
|
388
|
+
}}
|
|
389
|
+
disabled={!hasCustomPreferences && searchQuery.trim().length === 0}
|
|
390
|
+
>
|
|
391
|
+
Reset
|
|
392
|
+
</button>
|
|
393
|
+
</section>
|
|
394
|
+
|
|
395
|
+
<div className={styles.searchSection}>
|
|
396
|
+
<label htmlFor="file-search">Search file name</label>
|
|
397
|
+
<input
|
|
398
|
+
id="file-search"
|
|
399
|
+
type="text"
|
|
400
|
+
value={searchQuery}
|
|
401
|
+
onChange={(event) => {
|
|
402
|
+
setSearchQuery(event.target.value);
|
|
403
|
+
setCurrentPage(0);
|
|
404
|
+
}}
|
|
405
|
+
placeholder="Type to filter files"
|
|
406
|
+
className={styles.searchInput}
|
|
407
|
+
/>
|
|
408
|
+
</div>
|
|
409
|
+
|
|
410
|
+
<p className={styles.fileCount}>
|
|
411
|
+
{visibleFiles.length} shown of {files.length} total files
|
|
412
|
+
</p>
|
|
413
|
+
|
|
414
|
+
{actionNotice && (
|
|
415
|
+
<p
|
|
416
|
+
className={`${styles.actionNotice} ${
|
|
417
|
+
actionNotice.type === 'error'
|
|
418
|
+
? styles.actionNoticeError
|
|
419
|
+
: actionNotice.type === 'warning'
|
|
420
|
+
? styles.actionNoticeWarning
|
|
421
|
+
: styles.actionNoticeSuccess
|
|
422
|
+
}`}
|
|
423
|
+
>
|
|
424
|
+
{actionNotice.message}
|
|
425
|
+
</p>
|
|
426
|
+
)}
|
|
427
|
+
|
|
428
|
+
{visibleFiles.length === 0 ? (
|
|
429
|
+
<p className={styles.emptyState}>No files match your filters</p>
|
|
430
|
+
) : (
|
|
431
|
+
<ul className={styles.filesList}>
|
|
432
|
+
{paginatedFiles.map((file) => {
|
|
433
|
+
const summary = fileConfirmationStatus[file.id] || DEFAULT_CONFIRMATION_SUMMARY;
|
|
434
|
+
const isOpenSelected = effectiveOpenSelectedFileId === file.id;
|
|
435
|
+
const isDeleteSelected = effectiveDeleteSelectedFileIds.has(file.id);
|
|
436
|
+
const confirmationLabel = getConfirmationLabel(summary);
|
|
437
|
+
const classTypeLabel = getClassTypeLabel(summary.classType);
|
|
438
|
+
|
|
439
|
+
let confirmationClass = '';
|
|
440
|
+
if (summary.includeConfirmation) {
|
|
441
|
+
confirmationClass = summary.isConfirmed
|
|
442
|
+
? styles.fileItemConfirmed
|
|
443
|
+
: styles.fileItemNotConfirmed;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return (
|
|
447
|
+
<li key={file.id}>
|
|
448
|
+
<div
|
|
449
|
+
className={`${styles.fileItem} ${isOpenSelected ? styles.active : ''}`}
|
|
450
|
+
onClick={() => setOpenSelectedFileId(file.id)}
|
|
451
|
+
onKeyDown={(event) => {
|
|
452
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
453
|
+
event.preventDefault();
|
|
454
|
+
setOpenSelectedFileId(file.id);
|
|
455
|
+
}
|
|
456
|
+
}}
|
|
457
|
+
role="button"
|
|
458
|
+
tabIndex={0}
|
|
459
|
+
>
|
|
460
|
+
<input
|
|
461
|
+
type="checkbox"
|
|
462
|
+
checked={isDeleteSelected}
|
|
463
|
+
className={styles.deleteSelector}
|
|
464
|
+
onChange={() => toggleDeleteSelection(file.id)}
|
|
465
|
+
onClick={(event) => event.stopPropagation()}
|
|
466
|
+
aria-label={`Select ${file.originalFilename} for delete`}
|
|
467
|
+
/>
|
|
468
|
+
|
|
469
|
+
<div className={styles.fileInfo}>
|
|
470
|
+
<div className={styles.fileName} title={file.originalFilename}>
|
|
471
|
+
{file.originalFilename}
|
|
472
|
+
</div>
|
|
473
|
+
<div className={styles.fileMetaRow}>
|
|
474
|
+
<span className={styles.fileDate}>Uploaded: {formatDate(file.uploadedAt)}</span>
|
|
475
|
+
<span className={styles.classTypeBadge}>Class: {classTypeLabel}</span>
|
|
476
|
+
</div>
|
|
477
|
+
</div>
|
|
478
|
+
|
|
479
|
+
<span
|
|
480
|
+
className={`${styles.confirmationBadge} ${confirmationClass}`}
|
|
481
|
+
aria-label={`Confirmation status: ${confirmationLabel}`}
|
|
482
|
+
>
|
|
483
|
+
{confirmationLabel}
|
|
484
|
+
</span>
|
|
485
|
+
</div>
|
|
486
|
+
</li>
|
|
487
|
+
);
|
|
488
|
+
})}
|
|
489
|
+
</ul>
|
|
490
|
+
)}
|
|
491
|
+
</>
|
|
232
492
|
)}
|
|
233
493
|
</div>
|
|
234
|
-
|
|
235
|
-
{
|
|
236
|
-
<div className={styles.
|
|
494
|
+
|
|
495
|
+
<div className={styles.footerActions}>
|
|
496
|
+
<div className={styles.maintenanceActions}>
|
|
497
|
+
<button
|
|
498
|
+
type="button"
|
|
499
|
+
className={styles.secondaryActionButton}
|
|
500
|
+
onClick={selectAllVisibleFiles}
|
|
501
|
+
disabled={isReadOnly || visibleFiles.length === 0 || isDeletingSelected}
|
|
502
|
+
>
|
|
503
|
+
Select Visible
|
|
504
|
+
</button>
|
|
237
505
|
<button
|
|
238
|
-
|
|
239
|
-
|
|
506
|
+
type="button"
|
|
507
|
+
className={styles.secondaryActionButton}
|
|
508
|
+
onClick={clearDeleteSelection}
|
|
509
|
+
disabled={effectiveDeleteSelectedFileIds.size === 0 || isDeletingSelected}
|
|
240
510
|
>
|
|
241
|
-
|
|
511
|
+
Clear Selected
|
|
242
512
|
</button>
|
|
243
|
-
<span>
|
|
244
|
-
Page {currentPage + 1} of {totalPages} ({files.length} total files)
|
|
245
|
-
</span>
|
|
246
513
|
<button
|
|
247
|
-
|
|
248
|
-
|
|
514
|
+
type="button"
|
|
515
|
+
className={`${styles.secondaryActionButton} ${styles.deleteActionButton}`}
|
|
516
|
+
onClick={() => setIsDeleteModalOpen(true)}
|
|
517
|
+
disabled={!canDeleteSelected}
|
|
249
518
|
>
|
|
250
|
-
|
|
519
|
+
Delete Selected ({effectiveDeleteSelectedFileIds.size})
|
|
251
520
|
</button>
|
|
252
521
|
</div>
|
|
253
|
-
|
|
522
|
+
|
|
523
|
+
<button
|
|
524
|
+
type="button"
|
|
525
|
+
className={styles.openSelectedButton}
|
|
526
|
+
onClick={handleOpenSelectedFile}
|
|
527
|
+
disabled={!effectiveOpenSelectedFileId || isDeletingSelected}
|
|
528
|
+
>
|
|
529
|
+
Open Selected File
|
|
530
|
+
</button>
|
|
531
|
+
|
|
532
|
+
{totalPages > 1 && (
|
|
533
|
+
<div className={styles.pagination}>
|
|
534
|
+
<button
|
|
535
|
+
onClick={() => setCurrentPage((previous) => Math.max(0, previous - 1))}
|
|
536
|
+
disabled={effectiveCurrentPage === 0}
|
|
537
|
+
>
|
|
538
|
+
Previous
|
|
539
|
+
</button>
|
|
540
|
+
<span>
|
|
541
|
+
{effectiveCurrentPage + 1} of {totalPages} ({visibleFiles.length} filtered files)
|
|
542
|
+
</span>
|
|
543
|
+
<button
|
|
544
|
+
onClick={() => setCurrentPage((previous) => Math.min(totalPages - 1, previous + 1))}
|
|
545
|
+
disabled={effectiveCurrentPage === totalPages - 1}
|
|
546
|
+
>
|
|
547
|
+
Next
|
|
548
|
+
</button>
|
|
549
|
+
</div>
|
|
550
|
+
)}
|
|
551
|
+
</div>
|
|
254
552
|
</div>
|
|
553
|
+
|
|
554
|
+
<DeleteFilesModal
|
|
555
|
+
isOpen={isDeleteModalOpen}
|
|
556
|
+
isSubmitting={isDeletingSelected}
|
|
557
|
+
files={files}
|
|
558
|
+
selectedFileIds={effectiveDeleteSelectedFileIds}
|
|
559
|
+
onClose={() => setIsDeleteModalOpen(false)}
|
|
560
|
+
onSubmit={handleDeleteSelectedFiles}
|
|
561
|
+
/>
|
|
255
562
|
</div>
|
|
256
563
|
);
|
|
257
564
|
};
|