-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathProjectMetadataModal.tsx
More file actions
334 lines (319 loc) · 12.7 KB
/
ProjectMetadataModal.tsx
File metadata and controls
334 lines (319 loc) · 12.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
import papi, { logger } from '@papi/frontend';
import { useLocalizedStrings } from '@papi/frontend/react';
import { Trash2 } from 'lucide-react';
import { Button } from 'platform-bible-react';
import { useCallback, useMemo, useRef, useState } from 'react';
/** Localized string keys used by {@link ProjectMetadataModal}. */
const PROJECT_METADATA_MODAL_STRING_KEYS: `%${string}%`[] = [
'%interlinearizer_modal_metadata_title%',
'%interlinearizer_modal_metadata_id_label%',
'%interlinearizer_modal_metadata_name_label%',
'%interlinearizer_modal_metadata_name_placeholder%',
'%interlinearizer_modal_metadata_description_label%',
'%interlinearizer_modal_metadata_description_placeholder%',
'%interlinearizer_modal_metadata_analysis_language_label%',
'%interlinearizer_modal_metadata_language_placeholder%',
'%interlinearizer_modal_metadata_created_label%',
'%interlinearizer_modal_metadata_source_label%',
'%interlinearizer_modal_metadata_save%',
'%interlinearizer_modal_metadata_close%',
'%interlinearizer_modal_metadata_delete%',
'%interlinearizer_modal_metadata_delete_confirm_title%',
'%interlinearizer_modal_metadata_delete_confirm_body%',
'%interlinearizer_modal_metadata_delete_confirm_ok%',
'%interlinearizer_modal_metadata_delete_confirm_cancel%',
];
/** Props for {@link ProjectMetadataModal}. */
export type ProjectMetadataModalProps = Readonly<{
/** UUID of the active interlinearizer project. */
interlinearProjectId: string;
/** Optional user-facing name of the project. */
name?: string;
/** Optional user-facing description of the project. */
description?: string;
/** Platform.Bible project ID of the source text. */
sourceProjectId: string;
/** Optional Platform.Bible project ID of the target text for bilateral alignment projects. */
targetProjectId?: string;
/** BCP 47 tags for the analysis languages. */
analysisLanguages: string[];
/** ISO 8601 creation timestamp. */
createdAt: string;
/** Callback invoked when the modal should be dismissed without saving. */
onClose: () => void;
/** Optional callback invoked with updated metadata after a successful save. */
onProjectSaved?: (updated: {
name?: string;
description?: string;
analysisLanguages: string[];
}) => void;
/** Optional callback invoked with the deleted project ID after deletion. */
onProjectDeleted?: (deletedProjectId: string) => void;
}>;
/**
* Modal that displays and allows editing of the active interlinearizer project's metadata. Editable
* fields are name, description, and analysis language. Read-only fields are project ID, creation
* date, and source project. Includes an inline delete-with-confirmation flow.
*
* @param props - Component props (see {@link ProjectMetadataModalProps}).
* @returns The modal overlay with editable metadata fields and action buttons.
*/
export function ProjectMetadataModal({
interlinearProjectId,
name,
description,
sourceProjectId,
targetProjectId,
analysisLanguages,
createdAt,
onClose,
onProjectSaved,
onProjectDeleted,
}: ProjectMetadataModalProps) {
const [localizedStrings, stringsLoading] = useLocalizedStrings(
PROJECT_METADATA_MODAL_STRING_KEYS,
);
const [editName, setEditName] = useState(name ?? '');
const [editDescription, setEditDescription] = useState(description ?? '');
const [editLanguages, setEditLanguages] = useState(analysisLanguages.join(', '));
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const isSubmittingRef = useRef(false);
const formattedDate = useMemo(() => new Date(createdAt).toLocaleString(), [createdAt]);
/**
* Sends the updated name, description, and analysis languages to the backend, then notifies the
* caller and closes the modal. Logs on failure; the backend command handler is responsible for
* showing the error notification so this handler does not re-send it.
*
* The analysis-languages input is interpreted as a comma-separated list of BCP 47 tags; entries
* are trimmed and empty entries dropped. Save is disabled when the parsed list is empty since
* `analysisLanguages` is required and must not be cleared.
*
* @returns A promise that resolves when the command completes or the error is logged.
*/
const handleSave = useCallback(async () => {
/* v8 ignore next -- button is disabled while submitting; ref guards against programmatic races */
if (isSubmittingRef.current) return;
isSubmittingRef.current = true;
setIsSubmitting(true);
const newName = editName.trim() || undefined;
const newDescription = editDescription.trim() || undefined;
const newLanguages = editLanguages
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0);
try {
const updatedProjectJson = await papi.commands.sendCommand(
'interlinearizer.updateProjectMetadata',
interlinearProjectId,
newName,
newDescription,
newLanguages,
targetProjectId,
);
if (!updatedProjectJson) return;
onProjectSaved?.({
name: newName,
description: newDescription,
analysisLanguages: newLanguages,
});
onClose();
} catch (e) {
logger.error('Interlinearizer: failed to save project metadata', e);
} finally {
isSubmittingRef.current = false;
setIsSubmitting(false);
}
}, [
editName,
editDescription,
editLanguages,
interlinearProjectId,
targetProjectId,
onProjectSaved,
onClose,
]);
/**
* Sends the delete command to the backend, then notifies the caller and closes the modal. Logs on
* failure; the backend command handler is responsible for showing the error notification so this
* handler does not re-send it.
*
* @returns A promise that resolves when the command completes or the error is logged.
*/
const handleDelete = useCallback(async () => {
/* v8 ignore next -- button is disabled while submitting; ref guards against programmatic races */
if (isSubmittingRef.current) return;
isSubmittingRef.current = true;
setIsSubmitting(true);
try {
await papi.commands.sendCommand('interlinearizer.deleteProject', interlinearProjectId);
onProjectDeleted?.(interlinearProjectId);
onClose();
} catch (e) {
logger.error('Interlinearizer: failed to delete project', e);
} finally {
isSubmittingRef.current = false;
setIsSubmitting(false);
}
}, [interlinearProjectId, onProjectDeleted, onClose]);
/* v8 ignore next */ if (stringsLoading) return undefined;
return (
<div className="tw:modal-overlay">
<dialog
aria-labelledby="project-metadata-modal-title"
aria-modal="true"
className="tw:modal-dialog tw:rounded-lg tw:w-lg"
open
>
<h2 id="project-metadata-modal-title" className="tw:modal-title">
{localizedStrings['%interlinearizer_modal_metadata_title%']}
</h2>
{/* Editable fields */}
<div className="tw:flex tw:flex-col tw:gap-3 tw:mb-4">
<div className="tw:flex tw:flex-col tw:gap-1">
<label className="tw:section-label" htmlFor="metadata-edit-name">
{localizedStrings['%interlinearizer_modal_metadata_name_label%']}
</label>
<input
id="metadata-edit-name"
className="tw:rounded tw:border tw:border-border tw:bg-background tw:px-2 tw:py-1 tw:text-sm tw:text-foreground"
value={editName}
placeholder={localizedStrings['%interlinearizer_modal_metadata_name_placeholder%']}
onChange={(e) => setEditName(e.target.value)}
/>
</div>
<div className="tw:flex tw:flex-col tw:gap-1">
<label className="tw:section-label" htmlFor="metadata-edit-description">
{localizedStrings['%interlinearizer_modal_metadata_description_label%']}
</label>
<textarea
id="metadata-edit-description"
className="tw:rounded tw:border tw:border-border tw:bg-background tw:px-2 tw:py-1 tw:text-sm tw:text-foreground tw:resize-none"
rows={2}
value={editDescription}
placeholder={
localizedStrings['%interlinearizer_modal_metadata_description_placeholder%']
}
onChange={(e) => setEditDescription(e.target.value)}
/>
</div>
<div className="tw:flex tw:flex-col tw:gap-1">
<label className="tw:section-label" htmlFor="metadata-edit-language">
{localizedStrings['%interlinearizer_modal_metadata_analysis_language_label%']}
</label>
<input
id="metadata-edit-language"
className="tw:rounded tw:border tw:border-border tw:bg-background tw:px-2 tw:py-1 tw:text-sm tw:text-foreground tw:font-mono"
value={editLanguages}
placeholder={
localizedStrings['%interlinearizer_modal_metadata_language_placeholder%']
}
onChange={(e) => setEditLanguages(e.target.value)}
/>
</div>
</div>
{/* Read-only metadata */}
<dl className="tw:flex tw:flex-col tw:gap-2 tw:mb-5">
<MetadataRow
label={localizedStrings['%interlinearizer_modal_metadata_id_label%']}
value={interlinearProjectId}
mono
/>
<MetadataRow
label={localizedStrings['%interlinearizer_modal_metadata_created_label%']}
value={formattedDate}
/>
<MetadataRow
label={localizedStrings['%interlinearizer_modal_metadata_source_label%']}
value={sourceProjectId}
mono
/>
</dl>
{/* Footer */}
{confirmingDelete ? (
<div className="tw:rounded tw:border tw:border-destructive/40 tw:bg-destructive/5 tw:px-3 tw:py-2">
<p className="tw:font-medium tw:text-foreground tw:mb-0.5">
{localizedStrings['%interlinearizer_modal_metadata_delete_confirm_title%']}
</p>
<p className="tw:text-xs tw:text-muted-foreground tw:mb-2">
{localizedStrings['%interlinearizer_modal_metadata_delete_confirm_body%']}
</p>
<div className="tw:flex tw:gap-2 tw:justify-end">
<Button
variant="secondary"
size="sm"
onClick={() => setConfirmingDelete(false)}
disabled={isSubmitting}
>
{localizedStrings['%interlinearizer_modal_metadata_delete_confirm_cancel%']}
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleDelete}
disabled={isSubmitting}
>
{localizedStrings['%interlinearizer_modal_metadata_delete_confirm_ok%']}
</Button>
</div>
</div>
) : (
<div className="tw:flex tw:gap-2 tw:justify-between">
<Button
variant="destructive"
onClick={() => setConfirmingDelete(true)}
disabled={isSubmitting}
>
<Trash2 size={13} className="tw:mr-1" />
{localizedStrings['%interlinearizer_modal_metadata_delete%']}
</Button>
<div className="tw:flex tw:gap-2">
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
{localizedStrings['%interlinearizer_modal_metadata_close%']}
</Button>
<Button
onClick={handleSave}
disabled={
isSubmitting ||
editLanguages
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0).length === 0
}
>
{localizedStrings['%interlinearizer_modal_metadata_save%']}
</Button>
</div>
</div>
)}
</dialog>
</div>
);
}
/**
* A single label/value row inside the read-only metadata description list.
*
* @param props - Component props.
* @param props.label - Localized field label shown as `<dt>`.
* @param props.value - Value string shown as `<dd>`.
* @param props.mono - When true, renders the value in a monospace font.
* @returns A `<dt>`/`<dd>` pair.
*/
function MetadataRow({
label,
value,
mono,
}: Readonly<{ label: string; value: string; mono?: boolean }>) {
return (
<div className="tw:flex tw:flex-col tw:gap-0.5">
<dt className="tw:section-label">{label}</dt>
<dd
className={['tw:text-sm tw:break-all tw:text-foreground', mono ? 'tw:font-mono' : '']
.filter(Boolean)
.join(' ')}
>
{value}
</dd>
</div>
);
}