@liiift-studio/sanity-font-manager 2.4.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/UploadModal-ADNRGQUI.mjs +6 -0
  2. package/dist/UploadModal-WPK2CXLR.js +6 -0
  3. package/dist/chunk-JCDZ7SWZ.js +7711 -0
  4. package/dist/chunk-TMDE4A54.mjs +7711 -0
  5. package/dist/index.js +666 -1647
  6. package/dist/index.mjs +319 -1209
  7. package/package.json +5 -5
  8. package/src/components/BatchUploadFonts.jsx +57 -44
  9. package/src/components/BulkActions.jsx +99 -0
  10. package/src/components/ExistingDocumentResolver.jsx +152 -0
  11. package/src/components/FontReviewCard.jsx +455 -0
  12. package/src/components/SingleUploaderTool.jsx +3 -4
  13. package/src/components/UploadModal.jsx +304 -0
  14. package/src/components/UploadScriptsComponent.jsx +23 -21
  15. package/src/components/UploadStep1Settings.jsx +272 -0
  16. package/src/components/UploadStep2Review.jsx +474 -0
  17. package/src/components/UploadStep3Execute.jsx +234 -0
  18. package/src/components/UploadStep3bInstances.jsx +396 -0
  19. package/src/components/UploadSummary.jsx +196 -0
  20. package/src/index.js +46 -0
  21. package/src/utils/buildUploadPlan.js +326 -0
  22. package/src/utils/executeUploadPlan.js +430 -0
  23. package/src/utils/executionReducer.js +56 -0
  24. package/src/utils/fontHelpers.js +267 -0
  25. package/src/utils/generateCssFile.js +79 -77
  26. package/src/utils/generateFontData.js +47 -94
  27. package/src/utils/getEmptyFontKit.js +19 -17
  28. package/src/utils/parseFont.js +55 -0
  29. package/src/utils/parseVariableFontInstances.js +237 -147
  30. package/src/utils/planReducer.js +517 -0
  31. package/src/utils/planTypes.js +183 -0
  32. package/src/utils/processFontFiles.js +121 -78
  33. package/src/utils/regenerateFontData.js +2 -2
  34. package/src/utils/resolveExistingFont.js +87 -0
  35. package/src/utils/updateTypefaceDocument.js +15 -2
  36. package/src/utils/uploadFontFiles.js +405 -405
@@ -0,0 +1,474 @@
1
+ // Step 2 — Processing & Review with settings, FontReviewCard, BulkActions, and subfamily grouping
2
+
3
+ import React, { useMemo, useState, useCallback, useEffect } from 'react';
4
+ import { Box, Grid, Stack, Flex, Text, Label, Switch, Button, Card, Spinner, Badge, Tooltip } from '@sanity/ui';
5
+ import { InfoOutlineIcon } from '@sanity/icons';
6
+ import { PLAN_PHASE, FONT_STATUS, RECOMMENDATION } from '../utils/planTypes';
7
+ import FontReviewCard from './FontReviewCard';
8
+ import BulkActions from './BulkActions';
9
+ import PriceInput from './PriceInput';
10
+
11
+ /** Determines whether a font entry will create or update a document */
12
+ function isUpdateEntry(entry) {
13
+ const d = entry.decisions?.existingDocument;
14
+ const choice = d?.userChoice;
15
+ const rec = d?.recommendation;
16
+ return choice === 'update' || (!choice && (rec === RECOMMENDATION.USE_EXACT || rec === RECOMMENDATION.USE_CANDIDATE));
17
+ }
18
+
19
+ /**
20
+ * Step 2 — displays processing progress and font review cards with full editing.
21
+ */
22
+ export default function UploadStep2Review({
23
+ plan,
24
+ dispatch,
25
+ onCancelProcessing,
26
+ onReadyToUpload,
27
+ onStartExecution,
28
+ processingCancelled,
29
+ }) {
30
+ const isProcessing = plan.phase === PLAN_PHASE.PROCESSING;
31
+ const isReviewing = plan.phase === PLAN_PHASE.REVIEWING || plan.phase === PLAN_PHASE.READY;
32
+
33
+ // Settings state (editable in review)
34
+ const [localPrice, setLocalPrice] = useState(String(plan.settings?.price || 0));
35
+ const [localPreserveShortenedNames, setLocalPreserveShortenedNames] = useState(plan.settings?.preserveShortenedNames ?? true);
36
+ const [localPreserveFileNames, setLocalPreserveFileNames] = useState(plan.settings?.preserveFileNames ?? false);
37
+
38
+ // Search, filter, and sort state
39
+ const [searchQuery, setSearchQuery] = useState('');
40
+ const [filterBy, setFilterBy] = useState('all');
41
+ const [allExpanded, setAllExpanded] = useState(false);
42
+ const [sortBy, setSortBy] = useState('weight');
43
+ const [sortDir, setSortDir] = useState('asc');
44
+
45
+ const fontEntries = useMemo(() => Object.values(plan.fonts), [plan.fonts]);
46
+ const processedCount = fontEntries.filter(f => f.status === FONT_STATUS.PROCESSED).length;
47
+ const errorCount = fontEntries.filter(f => f.status === FONT_STATUS.ERROR).length;
48
+ const totalCount = plan.processingProgress.total;
49
+
50
+ // Debug: log all fonts with their subfamily assignment for investigation
51
+ useEffect(() => {
52
+ if (!isReviewing) return;
53
+ const processed = fontEntries.filter(f => f.status !== FONT_STATUS.ERROR);
54
+ if (processed.length === 0) return;
55
+
56
+ // Group by subfamily for clear logging
57
+ const bySubfamily = {};
58
+ processed.forEach(f => {
59
+ const sf = f.subfamily || '(none)';
60
+ if (!bySubfamily[sf]) bySubfamily[sf] = [];
61
+ bySubfamily[sf].push(f);
62
+ });
63
+
64
+ console.group('[UploadStep2Review] Subfamily assignments');
65
+ Object.entries(bySubfamily).forEach(([sf, fonts]) => {
66
+ console.group(`Subfamily: "${sf}" (${fonts.length} font${fonts.length === 1 ? '' : 's'})`);
67
+ fonts.forEach(f => {
68
+ console.log(` "${f.title}" (id: ${f.documentId})`, {
69
+ sourceFile: f.sourceFileName,
70
+ weightName: f.weightName,
71
+ style: f.style,
72
+ subfamily: f.subfamily,
73
+ subfamilyDecision: f.decisions?.subfamily,
74
+ parsedMetadata: {
75
+ familyName: f.parsedMetadata?.familyName,
76
+ fullName: f.parsedMetadata?.fullName,
77
+ preferredFamily: f.parsedMetadata?.preferredFamily,
78
+ preferredSubfamily: f.parsedMetadata?.preferredSubfamily,
79
+ },
80
+ });
81
+ });
82
+ console.groupEnd();
83
+ });
84
+ console.groupEnd();
85
+ }, [isReviewing, fontEntries]);
86
+
87
+ // Count creates vs updates
88
+ const createCount = useMemo(() =>
89
+ fontEntries.filter(f => f.status !== FONT_STATUS.ERROR && !isUpdateEntry(f)).length,
90
+ [fontEntries]
91
+ );
92
+ const updateCount = useMemo(() =>
93
+ fontEntries.filter(f => f.status !== FONT_STATUS.ERROR && isUpdateEntry(f)).length,
94
+ [fontEntries]
95
+ );
96
+
97
+ // Filter and search
98
+ const visibleEntries = useMemo(() => {
99
+ let entries = fontEntries;
100
+
101
+ // Apply filter
102
+ if (filterBy === 'create') {
103
+ entries = entries.filter(f => !isUpdateEntry(f) && f.status !== FONT_STATUS.ERROR);
104
+ } else if (filterBy === 'update') {
105
+ entries = entries.filter(f => isUpdateEntry(f));
106
+ } else if (filterBy === 'error') {
107
+ entries = entries.filter(f => f.status === FONT_STATUS.ERROR);
108
+ } else if (filterBy === 'conflict') {
109
+ entries = entries.filter(f => f._idConflict);
110
+ } else if (filterBy === 'style:italic') {
111
+ entries = entries.filter(f => f.style === 'Italic' && f.status !== FONT_STATUS.ERROR);
112
+ } else if (filterBy === 'style:regular') {
113
+ entries = entries.filter(f => f.style === 'Regular' && f.status !== FONT_STATUS.ERROR);
114
+ } else if (filterBy.startsWith('sf:')) {
115
+ const sf = filterBy.slice(3);
116
+ entries = entries.filter(f => f.subfamily === sf);
117
+ }
118
+
119
+ // Apply search
120
+ if (searchQuery.trim()) {
121
+ const q = searchQuery.toLowerCase().trim();
122
+ entries = entries.filter(f =>
123
+ f.title?.toLowerCase().includes(q) ||
124
+ f.documentId?.toLowerCase().includes(q) ||
125
+ f.sourceFileName?.toLowerCase().includes(q) ||
126
+ f.weightName?.toLowerCase().includes(q)
127
+ );
128
+ }
129
+
130
+ return entries;
131
+ }, [fontEntries, filterBy, searchQuery]);
132
+
133
+ const visibleTempIds = useMemo(() => visibleEntries.map(e => e.tempId), [visibleEntries]);
134
+ const hasConflicts = fontEntries.some(f => f._idConflict);
135
+
136
+ /** Sort comparator based on current sortBy/sortDir */
137
+ const sortEntries = useCallback((a, b) => {
138
+ const dir = sortDir === 'asc' ? 1 : -1;
139
+ let cmp = 0;
140
+ switch (sortBy) {
141
+ case 'title':
142
+ cmp = (a.title || '').localeCompare(b.title || '');
143
+ break;
144
+ case 'weight':
145
+ cmp = a.weight - b.weight;
146
+ if (cmp === 0) {
147
+ // Secondary sort: italic after regular at same weight
148
+ if (a.style === 'Italic' && b.style !== 'Italic') cmp = 1;
149
+ else if (a.style !== 'Italic' && b.style === 'Italic') cmp = -1;
150
+ }
151
+ break;
152
+ case 'style':
153
+ cmp = (a.style || '').localeCompare(b.style || '');
154
+ break;
155
+ case 'files':
156
+ cmp = (a.files?.length || 0) - (b.files?.length || 0);
157
+ break;
158
+ case 'action': {
159
+ const aUpdate = isUpdateEntry(a) ? 1 : 0;
160
+ const bUpdate = isUpdateEntry(b) ? 1 : 0;
161
+ cmp = aUpdate - bUpdate;
162
+ break;
163
+ }
164
+ default:
165
+ cmp = a.weight - b.weight;
166
+ }
167
+ return cmp * dir;
168
+ }, [sortBy, sortDir]);
169
+
170
+ /** Toggle sort — click same column to flip direction, different column to sort asc */
171
+ const handleSort = useCallback((column) => {
172
+ if (sortBy === column) {
173
+ setSortDir(d => d === 'asc' ? 'desc' : 'asc');
174
+ } else {
175
+ setSortBy(column);
176
+ setSortDir('asc');
177
+ }
178
+ }, [sortBy]);
179
+
180
+ // Group by subfamily for display — always show headers
181
+ const groupedEntries = useMemo(() => {
182
+ const groups = {};
183
+ for (const entry of visibleEntries) {
184
+ const sf = entry.subfamily || 'Regular';
185
+ if (!groups[sf]) groups[sf] = [];
186
+ groups[sf].push(entry);
187
+ }
188
+ // Sort entries within each group
189
+ Object.values(groups).forEach(g => g.sort(sortEntries));
190
+ // Sort groups: "Regular" first, then alphabetically
191
+ const sorted = {};
192
+ const keys = Object.keys(groups).sort((a, b) => {
193
+ if (a === 'Regular') return -1;
194
+ if (b === 'Regular') return 1;
195
+ return a.localeCompare(b);
196
+ });
197
+ keys.forEach(k => { sorted[k] = groups[k]; });
198
+ return sorted;
199
+ }, [visibleEntries]);
200
+
201
+ // Validation
202
+ const validationErrors = useMemo(() => {
203
+ const errors = [];
204
+ const uploadable = fontEntries.filter(f => f.status !== FONT_STATUS.ERROR);
205
+ const missingTitles = uploadable.filter(f => !f.title || f.title.trim() === '');
206
+ if (missingTitles.length > 0) {
207
+ errors.push(`${missingTitles.length} font${missingTitles.length === 1 ? '' : 's'} missing a title`);
208
+ }
209
+ const missingIds = uploadable.filter(f => !f.documentId || f.documentId.trim() === '');
210
+ if (missingIds.length > 0) {
211
+ errors.push(`${missingIds.length} font${missingIds.length === 1 ? '' : 's'} missing a document ID`);
212
+ }
213
+ if (hasConflicts) {
214
+ const conflictCount = uploadable.filter(f => f._idConflict).length;
215
+ errors.push(`${conflictCount} font${conflictCount === 1 ? '' : 's'} with duplicate document IDs`);
216
+ }
217
+ return errors;
218
+ }, [fontEntries, hasConflicts]);
219
+
220
+ const canUploadValidation = isReviewing && processedCount > 0 && validationErrors.length === 0;
221
+
222
+ const handleUpload = useCallback(() => {
223
+ if (validationErrors.length > 0) {
224
+ window.alert('Please fix the following before uploading:\n\n• ' + validationErrors.join('\n• '));
225
+ return;
226
+ }
227
+ onStartExecution();
228
+ }, [validationErrors, onStartExecution]);
229
+
230
+ const handleToggleExpandAll = useCallback(() => {
231
+ setAllExpanded(v => !v);
232
+ }, []);
233
+
234
+ return (
235
+ <Stack space={3}>
236
+ {/* Processing progress */}
237
+ {isProcessing && (
238
+ <Card border padding={3} radius={2}>
239
+ <Stack space={3}>
240
+ <Flex align="center" gap={3}>
241
+ <Spinner />
242
+ <Text size={1}>
243
+ Processing {plan.processingProgress.completed} of {totalCount} files...
244
+ </Text>
245
+ </Flex>
246
+ <Box style={{ height: 4, background: 'var(--card-border-color)', borderRadius: 2, overflow: 'hidden' }}>
247
+ <Box
248
+ style={{
249
+ height: '100%',
250
+ width: '100%',
251
+ transformOrigin: 'left',
252
+ transform: `scaleX(${totalCount > 0 ? plan.processingProgress.completed / totalCount : 0})`,
253
+ background: '#43b649',
254
+ transition: 'transform 0.3s ease-out',
255
+ borderRadius: 2,
256
+ }}
257
+ />
258
+ </Box>
259
+ {plan.processingProgress.currentFile && (
260
+ <Text size={0} muted style={{ fontFamily: 'monospace' }}>
261
+ {plan.processingProgress.currentFile}
262
+ </Text>
263
+ )}
264
+ <Flex justify="flex-end">
265
+ <Button
266
+ mode="ghost"
267
+ tone="caution"
268
+ text="Cancel Processing"
269
+ fontSize={1}
270
+ padding={2}
271
+ onClick={onCancelProcessing}
272
+ />
273
+ </Flex>
274
+ </Stack>
275
+ </Card>
276
+ )}
277
+
278
+ {/* Processing complete summary */}
279
+ {isReviewing && (
280
+ <Card tone={errorCount > 0 ? 'caution' : 'positive'} border padding={3} radius={2}>
281
+ <Flex align="center" gap={3}>
282
+ <Text size={1} weight="semibold">
283
+ {processedCount} document{processedCount === 1 ? '' : 's'}
284
+ </Text>
285
+ <Flex gap={1}>
286
+ {createCount > 0 && <Badge tone="positive" fontSize={0}>{createCount} create</Badge>}
287
+ {updateCount > 0 && <Badge tone="caution" fontSize={0}>{updateCount} update</Badge>}
288
+ {errorCount > 0 && <Badge tone="critical" fontSize={0}>{errorCount} error{errorCount === 1 ? '' : 's'}</Badge>}
289
+ </Flex>
290
+ </Flex>
291
+ </Card>
292
+ )}
293
+
294
+ {/* Settings — price and naming options */}
295
+ {isReviewing && (
296
+ <>
297
+ <Text size={1} weight="semibold">Settings</Text>
298
+ <Card border padding={3} radius={2}>
299
+ <Stack space={3}>
300
+ <Grid columns={[2]} gap={4}>
301
+ <Box>
302
+ <PriceInput
303
+ inputPrice={localPrice}
304
+ handleInputChange={(e) => {
305
+ setLocalPrice(e.target.value);
306
+ dispatch({ type: 'SET_SETTINGS', settings: { price: Number(e.target.value) || 0 } });
307
+ }}
308
+ />
309
+ </Box>
310
+ <Stack space={3}>
311
+ <Flex align="center" gap={2}>
312
+ <Switch
313
+ checked={localPreserveShortenedNames}
314
+ onChange={(e) => {
315
+ setLocalPreserveShortenedNames(e.target.checked);
316
+ dispatch({ type: 'SET_SETTINGS', settings: { preserveShortenedNames: e.target.checked } });
317
+ }}
318
+ />
319
+ <Tooltip
320
+ content={<Box padding={2} style={{ maxWidth: 260 }}><Text size={1} style={{ lineHeight: 1.6 }}>Abbreviations in font names are kept as-is (e.g. "XNarrow" stays "XNarrow").</Text></Box>}
321
+ placement="top"
322
+ portal
323
+ >
324
+ <Flex align="center" gap={1} style={{ cursor: 'default' }}>
325
+ <Label>Preserve shortened names</Label>
326
+ <InfoOutlineIcon style={{ opacity: 0.5, display: 'block' }} />
327
+ </Flex>
328
+ </Tooltip>
329
+ </Flex>
330
+ <Flex align="center" gap={2}>
331
+ <Switch
332
+ checked={localPreserveFileNames}
333
+ onChange={(e) => {
334
+ setLocalPreserveFileNames(e.target.checked);
335
+ dispatch({ type: 'SET_SETTINGS', settings: { preserveFileNames: e.target.checked } });
336
+ }}
337
+ />
338
+ <Tooltip
339
+ content={<Box padding={2} style={{ maxWidth: 260 }}><Text size={1} style={{ lineHeight: 1.6 }}>Original filename is used for the font title and document ID instead of embedded font metadata.</Text></Box>}
340
+ placement="top"
341
+ portal
342
+ >
343
+ <Flex align="center" gap={1} style={{ cursor: 'default' }}>
344
+ <Label>Preserve file names</Label>
345
+ <InfoOutlineIcon style={{ opacity: 0.5, display: 'block' }} />
346
+ </Flex>
347
+ </Tooltip>
348
+ </Flex>
349
+ </Stack>
350
+ </Grid>
351
+ </Stack>
352
+ </Card>
353
+ </>
354
+ )}
355
+
356
+ {/* Bulk actions bar */}
357
+ {fontEntries.length > 0 && (
358
+ <BulkActions
359
+ fonts={plan.fonts}
360
+ dispatch={dispatch}
361
+ searchQuery={searchQuery}
362
+ onSearchChange={setSearchQuery}
363
+ filterBy={filterBy}
364
+ onFilterChange={setFilterBy}
365
+ allExpanded={allExpanded}
366
+ onToggleExpandAll={handleToggleExpandAll}
367
+ visibleTempIds={visibleTempIds}
368
+ />
369
+ )}
370
+
371
+ {/* Expand All — between filters and column headers */}
372
+ {fontEntries.length > 0 && isReviewing && (
373
+ <Flex justify="flex-start" paddingY={1}>
374
+ <Button
375
+ mode="ghost"
376
+ fontSize={0}
377
+ padding={2}
378
+ text={allExpanded ? 'Collapse All' : 'Expand All'}
379
+ onClick={handleToggleExpandAll}
380
+ style={{ cursor: 'pointer' }}
381
+ />
382
+ </Flex>
383
+ )}
384
+
385
+ {/* Sortable column header row */}
386
+ {fontEntries.length > 0 && isReviewing && (
387
+ <Flex
388
+ align="center"
389
+ gap={2}
390
+ paddingX={2}
391
+ paddingY={1}
392
+ style={{ borderBottom: '1px solid var(--card-border-color)', userSelect: 'none' }}
393
+ >
394
+ <Box style={{ width: 20 }} />
395
+ {[
396
+ { key: 'title', label: 'Font Title', style: { flex: 1, cursor: 'pointer' } },
397
+ { key: 'weight', label: 'Weight', style: { width: 50, textAlign: 'center', cursor: 'pointer' } },
398
+ { key: 'style', label: 'Style', style: { width: 50, textAlign: 'center', cursor: 'pointer' } },
399
+ { key: 'files', label: 'Files', style: { width: 40, textAlign: 'center', cursor: 'pointer' } },
400
+ { key: 'action', label: 'Action', style: { width: 55, textAlign: 'center', cursor: 'pointer' } },
401
+ ].map(col => (
402
+ <Text
403
+ key={col.key}
404
+ size={0}
405
+ weight="semibold"
406
+ muted={sortBy !== col.key}
407
+ style={col.style}
408
+ onClick={() => handleSort(col.key)}
409
+ >
410
+ {col.label} {sortBy === col.key ? (sortDir === 'asc' ? '↑' : '↓') : ''}
411
+ </Text>
412
+ ))}
413
+ </Flex>
414
+ )}
415
+
416
+ {/* Font cards grouped by subfamily */}
417
+ {Object.entries(groupedEntries).map(([subfamily, entries]) => (
418
+ <Stack key={subfamily} space={1}>
419
+ <Card padding={2} radius={1} style={{ background: 'var(--card-muted-bg-color)' }}>
420
+ <Flex align="center" gap={2}>
421
+ <Text size={1} weight="semibold">
422
+ {subfamily}
423
+ </Text>
424
+ <Badge mode="outline" fontSize={0}>{entries.length}</Badge>
425
+ </Flex>
426
+ </Card>
427
+ <Stack space={0}>
428
+ {entries.map(entry => (
429
+ <FontReviewCard
430
+ key={entry.tempId}
431
+ entry={entry}
432
+ dispatch={dispatch}
433
+ allExpanded={allExpanded}
434
+ typefaceTitle={plan.settings?.typefaceTitle}
435
+ price={plan.settings?.price}
436
+ />
437
+ ))}
438
+ </Stack>
439
+ </Stack>
440
+ ))}
441
+
442
+ {/* Empty state */}
443
+ {visibleEntries.length === 0 && fontEntries.length > 0 && (
444
+ <Card border padding={4} radius={2}>
445
+ <Text size={1} muted align="center">No fonts match the current filter</Text>
446
+ </Card>
447
+ )}
448
+
449
+ {/* Validation errors */}
450
+ {isReviewing && validationErrors.length > 0 && (
451
+ <Card tone="caution" border padding={2} radius={2}>
452
+ <Stack space={1}>
453
+ {validationErrors.map((err, i) => (
454
+ <Text key={i} size={0} tone="caution">• {err}</Text>
455
+ ))}
456
+ </Stack>
457
+ </Card>
458
+ )}
459
+
460
+ {/* Upload button */}
461
+ {isReviewing && processedCount > 0 && (
462
+ <Flex justify="flex-end" gap={2} style={{ position: 'sticky', bottom: 0, background: 'var(--card-bg-color)', paddingTop: 8, paddingBottom: 4 }}>
463
+ <Button
464
+ mode="default"
465
+ tone="primary"
466
+ text={`Upload ${processedCount} Font${processedCount === 1 ? '' : 's'} to Sanity`}
467
+ disabled={!canUploadValidation}
468
+ onClick={handleUpload}
469
+ />
470
+ </Flex>
471
+ )}
472
+ </Stack>
473
+ );
474
+ }