@liiift-studio/sanity-font-manager 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +695 -437
  3. package/dist/index.js +2330 -7084
  4. package/dist/index.mjs +2331 -7085
  5. package/package.json +84 -83
  6. package/src/components/BatchUploadFonts.jsx +655 -655
  7. package/src/components/BulkActions.jsx +99 -99
  8. package/src/components/ExistingDocumentResolver.jsx +152 -152
  9. package/src/components/FontReviewCard.jsx +455 -455
  10. package/src/components/FontScriptUploaderComponent.jsx +463 -463
  11. package/src/components/GenerateCollectionsPairsComponent.jsx +259 -259
  12. package/src/components/KeyValueInput.jsx +95 -95
  13. package/src/components/KeyValueReferenceInput.jsx +267 -267
  14. package/src/components/NestedObjectArraySelector.jsx +146 -146
  15. package/src/components/PriceInput.jsx +26 -26
  16. package/src/components/PrimaryCollectionGeneratorTypeface.jsx +116 -116
  17. package/src/components/RegenerateSubfamiliesComponent.jsx +185 -185
  18. package/src/components/SetOTF.jsx +87 -87
  19. package/src/components/SingleUploaderTool.jsx +674 -674
  20. package/src/components/StatusDisplay.jsx +26 -26
  21. package/src/components/StyleCountInput.jsx +16 -16
  22. package/src/components/UpdateScriptsComponent.jsx +76 -76
  23. package/src/components/UploadButton.jsx +43 -43
  24. package/src/components/UploadModal.jsx +309 -309
  25. package/src/components/UploadScriptsComponent.jsx +539 -539
  26. package/src/components/UploadStep1Settings.jsx +272 -272
  27. package/src/components/UploadStep2Review.jsx +478 -478
  28. package/src/components/UploadStep3Execute.jsx +234 -234
  29. package/src/components/UploadStep3bInstances.jsx +396 -396
  30. package/src/components/UploadSummary.jsx +196 -196
  31. package/src/components/VariableInstanceReferencesInput.jsx +190 -190
  32. package/src/hooks/useNestedObjects.js +92 -92
  33. package/src/hooks/useSanityClient.js +9 -9
  34. package/src/index.js +120 -120
  35. package/src/schema/openTypeField.js +1995 -1995
  36. package/src/schema/styleCountField.js +12 -12
  37. package/src/schema/stylesField.js +302 -302
  38. package/src/schema/stylisticSetField.js +301 -301
  39. package/src/utils/buildUploadPlan.js +326 -326
  40. package/src/utils/executeUploadPlan.js +430 -430
  41. package/src/utils/executionReducer.js +56 -56
  42. package/src/utils/fontHelpers.js +281 -281
  43. package/src/utils/generateCssFile.js +207 -207
  44. package/src/utils/generateFontData.js +98 -98
  45. package/src/utils/generateFontFile.js +38 -38
  46. package/src/utils/generateKeywords.js +185 -185
  47. package/src/utils/generateSubset.js +45 -45
  48. package/src/utils/getEmptyFontKit.js +101 -101
  49. package/src/utils/parseFont.js +56 -56
  50. package/src/utils/parseVariableFontInstances.js +301 -301
  51. package/src/utils/planReducer.js +531 -531
  52. package/src/utils/planTypes.js +183 -183
  53. package/src/utils/processFontFiles.js +530 -530
  54. package/src/utils/regenerateFontData.js +146 -146
  55. package/src/utils/resolveExistingFont.js +87 -87
  56. package/src/utils/retitleFontEntries.js +154 -154
  57. package/src/utils/sanitizeForSanityId.js +65 -65
  58. package/src/utils/setupDecompressors.js +27 -27
  59. package/src/utils/updateFontPrices.js +94 -94
  60. package/src/utils/updateTypefaceDocument.js +162 -162
  61. package/src/utils/uploadFontFiles.js +405 -405
  62. package/src/utils/utils.js +24 -24
@@ -1,455 +1,455 @@
1
- // Per-font review row — table-style header with expandable detail panel
2
-
3
- import React, { useState, useCallback, useEffect, useMemo, memo } from 'react';
4
- import { Card, Stack, Flex, Box, Text, TextInput, Badge, Button, Select, Tooltip, Label } from '@sanity/ui';
5
- import { ChevronDownIcon, ChevronRightIcon, TrashIcon, ResetIcon, InfoOutlineIcon } from '@sanity/icons';
6
- import { FONT_STATUS, RECOMMENDATION } from '../utils/planTypes';
7
- import ExistingDocumentResolver from './ExistingDocumentResolver';
8
-
9
- /** Standard file types shown in the files row */
10
- const STANDARD_TYPES = ['ttf', 'otf', 'woff', 'woff2'];
11
- /** Extended file types shown when expanded */
12
- const EXTENDED_TYPES = ['eot', 'svg', 'css', 'woff2_subset', 'woff2_web'];
13
-
14
- /**
15
- * Collapsible review card for a single font in the upload plan.
16
- * Table-style header row with weight/style/files/action columns.
17
- */
18
- const FontReviewCard = memo(function FontReviewCard({ entry, dispatch, allExpanded, typefaceTitle, price }) {
19
- const [expanded, setExpanded] = useState(false);
20
- const [showAllFileTypes, setShowAllFileTypes] = useState(false);
21
- const [showDocPreview, setShowDocPreview] = useState(false);
22
-
23
- // Sync with allExpanded toggle from BulkActions
24
- useEffect(() => {
25
- setExpanded(allExpanded);
26
- }, [allExpanded]);
27
-
28
- // Local state for typing — dispatches on blur
29
- const [localTitle, setLocalTitle] = useState(entry.title);
30
- const [localWeight, setLocalWeight] = useState(String(entry.weight));
31
- const [localWeightName, setLocalWeightName] = useState(entry.weightName);
32
- const [localSubfamily, setLocalSubfamily] = useState(entry.subfamily);
33
-
34
- const [localDocId, setLocalDocId] = useState(entry.documentId);
35
-
36
- const isError = entry.status === FONT_STATUS.ERROR;
37
- const hasConflict = entry._idConflict;
38
- const resolution = entry.decisions?.existingDocument;
39
- const isUpdate = resolution?.userChoice === 'update' ||
40
- (!resolution?.userChoice && (resolution?.recommendation === RECOMMENDATION.USE_EXACT || resolution?.recommendation === RECOMMENDATION.USE_CANDIDATE));
41
-
42
- // Document ID is editable when user chose "create new" on a font with an existing match
43
- const isCreateNewOverride = resolution?.userChoice === 'create' &&
44
- (resolution?.exact || resolution?.candidates?.length > 0);
45
- const docIdEditable = isCreateNewOverride || hasConflict;
46
-
47
- // Detect if user has overridden any suggestions
48
- const hasUserOverrides = useMemo(() => {
49
- const d = entry.decisions;
50
- if (!d) return false;
51
- return (
52
- d.title?.userOverride != null ||
53
- d.weight?.userOverride != null ||
54
- d.weightName?.userOverride != null ||
55
- d.style?.userOverride != null ||
56
- d.subfamily?.userOverride != null ||
57
- d.documentId?.userOverride != null
58
- );
59
- }, [entry.decisions]);
60
-
61
- // Map of which file extensions are present
62
- const fileExtMap = useMemo(() => {
63
- const map = {};
64
- (entry.files || []).forEach(f => {
65
- const ext = f.name?.split('.').pop()?.toLowerCase();
66
- if (ext) map[ext] = f.name;
67
- });
68
- return map;
69
- }, [entry.files]);
70
-
71
- const fileCount = entry.files?.length || 0;
72
- const cardTone = isError ? 'critical' : hasConflict ? 'caution' : 'default';
73
-
74
- const handleTitleBlur = useCallback(() => {
75
- if (localTitle !== entry.title) {
76
- dispatch({ type: 'SET_FONT_TITLE', tempId: entry.tempId, title: localTitle });
77
- }
78
- }, [localTitle, entry.title, entry.tempId, dispatch]);
79
-
80
- const handleDocIdBlur = useCallback(() => {
81
- if (localDocId !== entry.documentId) {
82
- dispatch({ type: 'SET_FONT_DOCUMENT_ID', tempId: entry.tempId, documentId: localDocId });
83
- }
84
- }, [localDocId, entry.documentId, entry.tempId, dispatch]);
85
-
86
- const handleWeightBlur = useCallback(() => {
87
- const num = Number(localWeight);
88
- if (!isNaN(num) && num !== entry.weight) {
89
- dispatch({ type: 'SET_FONT_WEIGHT', tempId: entry.tempId, weight: num });
90
- }
91
- }, [localWeight, entry.weight, entry.tempId, dispatch]);
92
-
93
- const handleWeightNameBlur = useCallback(() => {
94
- if (localWeightName !== entry.weightName) {
95
- dispatch({ type: 'SET_FONT_WEIGHT_NAME', tempId: entry.tempId, weightName: localWeightName });
96
- }
97
- }, [localWeightName, entry.weightName, entry.tempId, dispatch]);
98
-
99
- const handleStyleChange = useCallback((e) => {
100
- dispatch({ type: 'SET_FONT_STYLE', tempId: entry.tempId, style: e.target.value });
101
- }, [entry.tempId, dispatch]);
102
-
103
- const handleSubfamilyBlur = useCallback(() => {
104
- if (localSubfamily !== entry.subfamily) {
105
- dispatch({ type: 'SET_FONT_SUBFAMILY', tempId: entry.tempId, subfamily: localSubfamily });
106
- }
107
- }, [localSubfamily, entry.subfamily, entry.tempId, dispatch]);
108
-
109
- const handleReset = useCallback(() => {
110
- dispatch({ type: 'RESET_FONT_TO_SUGGESTIONS', tempId: entry.tempId });
111
- setLocalTitle(entry.decisions.title.processed);
112
- setLocalDocId(entry.decisions.documentId.generated);
113
- setLocalWeight(String(entry.decisions.weight.detected));
114
- setLocalWeightName(entry.decisions.weightName.detected);
115
- setLocalSubfamily(entry.decisions.subfamily.detected || 'Regular');
116
- }, [entry, dispatch]);
117
-
118
- const handleRemove = useCallback(() => {
119
- dispatch({ type: 'REMOVE_FONT', tempId: entry.tempId });
120
- }, [entry.tempId, dispatch]);
121
-
122
- /** Format source string — converts nameId references to readable format, appends user override if present */
123
- const formatSource = (decision) => {
124
- if (!decision) return null;
125
- let src = decision.source || '';
126
- // Format name table references: nameId1-familyName → nameId1 (familyName)
127
- src = src.replace(/nameId(\d+)-(\w+)/g, 'nameId$1 ($2)');
128
- src = src.replace(/-/g, ' ').replace('fontkit ', '');
129
- // Special case: default-empty for subfamily
130
- if (src === 'default empty' && decision.detected === '') {
131
- src = 'empty — defaults to "Regular"';
132
- }
133
- const reason = decision.reason ? ` (${decision.reason})` : '';
134
- const override = decision.userOverride != null ? ' (user override)' : '';
135
- return `Source: ${src}${reason}${override}`;
136
- };
137
-
138
- return (
139
- <Card border radius={2} tone={cardTone} style={{ marginBottom: -1 }}>
140
- {/* Header row — table-style columns */}
141
- <Box
142
- as="button"
143
- onClick={() => !isError && setExpanded(v => !v)}
144
- style={{
145
- width: '100%',
146
- background: expanded ? 'var(--card-muted-bg-color)' : 'none',
147
- border: 'none',
148
- cursor: isError ? 'default' : 'pointer',
149
- textAlign: 'left',
150
- padding: 0,
151
- transition: 'background 0.1s ease',
152
- }}
153
- onMouseEnter={(e) => { if (!isError && !expanded) e.currentTarget.style.background = 'var(--card-muted-bg-color)'; }}
154
- onMouseLeave={(e) => { if (!expanded) e.currentTarget.style.background = 'none'; }}
155
- >
156
- <Flex align="center" gap={2} paddingX={2} paddingY={2}>
157
- <Box style={{ width: 20, flexShrink: 0 }}>
158
- {!isError && (expanded ? <ChevronDownIcon /> : <ChevronRightIcon />)}
159
- </Box>
160
- <Box style={{ flex: 1, whiteSpace: 'nowrap',}}>
161
- <Text size={1} weight="semibold" style={{ whiteSpace: 'nowrap' }}>
162
- {entry.title || entry.sourceFileName}
163
- {entry.variableFont && <Badge tone="primary" fontSize={0} style={{ marginLeft: 6 }}>VF</Badge>}
164
- {hasConflict && <Badge tone="caution" fontSize={0} style={{ marginLeft: 6 }}>ID Conflict</Badge>}
165
- </Text>
166
- </Box>
167
- <Text size={0} style={{ width: 50, textAlign: 'center', flexShrink: 0 }}>{entry.weight}</Text>
168
- <Text size={0} style={{ width: 50, textAlign: 'center', flexShrink: 0 }}>{entry.style}</Text>
169
- <Text size={0} style={{ width: 40, textAlign: 'center', flexShrink: 0 }}>{fileCount}</Text>
170
- <Box style={{ width: 55, textAlign: 'center', flexShrink: 0 }}>
171
- <Badge tone={isError ? 'critical' : isUpdate ? 'caution' : 'positive'} fontSize={0}>
172
- {isError ? 'Error' : isUpdate ? 'Update' : 'Create'}
173
- </Badge>
174
- </Box>
175
- </Flex>
176
- </Box>
177
-
178
- {/* Error message */}
179
- {isError && (
180
- <Box paddingX={2} paddingBottom={2}>
181
- <Flex justify="space-between" align="center">
182
- <Text size={0} muted>{entry.error}</Text>
183
- <Button mode="bleed" tone="critical" icon={TrashIcon} padding={1} onClick={handleRemove} />
184
- </Flex>
185
- </Box>
186
- )}
187
-
188
- {/* Expanded detail panel */}
189
- {expanded && !isError && (
190
- <Box padding={3} style={{ borderTop: '1px solid var(--card-border-color)', background: 'var(--card-muted-bg-color)' }}>
191
- <Stack space={4}>
192
- {/* Files — standard types with grey for missing, expandable for extended */}
193
- <Stack space={2}>
194
- <Flex align="center" gap={2}>
195
- <Label size={0}>Files ({fileCount})</Label>
196
- <Button
197
- mode="bleed"
198
- fontSize={0}
199
- padding={1}
200
- text={showAllFileTypes ? 'Hide extended types' : 'Show all types'}
201
- onClick={() => setShowAllFileTypes(v => !v)}
202
- style={{ cursor: 'pointer' }}
203
- />
204
- </Flex>
205
- <Flex gap={1} wrap="wrap">
206
- {STANDARD_TYPES.map(ext => (
207
- <Badge
208
- key={ext}
209
- fontSize={0}
210
- tone={fileExtMap[ext] ? 'primary' : 'default'}
211
- mode={fileExtMap[ext] ? 'default' : 'outline'}
212
- style={{ opacity: fileExtMap[ext] ? 1 : 0.35 }}
213
- >
214
- {ext.toUpperCase()}{fileExtMap[ext] ? `: ${fileExtMap[ext]}` : ''}
215
- </Badge>
216
- ))}
217
- {showAllFileTypes && EXTENDED_TYPES.map(ext => (
218
- <Badge
219
- key={ext}
220
- fontSize={0}
221
- tone={fileExtMap[ext] ? 'primary' : 'default'}
222
- mode={fileExtMap[ext] ? 'default' : 'outline'}
223
- style={{ opacity: fileExtMap[ext] ? 1 : 0.35 }}
224
- >
225
- {ext.toUpperCase()}{fileExtMap[ext] ? `: ${fileExtMap[ext]}` : ''}
226
- </Badge>
227
- ))}
228
- </Flex>
229
- </Stack>
230
-
231
- {/* Title */}
232
- <Stack space={2}>
233
- <Label size={0}>Font Title</Label>
234
- <TextInput
235
- value={localTitle}
236
- onChange={(e) => setLocalTitle(e.target.value)}
237
- onBlur={handleTitleBlur}
238
- fontSize={1}
239
- />
240
- <Text size={0} muted>{formatSource(entry.decisions?.title)}</Text>
241
- {entry.decisions?.title?.alternatives?.length > 0 && (
242
- <Flex gap={1} wrap="wrap">
243
- {entry.decisions.title.alternatives.filter(a => a.value).map((alt, i) => (
244
- <Tooltip key={i} content={<Box padding={1}><Text size={0}>{alt.source.replace(/nameId(\d+)-(\w+)/g, 'nameId$1 ($2)')}</Text></Box>} portal>
245
- <Badge
246
- mode="outline"
247
- fontSize={0}
248
- style={{ cursor: 'pointer' }}
249
- onClick={() => {
250
- setLocalTitle(alt.value);
251
- dispatch({ type: 'SET_FONT_TITLE', tempId: entry.tempId, title: alt.value, source: alt.source });
252
- }}
253
- >
254
- {alt.value.length > 30 ? alt.value.slice(0, 30) + '...' : alt.value}
255
- </Badge>
256
- </Tooltip>
257
- ))}
258
- </Flex>
259
- )}
260
- </Stack>
261
-
262
- {/* Document ID */}
263
- <Stack space={2}>
264
- <Flex align="center" gap={1}>
265
- <Label size={0}>Document ID</Label>
266
- {!docIdEditable && (
267
- <Tooltip
268
- content={<Box padding={2} style={{ maxWidth: 260 }}><Text size={1} style={{ lineHeight: 1.6 }}>Document IDs must be unique. This field is auto-derived from the font title. It becomes editable when you choose "Create new instead" on a font with an existing match, or when there is a duplicate ID conflict.</Text></Box>}
269
- placement="top"
270
- portal
271
- >
272
- <InfoOutlineIcon style={{ opacity: 0.4, fontSize: 12 }} />
273
- </Tooltip>
274
- )}
275
- </Flex>
276
- <TextInput
277
- value={docIdEditable ? localDocId : entry.documentId}
278
- onChange={docIdEditable ? (e) => setLocalDocId(e.target.value) : undefined}
279
- onBlur={docIdEditable ? handleDocIdBlur : undefined}
280
- readOnly={!docIdEditable}
281
- fontSize={1}
282
- style={{ fontFamily: 'monospace', opacity: docIdEditable ? 1 : 0.7 }}
283
- />
284
- {hasConflict && (
285
- <Text size={0} tone="caution">This ID conflicts with another font in this batch — edit to make unique</Text>
286
- )}
287
- {isCreateNewOverride && !hasConflict && (
288
- <Text size={0} tone="caution">Creating new document — edit the ID to avoid overwriting the existing document</Text>
289
- )}
290
- {!docIdEditable && (
291
- <Text size={0} muted>Auto-derived from font title</Text>
292
- )}
293
- </Stack>
294
-
295
- {/* Weight + Weight Name row */}
296
- <Flex gap={3}>
297
- <Box style={{ flex: 1 }}>
298
- <Stack space={2}>
299
- <Label size={0}>Weight</Label>
300
- <TextInput
301
- type="number"
302
- value={localWeight}
303
- onChange={(e) => setLocalWeight(e.target.value)}
304
- onBlur={handleWeightBlur}
305
- fontSize={1}
306
- />
307
- {entry.decisions?.weight && (
308
- <Text size={0} muted>{formatSource(entry.decisions.weight)}</Text>
309
- )}
310
- </Stack>
311
- </Box>
312
- <Box style={{ flex: 1 }}>
313
- <Stack space={2}>
314
- <Label size={0}>Weight Name</Label>
315
- <TextInput
316
- value={localWeightName}
317
- onChange={(e) => setLocalWeightName(e.target.value)}
318
- onBlur={handleWeightNameBlur}
319
- fontSize={1}
320
- />
321
- {entry.decisions?.weightName && (
322
- <Text size={0} muted>{formatSource(entry.decisions.weightName)}</Text>
323
- )}
324
- </Stack>
325
- </Box>
326
- </Flex>
327
-
328
- {/* Style + Subfamily row */}
329
- <Flex gap={3}>
330
- <Box style={{ flex: 1 }}>
331
- <Stack space={2}>
332
- <Label size={0}>Style</Label>
333
- <Select value={entry.style} onChange={handleStyleChange} fontSize={1}>
334
- <option value="Regular">Regular</option>
335
- <option value="Italic">Italic</option>
336
- </Select>
337
- {entry.decisions?.style && (
338
- <Text size={0} muted>{formatSource(entry.decisions.style)}</Text>
339
- )}
340
- </Stack>
341
- </Box>
342
- <Box style={{ flex: 1 }}>
343
- <Stack space={2}>
344
- <Label size={0}>Subfamily</Label>
345
- <TextInput
346
- value={localSubfamily}
347
- onChange={(e) => setLocalSubfamily(e.target.value)}
348
- onBlur={handleSubfamilyBlur}
349
- fontSize={1}
350
- />
351
- {entry.decisions?.subfamily && (
352
- <Text size={0} muted>{formatSource(entry.decisions.subfamily)}</Text>
353
- )}
354
- </Stack>
355
- </Box>
356
- </Flex>
357
-
358
- {/* VF axes info */}
359
- {entry.variableFont && entry.variationAxes && (
360
- <Stack space={2}>
361
- <Label size={0}>Variable Font Axes</Label>
362
- <Flex gap={1} wrap="wrap">
363
- {Object.entries(entry.variationAxes).map(([tag, axis]) => (
364
- <Badge key={tag} mode="outline" fontSize={0}>
365
- {tag} {axis.min}–{axis.max}
366
- </Badge>
367
- ))}
368
- </Flex>
369
- {entry.decisions?.weight?.userOverride != null && entry.variationAxes?.wght && (
370
- (entry.weight < entry.variationAxes.wght.min || entry.weight > entry.variationAxes.wght.max) && (
371
- <Text size={0} tone="caution">
372
- Weight {entry.weight} is outside the wght axis range ({entry.variationAxes.wght.min}–{entry.variationAxes.wght.max})
373
- </Text>
374
- )
375
- )}
376
- </Stack>
377
- )}
378
-
379
- {/* Existing document resolution */}
380
- <ExistingDocumentResolver
381
- decision={entry.decisions?.existingDocument}
382
- tempId={entry.tempId}
383
- dispatch={dispatch}
384
- />
385
-
386
- {/* Document Preview — expandable view of all fields that will be written */}
387
- <Stack space={2}>
388
- <Button
389
- mode="bleed"
390
- fontSize={0}
391
- padding={1}
392
- text={showDocPreview ? 'Hide document preview' : 'Show document preview'}
393
- onClick={() => setShowDocPreview(v => !v)}
394
- style={{ cursor: 'pointer', alignSelf: 'flex-start' }}
395
- />
396
- {showDocPreview && (
397
- <Card border padding={3} radius={1} style={{ fontFamily: 'monospace', fontSize: 12 }}>
398
- <Stack space={2}>
399
- {[
400
- ['_id', entry.documentId],
401
- ['_type', 'font'],
402
- ['title', entry.title],
403
- ['slug', entry.documentId],
404
- ['typefaceName', typefaceTitle || '—'],
405
- ['weightName', entry.weightName || '—'],
406
- ['weight', entry.weight],
407
- ['style', entry.style],
408
- ['subfamily', entry.subfamily || '—'],
409
- ['variableFont', String(entry.variableFont)],
410
- ['price', price ?? '—'],
411
- ['sell', price > 0 ? 'true' : 'false'],
412
- ['normalWeight', 'true'],
413
- ['files', (entry.files || []).map(f => f.name).join(', ') || '—'],
414
- ].map(([key, value]) => (
415
- <Flex key={key} gap={2}>
416
- <Text size={0} muted style={{ width: 120, flexShrink: 0 }}>{key}</Text>
417
- <Text size={0} style={{ wordBreak: 'break-all' }}>{String(value)}</Text>
418
- </Flex>
419
- ))}
420
- </Stack>
421
- </Card>
422
- )}
423
- </Stack>
424
-
425
- {/* Actions — only show reset if user has overridden suggestions */}
426
- <Flex justify="flex-end" gap={2}>
427
- {hasUserOverrides && (
428
- <Button
429
- mode="ghost"
430
- tone="default"
431
- icon={ResetIcon}
432
- text="Reset to Suggestions"
433
- fontSize={1}
434
- padding={2}
435
- onClick={handleReset}
436
- />
437
- )}
438
- <Button
439
- mode="ghost"
440
- tone="critical"
441
- icon={TrashIcon}
442
- text="Remove"
443
- fontSize={1}
444
- padding={2}
445
- onClick={handleRemove}
446
- />
447
- </Flex>
448
- </Stack>
449
- </Box>
450
- )}
451
- </Card>
452
- );
453
- });
454
-
455
- export default FontReviewCard;
1
+ // Per-font review row — table-style header with expandable detail panel
2
+
3
+ import React, { useState, useCallback, useEffect, useMemo, memo } from 'react';
4
+ import { Card, Stack, Flex, Box, Text, TextInput, Badge, Button, Select, Tooltip, Label } from '@sanity/ui';
5
+ import { ChevronDownIcon, ChevronRightIcon, TrashIcon, ResetIcon, InfoOutlineIcon } from '@sanity/icons';
6
+ import { FONT_STATUS, RECOMMENDATION } from '../utils/planTypes';
7
+ import ExistingDocumentResolver from './ExistingDocumentResolver';
8
+
9
+ /** Standard file types shown in the files row */
10
+ const STANDARD_TYPES = ['ttf', 'otf', 'woff', 'woff2'];
11
+ /** Extended file types shown when expanded */
12
+ const EXTENDED_TYPES = ['eot', 'svg', 'css', 'woff2_subset', 'woff2_web'];
13
+
14
+ /**
15
+ * Collapsible review card for a single font in the upload plan.
16
+ * Table-style header row with weight/style/files/action columns.
17
+ */
18
+ const FontReviewCard = memo(function FontReviewCard({ entry, dispatch, allExpanded, typefaceTitle, price }) {
19
+ const [expanded, setExpanded] = useState(false);
20
+ const [showAllFileTypes, setShowAllFileTypes] = useState(false);
21
+ const [showDocPreview, setShowDocPreview] = useState(false);
22
+
23
+ // Sync with allExpanded toggle from BulkActions
24
+ useEffect(() => {
25
+ setExpanded(allExpanded);
26
+ }, [allExpanded]);
27
+
28
+ // Local state for typing — dispatches on blur
29
+ const [localTitle, setLocalTitle] = useState(entry.title);
30
+ const [localWeight, setLocalWeight] = useState(String(entry.weight));
31
+ const [localWeightName, setLocalWeightName] = useState(entry.weightName);
32
+ const [localSubfamily, setLocalSubfamily] = useState(entry.subfamily);
33
+
34
+ const [localDocId, setLocalDocId] = useState(entry.documentId);
35
+
36
+ const isError = entry.status === FONT_STATUS.ERROR;
37
+ const hasConflict = entry._idConflict;
38
+ const resolution = entry.decisions?.existingDocument;
39
+ const isUpdate = resolution?.userChoice === 'update' ||
40
+ (!resolution?.userChoice && (resolution?.recommendation === RECOMMENDATION.USE_EXACT || resolution?.recommendation === RECOMMENDATION.USE_CANDIDATE));
41
+
42
+ // Document ID is editable when user chose "create new" on a font with an existing match
43
+ const isCreateNewOverride = resolution?.userChoice === 'create' &&
44
+ (resolution?.exact || resolution?.candidates?.length > 0);
45
+ const docIdEditable = isCreateNewOverride || hasConflict;
46
+
47
+ // Detect if user has overridden any suggestions
48
+ const hasUserOverrides = useMemo(() => {
49
+ const d = entry.decisions;
50
+ if (!d) return false;
51
+ return (
52
+ d.title?.userOverride != null ||
53
+ d.weight?.userOverride != null ||
54
+ d.weightName?.userOverride != null ||
55
+ d.style?.userOverride != null ||
56
+ d.subfamily?.userOverride != null ||
57
+ d.documentId?.userOverride != null
58
+ );
59
+ }, [entry.decisions]);
60
+
61
+ // Map of which file extensions are present
62
+ const fileExtMap = useMemo(() => {
63
+ const map = {};
64
+ (entry.files || []).forEach(f => {
65
+ const ext = f.name?.split('.').pop()?.toLowerCase();
66
+ if (ext) map[ext] = f.name;
67
+ });
68
+ return map;
69
+ }, [entry.files]);
70
+
71
+ const fileCount = entry.files?.length || 0;
72
+ const cardTone = isError ? 'critical' : hasConflict ? 'caution' : 'default';
73
+
74
+ const handleTitleBlur = useCallback(() => {
75
+ if (localTitle !== entry.title) {
76
+ dispatch({ type: 'SET_FONT_TITLE', tempId: entry.tempId, title: localTitle });
77
+ }
78
+ }, [localTitle, entry.title, entry.tempId, dispatch]);
79
+
80
+ const handleDocIdBlur = useCallback(() => {
81
+ if (localDocId !== entry.documentId) {
82
+ dispatch({ type: 'SET_FONT_DOCUMENT_ID', tempId: entry.tempId, documentId: localDocId });
83
+ }
84
+ }, [localDocId, entry.documentId, entry.tempId, dispatch]);
85
+
86
+ const handleWeightBlur = useCallback(() => {
87
+ const num = Number(localWeight);
88
+ if (!isNaN(num) && num !== entry.weight) {
89
+ dispatch({ type: 'SET_FONT_WEIGHT', tempId: entry.tempId, weight: num });
90
+ }
91
+ }, [localWeight, entry.weight, entry.tempId, dispatch]);
92
+
93
+ const handleWeightNameBlur = useCallback(() => {
94
+ if (localWeightName !== entry.weightName) {
95
+ dispatch({ type: 'SET_FONT_WEIGHT_NAME', tempId: entry.tempId, weightName: localWeightName });
96
+ }
97
+ }, [localWeightName, entry.weightName, entry.tempId, dispatch]);
98
+
99
+ const handleStyleChange = useCallback((e) => {
100
+ dispatch({ type: 'SET_FONT_STYLE', tempId: entry.tempId, style: e.target.value });
101
+ }, [entry.tempId, dispatch]);
102
+
103
+ const handleSubfamilyBlur = useCallback(() => {
104
+ if (localSubfamily !== entry.subfamily) {
105
+ dispatch({ type: 'SET_FONT_SUBFAMILY', tempId: entry.tempId, subfamily: localSubfamily });
106
+ }
107
+ }, [localSubfamily, entry.subfamily, entry.tempId, dispatch]);
108
+
109
+ const handleReset = useCallback(() => {
110
+ dispatch({ type: 'RESET_FONT_TO_SUGGESTIONS', tempId: entry.tempId });
111
+ setLocalTitle(entry.decisions.title.processed);
112
+ setLocalDocId(entry.decisions.documentId.generated);
113
+ setLocalWeight(String(entry.decisions.weight.detected));
114
+ setLocalWeightName(entry.decisions.weightName.detected);
115
+ setLocalSubfamily(entry.decisions.subfamily.detected || 'Regular');
116
+ }, [entry, dispatch]);
117
+
118
+ const handleRemove = useCallback(() => {
119
+ dispatch({ type: 'REMOVE_FONT', tempId: entry.tempId });
120
+ }, [entry.tempId, dispatch]);
121
+
122
+ /** Format source string — converts nameId references to readable format, appends user override if present */
123
+ const formatSource = (decision) => {
124
+ if (!decision) return null;
125
+ let src = decision.source || '';
126
+ // Format name table references: nameId1-familyName → nameId1 (familyName)
127
+ src = src.replace(/nameId(\d+)-(\w+)/g, 'nameId$1 ($2)');
128
+ src = src.replace(/-/g, ' ').replace('fontkit ', '');
129
+ // Special case: default-empty for subfamily
130
+ if (src === 'default empty' && decision.detected === '') {
131
+ src = 'empty — defaults to "Regular"';
132
+ }
133
+ const reason = decision.reason ? ` (${decision.reason})` : '';
134
+ const override = decision.userOverride != null ? ' (user override)' : '';
135
+ return `Source: ${src}${reason}${override}`;
136
+ };
137
+
138
+ return (
139
+ <Card border radius={2} tone={cardTone} style={{ marginBottom: -1 }}>
140
+ {/* Header row — table-style columns */}
141
+ <Box
142
+ as="button"
143
+ onClick={() => !isError && setExpanded(v => !v)}
144
+ style={{
145
+ width: '100%',
146
+ background: expanded ? 'var(--card-muted-bg-color)' : 'none',
147
+ border: 'none',
148
+ cursor: isError ? 'default' : 'pointer',
149
+ textAlign: 'left',
150
+ padding: 0,
151
+ transition: 'background 0.1s ease',
152
+ }}
153
+ onMouseEnter={(e) => { if (!isError && !expanded) e.currentTarget.style.background = 'var(--card-muted-bg-color)'; }}
154
+ onMouseLeave={(e) => { if (!expanded) e.currentTarget.style.background = 'none'; }}
155
+ >
156
+ <Flex align="center" gap={2} paddingX={2} paddingY={2}>
157
+ <Box style={{ width: 20, flexShrink: 0 }}>
158
+ {!isError && (expanded ? <ChevronDownIcon /> : <ChevronRightIcon />)}
159
+ </Box>
160
+ <Box style={{ flex: 1, whiteSpace: 'nowrap',}}>
161
+ <Text size={1} weight="semibold" style={{ whiteSpace: 'nowrap' }}>
162
+ {entry.title || entry.sourceFileName}
163
+ {entry.variableFont && <Badge tone="primary" fontSize={0} style={{ marginLeft: 6 }}>VF</Badge>}
164
+ {hasConflict && <Badge tone="caution" fontSize={0} style={{ marginLeft: 6 }}>ID Conflict</Badge>}
165
+ </Text>
166
+ </Box>
167
+ <Text size={0} style={{ width: 50, textAlign: 'center', flexShrink: 0 }}>{entry.weight}</Text>
168
+ <Text size={0} style={{ width: 50, textAlign: 'center', flexShrink: 0 }}>{entry.style}</Text>
169
+ <Text size={0} style={{ width: 40, textAlign: 'center', flexShrink: 0 }}>{fileCount}</Text>
170
+ <Box style={{ width: 55, textAlign: 'center', flexShrink: 0 }}>
171
+ <Badge tone={isError ? 'critical' : isUpdate ? 'caution' : 'positive'} fontSize={0}>
172
+ {isError ? 'Error' : isUpdate ? 'Update' : 'Create'}
173
+ </Badge>
174
+ </Box>
175
+ </Flex>
176
+ </Box>
177
+
178
+ {/* Error message */}
179
+ {isError && (
180
+ <Box paddingX={2} paddingBottom={2}>
181
+ <Flex justify="space-between" align="center">
182
+ <Text size={0} muted>{entry.error}</Text>
183
+ <Button mode="bleed" tone="critical" icon={TrashIcon} padding={1} onClick={handleRemove} />
184
+ </Flex>
185
+ </Box>
186
+ )}
187
+
188
+ {/* Expanded detail panel */}
189
+ {expanded && !isError && (
190
+ <Box padding={3} style={{ borderTop: '1px solid var(--card-border-color)', background: 'var(--card-muted-bg-color)' }}>
191
+ <Stack space={4}>
192
+ {/* Files — standard types with grey for missing, expandable for extended */}
193
+ <Stack space={2}>
194
+ <Flex align="center" gap={2}>
195
+ <Label size={0}>Files ({fileCount})</Label>
196
+ <Button
197
+ mode="bleed"
198
+ fontSize={0}
199
+ padding={1}
200
+ text={showAllFileTypes ? 'Hide extended types' : 'Show all types'}
201
+ onClick={() => setShowAllFileTypes(v => !v)}
202
+ style={{ cursor: 'pointer' }}
203
+ />
204
+ </Flex>
205
+ <Flex gap={1} wrap="wrap">
206
+ {STANDARD_TYPES.map(ext => (
207
+ <Badge
208
+ key={ext}
209
+ fontSize={0}
210
+ tone={fileExtMap[ext] ? 'primary' : 'default'}
211
+ mode={fileExtMap[ext] ? 'default' : 'outline'}
212
+ style={{ opacity: fileExtMap[ext] ? 1 : 0.35 }}
213
+ >
214
+ {ext.toUpperCase()}{fileExtMap[ext] ? `: ${fileExtMap[ext]}` : ''}
215
+ </Badge>
216
+ ))}
217
+ {showAllFileTypes && EXTENDED_TYPES.map(ext => (
218
+ <Badge
219
+ key={ext}
220
+ fontSize={0}
221
+ tone={fileExtMap[ext] ? 'primary' : 'default'}
222
+ mode={fileExtMap[ext] ? 'default' : 'outline'}
223
+ style={{ opacity: fileExtMap[ext] ? 1 : 0.35 }}
224
+ >
225
+ {ext.toUpperCase()}{fileExtMap[ext] ? `: ${fileExtMap[ext]}` : ''}
226
+ </Badge>
227
+ ))}
228
+ </Flex>
229
+ </Stack>
230
+
231
+ {/* Title */}
232
+ <Stack space={2}>
233
+ <Label size={0}>Font Title</Label>
234
+ <TextInput
235
+ value={localTitle}
236
+ onChange={(e) => setLocalTitle(e.target.value)}
237
+ onBlur={handleTitleBlur}
238
+ fontSize={1}
239
+ />
240
+ <Text size={0} muted>{formatSource(entry.decisions?.title)}</Text>
241
+ {entry.decisions?.title?.alternatives?.length > 0 && (
242
+ <Flex gap={1} wrap="wrap">
243
+ {entry.decisions.title.alternatives.filter(a => a.value).map((alt, i) => (
244
+ <Tooltip key={i} content={<Box padding={1}><Text size={0}>{alt.source.replace(/nameId(\d+)-(\w+)/g, 'nameId$1 ($2)')}</Text></Box>} portal>
245
+ <Badge
246
+ mode="outline"
247
+ fontSize={0}
248
+ style={{ cursor: 'pointer' }}
249
+ onClick={() => {
250
+ setLocalTitle(alt.value);
251
+ dispatch({ type: 'SET_FONT_TITLE', tempId: entry.tempId, title: alt.value, source: alt.source });
252
+ }}
253
+ >
254
+ {alt.value.length > 30 ? alt.value.slice(0, 30) + '...' : alt.value}
255
+ </Badge>
256
+ </Tooltip>
257
+ ))}
258
+ </Flex>
259
+ )}
260
+ </Stack>
261
+
262
+ {/* Document ID */}
263
+ <Stack space={2}>
264
+ <Flex align="center" gap={1}>
265
+ <Label size={0}>Document ID</Label>
266
+ {!docIdEditable && (
267
+ <Tooltip
268
+ content={<Box padding={2} style={{ maxWidth: 260 }}><Text size={1} style={{ lineHeight: 1.6 }}>Document IDs must be unique. This field is auto-derived from the font title. It becomes editable when you choose "Create new instead" on a font with an existing match, or when there is a duplicate ID conflict.</Text></Box>}
269
+ placement="top"
270
+ portal
271
+ >
272
+ <InfoOutlineIcon style={{ opacity: 0.4, fontSize: 12 }} />
273
+ </Tooltip>
274
+ )}
275
+ </Flex>
276
+ <TextInput
277
+ value={docIdEditable ? localDocId : entry.documentId}
278
+ onChange={docIdEditable ? (e) => setLocalDocId(e.target.value) : undefined}
279
+ onBlur={docIdEditable ? handleDocIdBlur : undefined}
280
+ readOnly={!docIdEditable}
281
+ fontSize={1}
282
+ style={{ fontFamily: 'monospace', opacity: docIdEditable ? 1 : 0.7 }}
283
+ />
284
+ {hasConflict && (
285
+ <Text size={0} tone="caution">This ID conflicts with another font in this batch — edit to make unique</Text>
286
+ )}
287
+ {isCreateNewOverride && !hasConflict && (
288
+ <Text size={0} tone="caution">Creating new document — edit the ID to avoid overwriting the existing document</Text>
289
+ )}
290
+ {!docIdEditable && (
291
+ <Text size={0} muted>Auto-derived from font title</Text>
292
+ )}
293
+ </Stack>
294
+
295
+ {/* Weight + Weight Name row */}
296
+ <Flex gap={3}>
297
+ <Box style={{ flex: 1 }}>
298
+ <Stack space={2}>
299
+ <Label size={0}>Weight</Label>
300
+ <TextInput
301
+ type="number"
302
+ value={localWeight}
303
+ onChange={(e) => setLocalWeight(e.target.value)}
304
+ onBlur={handleWeightBlur}
305
+ fontSize={1}
306
+ />
307
+ {entry.decisions?.weight && (
308
+ <Text size={0} muted>{formatSource(entry.decisions.weight)}</Text>
309
+ )}
310
+ </Stack>
311
+ </Box>
312
+ <Box style={{ flex: 1 }}>
313
+ <Stack space={2}>
314
+ <Label size={0}>Weight Name</Label>
315
+ <TextInput
316
+ value={localWeightName}
317
+ onChange={(e) => setLocalWeightName(e.target.value)}
318
+ onBlur={handleWeightNameBlur}
319
+ fontSize={1}
320
+ />
321
+ {entry.decisions?.weightName && (
322
+ <Text size={0} muted>{formatSource(entry.decisions.weightName)}</Text>
323
+ )}
324
+ </Stack>
325
+ </Box>
326
+ </Flex>
327
+
328
+ {/* Style + Subfamily row */}
329
+ <Flex gap={3}>
330
+ <Box style={{ flex: 1 }}>
331
+ <Stack space={2}>
332
+ <Label size={0}>Style</Label>
333
+ <Select value={entry.style} onChange={handleStyleChange} fontSize={1}>
334
+ <option value="Regular">Regular</option>
335
+ <option value="Italic">Italic</option>
336
+ </Select>
337
+ {entry.decisions?.style && (
338
+ <Text size={0} muted>{formatSource(entry.decisions.style)}</Text>
339
+ )}
340
+ </Stack>
341
+ </Box>
342
+ <Box style={{ flex: 1 }}>
343
+ <Stack space={2}>
344
+ <Label size={0}>Subfamily</Label>
345
+ <TextInput
346
+ value={localSubfamily}
347
+ onChange={(e) => setLocalSubfamily(e.target.value)}
348
+ onBlur={handleSubfamilyBlur}
349
+ fontSize={1}
350
+ />
351
+ {entry.decisions?.subfamily && (
352
+ <Text size={0} muted>{formatSource(entry.decisions.subfamily)}</Text>
353
+ )}
354
+ </Stack>
355
+ </Box>
356
+ </Flex>
357
+
358
+ {/* VF axes info */}
359
+ {entry.variableFont && entry.variationAxes && (
360
+ <Stack space={2}>
361
+ <Label size={0}>Variable Font Axes</Label>
362
+ <Flex gap={1} wrap="wrap">
363
+ {Object.entries(entry.variationAxes).map(([tag, axis]) => (
364
+ <Badge key={tag} mode="outline" fontSize={0}>
365
+ {tag} {axis.min}–{axis.max}
366
+ </Badge>
367
+ ))}
368
+ </Flex>
369
+ {entry.decisions?.weight?.userOverride != null && entry.variationAxes?.wght && (
370
+ (entry.weight < entry.variationAxes.wght.min || entry.weight > entry.variationAxes.wght.max) && (
371
+ <Text size={0} tone="caution">
372
+ Weight {entry.weight} is outside the wght axis range ({entry.variationAxes.wght.min}–{entry.variationAxes.wght.max})
373
+ </Text>
374
+ )
375
+ )}
376
+ </Stack>
377
+ )}
378
+
379
+ {/* Existing document resolution */}
380
+ <ExistingDocumentResolver
381
+ decision={entry.decisions?.existingDocument}
382
+ tempId={entry.tempId}
383
+ dispatch={dispatch}
384
+ />
385
+
386
+ {/* Document Preview — expandable view of all fields that will be written */}
387
+ <Stack space={2}>
388
+ <Button
389
+ mode="bleed"
390
+ fontSize={0}
391
+ padding={1}
392
+ text={showDocPreview ? 'Hide document preview' : 'Show document preview'}
393
+ onClick={() => setShowDocPreview(v => !v)}
394
+ style={{ cursor: 'pointer', alignSelf: 'flex-start' }}
395
+ />
396
+ {showDocPreview && (
397
+ <Card border padding={3} radius={1} style={{ fontFamily: 'monospace', fontSize: 12 }}>
398
+ <Stack space={2}>
399
+ {[
400
+ ['_id', entry.documentId],
401
+ ['_type', 'font'],
402
+ ['title', entry.title],
403
+ ['slug', entry.documentId],
404
+ ['typefaceName', typefaceTitle || '—'],
405
+ ['weightName', entry.weightName || '—'],
406
+ ['weight', entry.weight],
407
+ ['style', entry.style],
408
+ ['subfamily', entry.subfamily || '—'],
409
+ ['variableFont', String(entry.variableFont)],
410
+ ['price', price ?? '—'],
411
+ ['sell', price > 0 ? 'true' : 'false'],
412
+ ['normalWeight', 'true'],
413
+ ['files', (entry.files || []).map(f => f.name).join(', ') || '—'],
414
+ ].map(([key, value]) => (
415
+ <Flex key={key} gap={2}>
416
+ <Text size={0} muted style={{ width: 120, flexShrink: 0 }}>{key}</Text>
417
+ <Text size={0} style={{ wordBreak: 'break-all' }}>{String(value)}</Text>
418
+ </Flex>
419
+ ))}
420
+ </Stack>
421
+ </Card>
422
+ )}
423
+ </Stack>
424
+
425
+ {/* Actions — only show reset if user has overridden suggestions */}
426
+ <Flex justify="flex-end" gap={2}>
427
+ {hasUserOverrides && (
428
+ <Button
429
+ mode="ghost"
430
+ tone="default"
431
+ icon={ResetIcon}
432
+ text="Reset to Suggestions"
433
+ fontSize={1}
434
+ padding={2}
435
+ onClick={handleReset}
436
+ />
437
+ )}
438
+ <Button
439
+ mode="ghost"
440
+ tone="critical"
441
+ icon={TrashIcon}
442
+ text="Remove"
443
+ fontSize={1}
444
+ padding={2}
445
+ onClick={handleRemove}
446
+ />
447
+ </Flex>
448
+ </Stack>
449
+ </Box>
450
+ )}
451
+ </Card>
452
+ );
453
+ });
454
+
455
+ export default FontReviewCard;