@liiift-studio/sanity-font-manager 2.7.0 → 2.7.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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +695 -437
  3. package/dist/index.js +1634 -17839
  4. package/dist/index.mjs +1551 -17745
  5. package/package.json +83 -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,655 +1,655 @@
1
- // Batch font uploader — drag-and-drop file list, confirm-to-upload, elapsed timer, Wake Lock, and beforeunload guard for long uploads
2
-
3
- import React, { useCallback, useState, useMemo, useRef, useEffect, lazy, Suspense } from 'react';
4
- import { Card, Box, Flex, Grid, Text, Label, Switch, Button, Spinner, Tooltip, Stack } from '@sanity/ui';
5
- import { ControlsIcon, InfoOutlineIcon, TrashIcon, UploadIcon, WarningOutlineIcon } from '@sanity/icons';
6
- import { useFormValue } from 'sanity';
7
-
8
- const UploadModal = lazy(() => import('./UploadModal'));
9
-
10
- import { useSanityClient } from '../hooks/useSanityClient';
11
- import { processFontFiles } from '../utils/processFontFiles';
12
- import { uploadFontFiles } from '../utils/uploadFontFiles';
13
- import { updateTypefaceDocument } from '../utils/updateTypefaceDocument';
14
- import { generateStyleKeywords } from '../utils/generateKeywords';
15
- import { renameFontDocuments } from '../utils/regenerateFontData';
16
- import { updateFontPrices } from '../utils/updateFontPrices';
17
- import generateCssFile from '../utils/generateCssFile';
18
-
19
- import StatusDisplay from './StatusDisplay';
20
- import PriceInput from './PriceInput';
21
- import { RegenerateSubfamiliesComponent } from './RegenerateSubfamiliesComponent';
22
-
23
- // Accepted font file extensions
24
- const ACCEPTED_EXTENSIONS = ['ttf', 'otf', 'woff', 'woff2', 'eot', 'svg'];
25
-
26
- /** Formats elapsed seconds as "Xm Ys" or "Ys". */
27
- const formatElapsed = (s) => {
28
- const m = Math.floor(s / 60);
29
- const sec = s % 60;
30
- return m > 0 ? `${m}m ${sec}s` : `${sec}s`;
31
- };
32
-
33
- export const BatchUploadFonts = (props) => {
34
- const defaults = props?.schemaType?.options?.defaults || {};
35
- const [status, setStatus] = useState('ready');
36
- const [ready, setReady] = useState(true);
37
- const [inputPrice, setInputPrice] = useState(String(defaults.price ?? '0'));
38
- const [error, setError] = useState(false);
39
- const [preserveShortenedNames, setPreserveShortenedNames] = useState(defaults.preserveShortenedNames ?? true);
40
- const [preserveFileNames, setPreserveFileNames] = useState(defaults.preserveFileNames ?? false);
41
- const [showUtilities, setShowUtilities] = useState(false);
42
- const [pendingFiles, setPendingFiles] = useState([]);
43
- const [isDragging, setIsDragging] = useState(false);
44
- const [elapsedSeconds, setElapsedSeconds] = useState(0);
45
- const [showUploadModal, setShowUploadModal] = useState(false);
46
-
47
- const fileInputRef = useRef(null);
48
- const elapsedTimerRef = useRef(null);
49
- const wakeLockRef = useRef(null);
50
-
51
- const client = useSanityClient();
52
-
53
- const doc_id = useFormValue(['_id']);
54
- const title = useFormValue(['title']);
55
- const preferredStyleRef = useFormValue(['preferredStyle']);
56
- const slug = useFormValue(['slug']);
57
- const stylesObject = useFormValue(['styles']) || { fonts: [], variableFont: [] };
58
- const subfamiliesArray = stylesObject?.subfamilies || [];
59
-
60
- const { weightKeywordList, italicKeywordList } = useMemo(() => generateStyleKeywords(), []);
61
-
62
- // Elapsed timer — runs while upload is in progress
63
- useEffect(() => {
64
- if (ready !== true) {
65
- setElapsedSeconds(0);
66
- elapsedTimerRef.current = setInterval(() => {
67
- setElapsedSeconds(s => s + 1);
68
- }, 1000);
69
- } else {
70
- clearInterval(elapsedTimerRef.current);
71
- }
72
- return () => clearInterval(elapsedTimerRef.current);
73
- }, [ready]);
74
-
75
- // Warn before navigating away while an upload is running
76
- useEffect(() => {
77
- if (ready !== true) {
78
- const handler = (e) => { e.preventDefault(); e.returnValue = ''; };
79
- window.addEventListener('beforeunload', handler);
80
- return () => window.removeEventListener('beforeunload', handler);
81
- }
82
- }, [ready]);
83
-
84
- // Wake Lock — prevents the screen from sleeping during long uploads
85
- useEffect(() => {
86
- if (ready !== true) {
87
- navigator.wakeLock?.request('screen')
88
- .then(lock => { wakeLockRef.current = lock; })
89
- .catch(() => {});
90
- } else if (wakeLockRef.current) {
91
- wakeLockRef.current.release().catch(() => {});
92
- wakeLockRef.current = null;
93
- }
94
- }, [ready]);
95
-
96
- /** Validates that title and price are set before starting an upload. */
97
- const validateInputs = (title, inputPrice) => {
98
- const price = Number(inputPrice);
99
- if (!title) {
100
- setStatus('Typeface needs a title');
101
- setError(true);
102
- return false;
103
- }
104
- if (isNaN(price) || typeof price !== 'number') {
105
- setStatus('Invalid price — please refresh and try again');
106
- setError(true);
107
- return false;
108
- }
109
- return true;
110
- };
111
-
112
- /** Sorts font files so TTF/OTF are processed before web formats. */
113
- const sortFilesByType = (files) => {
114
- if (!files) return [];
115
- const typeOrder = ['ttf', 'otf', 'eot', 'svg', 'woff', 'woff2'];
116
- return Array.from(files).sort((a, b) => {
117
- const aIndex = typeOrder.indexOf(a.name.split('.').pop().toLowerCase());
118
- const bIndex = typeOrder.indexOf(b.name.split('.').pop().toLowerCase());
119
- if (aIndex === bIndex) return a.name.localeCompare(b.name);
120
- return aIndex - bIndex;
121
- });
122
- };
123
-
124
- /** Returns only files with accepted font extensions. */
125
- const filterFontFiles = (files) =>
126
- Array.from(files).filter(f => ACCEPTED_EXTENSIONS.includes(f.name.split('.').pop().toLowerCase()));
127
-
128
- /** Sets final status after upload completes, reporting any failed files. */
129
- const handleCompletionStatus = (failedFiles, setError, setStatus) => {
130
- if (failedFiles.length > 0) {
131
- console.error('Failed uploads:', {
132
- files: failedFiles,
133
- names: failedFiles.map(f => f.name),
134
- metadata: failedFiles.map(f => f?.fk?.name?.records),
135
- });
136
- setError(true);
137
- setStatus(`Upload completed with errors. Failed files: ${failedFiles.map(f => f.name).join(', ')}`);
138
- } else {
139
- setError(false);
140
- setStatus('Upload completed successfully');
141
- }
142
- };
143
-
144
- /** Adds files from the file picker to the pending list. */
145
- const handleFileSelect = useCallback((e) => {
146
- const files = filterFontFiles(e.target.files);
147
- if (files.length > 0) setPendingFiles(prev => [...prev, ...files]);
148
- e.target.value = '';
149
- }, []);
150
-
151
- /** Removes a single file from the pending list by object reference. */
152
- const handleRemoveFile = useCallback((file) => {
153
- setPendingFiles(prev => prev.filter(f => f !== file));
154
- }, []);
155
-
156
- const handleDragEnter = useCallback((e) => { e.preventDefault(); setIsDragging(true); }, []);
157
- const handleDragOver = useCallback((e) => { e.preventDefault(); }, []);
158
- const handleDragLeave = useCallback((e) => { e.preventDefault(); setIsDragging(false); }, []);
159
-
160
- /** Adds dropped font files to the pending list. */
161
- const handleDrop = useCallback((e) => {
162
- e.preventDefault();
163
- setIsDragging(false);
164
- const files = filterFontFiles(e.dataTransfer.files);
165
- if (files.length > 0) setPendingFiles(prev => [...prev, ...files]);
166
- }, []);
167
-
168
- /** Processes and uploads the confirmed pending file list. */
169
- const handleConfirmUpload = useCallback(async () => {
170
- try {
171
- setStatus('Uploading font files...');
172
- setReady('upload');
173
- setError(false);
174
-
175
- if (!validateInputs(title, inputPrice)) {
176
- setReady(true);
177
- return false;
178
- }
179
-
180
- const sortedFiles = sortFilesByType(pendingFiles);
181
- setPendingFiles([]);
182
-
183
- const { fontsObjects, subfamilies, uniqueSubfamilies, newPreferredStyle, failedFiles } =
184
- await processFontFiles(
185
- sortedFiles,
186
- title,
187
- weightKeywordList,
188
- italicKeywordList,
189
- setStatus,
190
- preserveShortenedNames,
191
- preserveFileNames,
192
- );
193
-
194
- const { fontRefs, variableRefs } = await uploadFontFiles(
195
- fontsObjects,
196
- subfamilies,
197
- client,
198
- inputPrice,
199
- stylesObject,
200
- setStatus,
201
- setError,
202
- preserveFileNames,
203
- );
204
-
205
- await updateTypefaceDocument(
206
- doc_id,
207
- fontRefs,
208
- variableRefs,
209
- subfamilies,
210
- uniqueSubfamilies,
211
- subfamiliesArray,
212
- preferredStyleRef,
213
- newPreferredStyle,
214
- stylesObject,
215
- client,
216
- setStatus,
217
- setError,
218
- );
219
-
220
- handleCompletionStatus(failedFiles, setError, setStatus);
221
- } catch (e) {
222
- console.error(e.message);
223
- setError(true);
224
- setStatus('Error uploading font');
225
- }
226
-
227
- setReady(true);
228
- setError(false);
229
- }, [pendingFiles, stylesObject, title, slug, doc_id, inputPrice, weightKeywordList, italicKeywordList, client, preferredStyleRef, subfamiliesArray, preserveShortenedNames, preserveFileNames]);
230
-
231
- /** Renames all existing font documents in this typeface by re-reading their TTF metadata. */
232
- const handleRenameExistingFonts = useCallback(async () => {
233
- try {
234
- setStatus('Processing font documents...');
235
- setReady('rename');
236
- setError(false);
237
-
238
- if (!title) {
239
- setStatus('Typeface needs a title');
240
- setError(true);
241
- setReady(true);
242
- return false;
243
- }
244
-
245
- const result = await renameFontDocuments({
246
- client,
247
- typefaceName: title,
248
- slug,
249
- weightKeywordList,
250
- italicKeywordList,
251
- preserveShortenedNames,
252
- setStatus,
253
- setError,
254
- });
255
-
256
- if (!result.success) setError(true);
257
- } catch (err) {
258
- console.error('Error renaming font documents:', err);
259
- setError(true);
260
- setStatus(`Error: ${err.message}`);
261
- }
262
- setReady(true);
263
- }, [title, client, slug, weightKeywordList, italicKeywordList, preserveShortenedNames]);
264
-
265
- /** Bulk-sets the same price on every font in this typeface. */
266
- const handleChangeFontPrice = useCallback(async () => {
267
- setStatus('Updating font prices...');
268
- setReady('price');
269
- setError(false);
270
-
271
- await updateFontPrices({ client, title, slug, inputPrice, doc_id, setStatus, setError });
272
-
273
- setReady(true);
274
- }, [title, slug, client, doc_id, inputPrice]);
275
-
276
- /** Regenerates the CSS @font-face file for every font in this typeface from its woff2 asset. */
277
- const handleRegenerateCssFiles = useCallback(async () => {
278
- try {
279
- setStatus('Regenerating CSS files...');
280
- setReady('css');
281
- setError(false);
282
-
283
- if (!title) { setStatus('Typeface needs a title'); setError(true); setReady(true); return false; }
284
- if (!slug?.current) { setStatus('Typeface needs a slug'); setError(true); setReady(true); return false; }
285
-
286
- const typeface = await client.fetch(
287
- `*[_type == "typeface" && slug.current == $slug][0]`,
288
- { slug: slug.current }
289
- );
290
-
291
- if (!typeface) { setStatus('Typeface not found'); setError(true); setReady(true); return false; }
292
- if (!typeface.styles?.fonts?.length) { setStatus('No fonts found in typeface'); setError(true); setReady(true); return false; }
293
-
294
- const fontRefs = typeface.styles.fonts;
295
- setStatus(`Regenerating CSS for ${fontRefs.length} fonts...`);
296
-
297
- let updatedCount = 0;
298
- let errorCount = 0;
299
-
300
- for (let i = 0; i < fontRefs.length; i++) {
301
- try {
302
- const fontDoc = await client.fetch(`*[_id == $id][0]`, { id: fontRefs[i]._ref });
303
- if (!fontDoc) { errorCount++; continue; }
304
- if (!fontDoc.fileInput?.woff2?.asset) { errorCount++; continue; }
305
-
306
- const woff2Asset = await client.fetch(`*[_id == $id][0]`, { id: fontDoc.fileInput.woff2.asset._ref });
307
- if (!woff2Asset?.url) { errorCount++; continue; }
308
-
309
- const woff2Response = await fetch(woff2Asset.url);
310
- const woff2Blob = await woff2Response.blob();
311
- const woff2File = new File([woff2Blob], `${fontDoc._id}.woff2`, { type: 'font/woff2' });
312
-
313
- setStatus(`Regenerating CSS for font ${i + 1}/${fontRefs.length}: ${fontDoc.title}`);
314
-
315
- const updatedFileInput = await generateCssFile({
316
- woff2File,
317
- fileInput: fontDoc.fileInput,
318
- fileName: fontDoc._id,
319
- fontName: fontDoc.title,
320
- variableFont: fontDoc.variableFont || false,
321
- weight: fontDoc.weight || 400,
322
- client,
323
- style: fontDoc.style || 'normal',
324
- });
325
-
326
- await client.patch(fontRefs[i]._ref).set({ fileInput: updatedFileInput }).commit();
327
- updatedCount++;
328
- setStatus(`Regenerated CSS for ${updatedCount}/${fontRefs.length} fonts...`);
329
- } catch (err) {
330
- console.error(`Error regenerating CSS for font ${fontRefs[i]._ref}:`, err);
331
- errorCount++;
332
- }
333
- }
334
-
335
- const successMessage = `Successfully regenerated CSS for ${updatedCount} fonts${errorCount > 0 ? ` (${errorCount} errors)` : ''}`;
336
- setStatus(successMessage);
337
- if (errorCount > 0) setError(true);
338
- } catch (err) {
339
- console.error('Error regenerating CSS files:', err);
340
- setError(true);
341
- setStatus(`Error: ${err.message}`);
342
- }
343
- setReady(true);
344
- }, [title, slug, client]);
345
-
346
- /** Handles price field changes. */
347
- const handleInputChange = (e) => {
348
- setInputPrice(e.target.value);
349
- setError(false);
350
- setStatus('ready');
351
- };
352
-
353
- /** Renders an info-icon tooltip trigger wrapping a label. */
354
- const renderTooltipLabel = (label, description) => (
355
- <Tooltip
356
- content={<Box padding={2} style={{ maxWidth: 260 }}><Text size={1} style={{ lineHeight: 1.6 }}>{description}</Text></Box>}
357
- placement="top"
358
- portal
359
- >
360
- <Flex align="center" gap={1} style={{ cursor: 'default' }}>
361
- <Label>{label}</Label>
362
- <InfoOutlineIcon style={{ opacity: 0.5, display: 'block' }} />
363
- </Flex>
364
- </Tooltip>
365
- );
366
-
367
- /** Renders the in-progress state: spinner, live status, elapsed time, and do-not-close warning. */
368
- const renderProcessing = () => (
369
- <Stack space={3} paddingY={2}>
370
- <Flex align="center" gap={3}>
371
- <Spinner />
372
- <Text size={1} muted>{status}</Text>
373
- </Flex>
374
- <Card tone="caution" border radius={2} padding={2}>
375
- <Flex align="center" justify="space-between" gap={2}>
376
- <Flex align="center" gap={2}>
377
- <WarningOutlineIcon style={{ flexShrink: 0 }} />
378
- <Text size={1} weight="semibold">Do not close or reload this tab</Text>
379
- </Flex>
380
- <Text size={1} muted style={{ flexShrink: 0 }}>{formatElapsed(elapsedSeconds)}</Text>
381
- </Flex>
382
- </Card>
383
- </Stack>
384
- );
385
-
386
- /** Renders the drag-and-drop zone. */
387
- const renderDropZone = () => (
388
- <Box
389
- onDragEnter={handleDragEnter}
390
- onDragOver={handleDragOver}
391
- onDragLeave={handleDragLeave}
392
- onDrop={handleDrop}
393
- style={{
394
- border: `2px dashed ${isDragging ? 'var(--card-focus-ring-color)' : 'var(--card-border-color)'}`,
395
- borderRadius: 4,
396
- padding: '28px 16px',
397
- textAlign: 'center',
398
- background: isDragging ? 'rgba(100, 153, 255, 0.06)' : 'transparent',
399
- transition: 'border-color 0.12s, background 0.12s',
400
- cursor: 'default',
401
- }}
402
- >
403
- <input
404
- ref={fileInputRef}
405
- type="file"
406
- multiple
407
- hidden
408
- accept=".ttf,.otf,.woff,.woff2,.eot,.svg"
409
- onChange={handleFileSelect}
410
- />
411
- <Stack space={3}>
412
- <Text size={1} muted>
413
- {isDragging ? 'Release to add files' : 'Drop font files here'}
414
- </Text>
415
- <Flex justify="center">
416
- <Button
417
- mode="ghost"
418
- tone="primary"
419
- fontSize={1}
420
- padding={2}
421
- text="Browse files"
422
- onClick={() => fileInputRef.current?.click()}
423
- />
424
- </Flex>
425
- </Stack>
426
- </Box>
427
- );
428
-
429
- /** Renders the sorted pending file list with a scrollable container, file count, and upload action. */
430
- const renderFileList = () => {
431
- const sorted = sortFilesByType(pendingFiles);
432
- return (
433
- <Stack space={2}>
434
- {/* Header: file count + clear */}
435
- <Flex align="center" justify="space-between">
436
- <Text size={1} muted>
437
- {pendingFiles.length} file{pendingFiles.length === 1 ? '' : 's'} selected
438
- </Text>
439
- <Button
440
- mode="bleed"
441
- tone="default"
442
- fontSize={1}
443
- padding={1}
444
- text="Clear all"
445
- onClick={() => setPendingFiles([])}
446
- />
447
- </Flex>
448
-
449
- {/* Scrollable file list */}
450
- <Box style={{ maxHeight: '260px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '4px' }}>
451
- {sorted.map((file, i) => {
452
- const ext = file.name.split('.').pop().toUpperCase();
453
- return (
454
- <Card key={`${file.name}-${file.size}-${i}`} border radius={1} paddingX={2} paddingY={2}>
455
- <Flex justify="space-between" align="center" gap={2}>
456
- <Flex gap={3} align="center" style={{ flex: 1, minWidth: 0 }}>
457
- <Text
458
- size={0}
459
- style={{ fontFamily: 'monospace', minWidth: '2.5rem', flexShrink: 0 }}
460
- >
461
- {ext}
462
- </Text>
463
- <Box style={{ flex: 1, minWidth: 0 }}>
464
- <Text size={1}>{file.name}</Text>
465
- </Box>
466
- </Flex>
467
- <Button
468
- mode="bleed"
469
- tone="critical"
470
- icon={TrashIcon}
471
- padding={2}
472
- onClick={() => handleRemoveFile(file)}
473
- />
474
- </Flex>
475
- </Card>
476
- );
477
- })}
478
- </Box>
479
-
480
- {/* Add more files zone */}
481
- <Box
482
- onDragEnter={handleDragEnter}
483
- onDragOver={handleDragOver}
484
- onDragLeave={handleDragLeave}
485
- onDrop={handleDrop}
486
- style={{
487
- border: `2px dashed ${isDragging ? 'var(--card-focus-ring-color)' : 'var(--card-border-color)'}`,
488
- borderRadius: 4,
489
- padding: '10px 16px',
490
- textAlign: 'center',
491
- background: isDragging ? 'rgba(100, 153, 255, 0.06)' : 'transparent',
492
- transition: 'border-color 0.12s, background 0.12s',
493
- }}
494
- >
495
- <input
496
- ref={fileInputRef}
497
- type="file"
498
- multiple
499
- hidden
500
- accept=".ttf,.otf,.woff,.woff2,.eot,.svg"
501
- onChange={handleFileSelect}
502
- />
503
- <Flex align="center" justify="center" gap={2}>
504
- <Text size={1} muted>{isDragging ? 'Release to add' : 'Drop more files or'}</Text>
505
- <Button
506
- mode="bleed"
507
- tone="primary"
508
- fontSize={1}
509
- padding={1}
510
- text="browse"
511
- onClick={() => fileInputRef.current?.click()}
512
- />
513
- </Flex>
514
- </Box>
515
-
516
- {/* Upload confirm */}
517
- <Button
518
- mode="ghost"
519
- tone="primary"
520
- icon={UploadIcon}
521
- text={`Upload ${pendingFiles.length} Font${pendingFiles.length === 1 ? '' : 's'}`}
522
- style={{ width: '100%' }}
523
- onClick={handleConfirmUpload}
524
- />
525
- </Stack>
526
- );
527
- };
528
-
529
- const hasRequiredFields = title && title !== '' && slug && slug !== '';
530
-
531
- return (
532
- <>
533
- {!hasRequiredFields && (
534
- <Card border padding={4} radius={2} tone="caution">
535
- <Flex align="center" gap={3}>
536
- <Text size={2}>
537
- <WarningOutlineIcon />
538
- </Text>
539
- <Stack space={2}>
540
- <Text size={1} weight="semibold">
541
- {!title || title === '' ? 'Title required to use font uploader' : 'Slug required to use font uploader'}
542
- </Text>
543
- <Text size={1} muted>
544
- Add a {!title || title === '' ? 'title' : 'slug'} to this typeface document, then return to the Styles tab to upload fonts.
545
- </Text>
546
- </Stack>
547
- </Flex>
548
- </Card>
549
- )}
550
- {hasRequiredFields &&
551
- <>
552
- <StatusDisplay
553
- status={status}
554
- error={error}
555
- action={
556
- <Button
557
- mode={showUtilities ? 'default' : 'ghost'}
558
- tone="primary"
559
- icon={ControlsIcon}
560
- text="Utilities"
561
- fontSize={1}
562
- padding={2}
563
- onClick={() => setShowUtilities(v => !v)}
564
- />
565
- }
566
- />
567
-
568
- <Button
569
- mode="default"
570
- tone="primary"
571
- icon={UploadIcon}
572
- text="Upload Fonts"
573
- fontSize={2}
574
- padding={4}
575
- onClick={() => setShowUploadModal(true)}
576
- style={{ width: '100%' }}
577
- />
578
-
579
- {/* New upload modal */}
580
- {showUploadModal && (
581
- <Suspense fallback={<Spinner />}>
582
- <UploadModal
583
- open={showUploadModal}
584
- onClose={() => setShowUploadModal(false)}
585
- client={client}
586
- docId={doc_id}
587
- typefaceTitle={title}
588
- stylesObject={stylesObject}
589
- preferredStyleRef={preferredStyleRef}
590
- slug={slug}
591
- defaults={defaults}
592
- />
593
- </Suspense>
594
- )}
595
-
596
- {/* Utilities panel — toggled via the Utilities button */}
597
- {showUtilities && (
598
- <Card border padding={3} shadow={1} radius={2} marginTop={3}>
599
- <Stack space={4}>
600
-
601
- {/* Regenerate Subfamilies */}
602
- <Stack space={2}>
603
- <Text size={1} weight="semibold" style={{ lineHeight: 1.6 }}>Regenerate Subfamilies</Text>
604
- <RegenerateSubfamiliesComponent />
605
- </Stack>
606
-
607
- {/* Rename Fonts */}
608
- <Stack space={3}>
609
- <Text size={1} weight="semibold" style={{ lineHeight: 1.6 }}>Rename Fonts (name table, Full Name)</Text>
610
- <Flex align="center" gap={2}>
611
- <Switch
612
- checked={preserveShortenedNames}
613
- onChange={(e) => setPreserveShortenedNames(e.target.checked)}
614
- />
615
- {renderTooltipLabel(
616
- 'Preserve shortened names',
617
- 'Abbreviations in font names are kept as-is (e.g. "XNarrow" stays "XNarrow", "Bd" stays "Bd").'
618
- )}
619
- </Flex>
620
- {ready === 'rename'
621
- ? renderProcessing()
622
- : <Button mode="ghost" tone="primary" text="Rename Existing Fonts" style={{ width: '100%' }} onClick={handleRenameExistingFonts} disabled={ready !== true} />
623
- }
624
- </Stack>
625
-
626
- {/* Update Font Prices */}
627
- <Stack space={3}>
628
- <Text size={1} weight="semibold" style={{ lineHeight: 1.6 }}>Update Font Prices</Text>
629
- {ready === 'price'
630
- ? renderProcessing()
631
- : <Stack space={2}>
632
- <PriceInput inputPrice={inputPrice} handleInputChange={handleInputChange} />
633
- <Button mode="ghost" tone="primary" text="Update All Font Prices" style={{ width: '100%' }} onClick={handleChangeFontPrice} disabled={ready !== true} />
634
- </Stack>
635
- }
636
- </Stack>
637
-
638
- {/* Regenerate CSS */}
639
- <Stack space={3}>
640
- <Text size={1} weight="semibold" style={{ lineHeight: 1.6 }}>Regenerate CSS</Text>
641
- <Text size={1} muted style={{ lineHeight: 1.6 }}>Rebuilds the CSS @font-face files for all fonts in the typeface fonts list.</Text>
642
- {ready === 'css'
643
- ? renderProcessing()
644
- : <Button mode="ghost" tone="primary" text="Regenerate CSS Files" style={{ width: '100%' }} onClick={handleRegenerateCssFiles} disabled={ready !== true} />
645
- }
646
- </Stack>
647
-
648
- </Stack>
649
- </Card>
650
- )}
651
- </>
652
- }
653
- </>
654
- );
655
- };
1
+ // Batch font uploader — drag-and-drop file list, confirm-to-upload, elapsed timer, Wake Lock, and beforeunload guard for long uploads
2
+
3
+ import React, { useCallback, useState, useMemo, useRef, useEffect, lazy, Suspense } from 'react';
4
+ import { Card, Box, Flex, Grid, Text, Label, Switch, Button, Spinner, Tooltip, Stack } from '@sanity/ui';
5
+ import { ControlsIcon, InfoOutlineIcon, TrashIcon, UploadIcon, WarningOutlineIcon } from '@sanity/icons';
6
+ import { useFormValue } from 'sanity';
7
+
8
+ const UploadModal = lazy(() => import('./UploadModal'));
9
+
10
+ import { useSanityClient } from '../hooks/useSanityClient';
11
+ import { processFontFiles } from '../utils/processFontFiles';
12
+ import { uploadFontFiles } from '../utils/uploadFontFiles';
13
+ import { updateTypefaceDocument } from '../utils/updateTypefaceDocument';
14
+ import { generateStyleKeywords } from '../utils/generateKeywords';
15
+ import { renameFontDocuments } from '../utils/regenerateFontData';
16
+ import { updateFontPrices } from '../utils/updateFontPrices';
17
+ import generateCssFile from '../utils/generateCssFile';
18
+
19
+ import StatusDisplay from './StatusDisplay';
20
+ import PriceInput from './PriceInput';
21
+ import { RegenerateSubfamiliesComponent } from './RegenerateSubfamiliesComponent';
22
+
23
+ // Accepted font file extensions
24
+ const ACCEPTED_EXTENSIONS = ['ttf', 'otf', 'woff', 'woff2', 'eot', 'svg'];
25
+
26
+ /** Formats elapsed seconds as "Xm Ys" or "Ys". */
27
+ const formatElapsed = (s) => {
28
+ const m = Math.floor(s / 60);
29
+ const sec = s % 60;
30
+ return m > 0 ? `${m}m ${sec}s` : `${sec}s`;
31
+ };
32
+
33
+ export const BatchUploadFonts = (props) => {
34
+ const defaults = props?.schemaType?.options?.defaults || {};
35
+ const [status, setStatus] = useState('ready');
36
+ const [ready, setReady] = useState(true);
37
+ const [inputPrice, setInputPrice] = useState(String(defaults.price ?? '0'));
38
+ const [error, setError] = useState(false);
39
+ const [preserveShortenedNames, setPreserveShortenedNames] = useState(defaults.preserveShortenedNames ?? true);
40
+ const [preserveFileNames, setPreserveFileNames] = useState(defaults.preserveFileNames ?? false);
41
+ const [showUtilities, setShowUtilities] = useState(false);
42
+ const [pendingFiles, setPendingFiles] = useState([]);
43
+ const [isDragging, setIsDragging] = useState(false);
44
+ const [elapsedSeconds, setElapsedSeconds] = useState(0);
45
+ const [showUploadModal, setShowUploadModal] = useState(false);
46
+
47
+ const fileInputRef = useRef(null);
48
+ const elapsedTimerRef = useRef(null);
49
+ const wakeLockRef = useRef(null);
50
+
51
+ const client = useSanityClient();
52
+
53
+ const doc_id = useFormValue(['_id']);
54
+ const title = useFormValue(['title']);
55
+ const preferredStyleRef = useFormValue(['preferredStyle']);
56
+ const slug = useFormValue(['slug']);
57
+ const stylesObject = useFormValue(['styles']) || { fonts: [], variableFont: [] };
58
+ const subfamiliesArray = stylesObject?.subfamilies || [];
59
+
60
+ const { weightKeywordList, italicKeywordList } = useMemo(() => generateStyleKeywords(), []);
61
+
62
+ // Elapsed timer — runs while upload is in progress
63
+ useEffect(() => {
64
+ if (ready !== true) {
65
+ setElapsedSeconds(0);
66
+ elapsedTimerRef.current = setInterval(() => {
67
+ setElapsedSeconds(s => s + 1);
68
+ }, 1000);
69
+ } else {
70
+ clearInterval(elapsedTimerRef.current);
71
+ }
72
+ return () => clearInterval(elapsedTimerRef.current);
73
+ }, [ready]);
74
+
75
+ // Warn before navigating away while an upload is running
76
+ useEffect(() => {
77
+ if (ready !== true) {
78
+ const handler = (e) => { e.preventDefault(); e.returnValue = ''; };
79
+ window.addEventListener('beforeunload', handler);
80
+ return () => window.removeEventListener('beforeunload', handler);
81
+ }
82
+ }, [ready]);
83
+
84
+ // Wake Lock — prevents the screen from sleeping during long uploads
85
+ useEffect(() => {
86
+ if (ready !== true) {
87
+ navigator.wakeLock?.request('screen')
88
+ .then(lock => { wakeLockRef.current = lock; })
89
+ .catch(() => {});
90
+ } else if (wakeLockRef.current) {
91
+ wakeLockRef.current.release().catch(() => {});
92
+ wakeLockRef.current = null;
93
+ }
94
+ }, [ready]);
95
+
96
+ /** Validates that title and price are set before starting an upload. */
97
+ const validateInputs = (title, inputPrice) => {
98
+ const price = Number(inputPrice);
99
+ if (!title) {
100
+ setStatus('Typeface needs a title');
101
+ setError(true);
102
+ return false;
103
+ }
104
+ if (isNaN(price) || typeof price !== 'number') {
105
+ setStatus('Invalid price — please refresh and try again');
106
+ setError(true);
107
+ return false;
108
+ }
109
+ return true;
110
+ };
111
+
112
+ /** Sorts font files so TTF/OTF are processed before web formats. */
113
+ const sortFilesByType = (files) => {
114
+ if (!files) return [];
115
+ const typeOrder = ['ttf', 'otf', 'eot', 'svg', 'woff', 'woff2'];
116
+ return Array.from(files).sort((a, b) => {
117
+ const aIndex = typeOrder.indexOf(a.name.split('.').pop().toLowerCase());
118
+ const bIndex = typeOrder.indexOf(b.name.split('.').pop().toLowerCase());
119
+ if (aIndex === bIndex) return a.name.localeCompare(b.name);
120
+ return aIndex - bIndex;
121
+ });
122
+ };
123
+
124
+ /** Returns only files with accepted font extensions. */
125
+ const filterFontFiles = (files) =>
126
+ Array.from(files).filter(f => ACCEPTED_EXTENSIONS.includes(f.name.split('.').pop().toLowerCase()));
127
+
128
+ /** Sets final status after upload completes, reporting any failed files. */
129
+ const handleCompletionStatus = (failedFiles, setError, setStatus) => {
130
+ if (failedFiles.length > 0) {
131
+ console.error('Failed uploads:', {
132
+ files: failedFiles,
133
+ names: failedFiles.map(f => f.name),
134
+ metadata: failedFiles.map(f => f?.fk?.name?.records),
135
+ });
136
+ setError(true);
137
+ setStatus(`Upload completed with errors. Failed files: ${failedFiles.map(f => f.name).join(', ')}`);
138
+ } else {
139
+ setError(false);
140
+ setStatus('Upload completed successfully');
141
+ }
142
+ };
143
+
144
+ /** Adds files from the file picker to the pending list. */
145
+ const handleFileSelect = useCallback((e) => {
146
+ const files = filterFontFiles(e.target.files);
147
+ if (files.length > 0) setPendingFiles(prev => [...prev, ...files]);
148
+ e.target.value = '';
149
+ }, []);
150
+
151
+ /** Removes a single file from the pending list by object reference. */
152
+ const handleRemoveFile = useCallback((file) => {
153
+ setPendingFiles(prev => prev.filter(f => f !== file));
154
+ }, []);
155
+
156
+ const handleDragEnter = useCallback((e) => { e.preventDefault(); setIsDragging(true); }, []);
157
+ const handleDragOver = useCallback((e) => { e.preventDefault(); }, []);
158
+ const handleDragLeave = useCallback((e) => { e.preventDefault(); setIsDragging(false); }, []);
159
+
160
+ /** Adds dropped font files to the pending list. */
161
+ const handleDrop = useCallback((e) => {
162
+ e.preventDefault();
163
+ setIsDragging(false);
164
+ const files = filterFontFiles(e.dataTransfer.files);
165
+ if (files.length > 0) setPendingFiles(prev => [...prev, ...files]);
166
+ }, []);
167
+
168
+ /** Processes and uploads the confirmed pending file list. */
169
+ const handleConfirmUpload = useCallback(async () => {
170
+ try {
171
+ setStatus('Uploading font files...');
172
+ setReady('upload');
173
+ setError(false);
174
+
175
+ if (!validateInputs(title, inputPrice)) {
176
+ setReady(true);
177
+ return false;
178
+ }
179
+
180
+ const sortedFiles = sortFilesByType(pendingFiles);
181
+ setPendingFiles([]);
182
+
183
+ const { fontsObjects, subfamilies, uniqueSubfamilies, newPreferredStyle, failedFiles } =
184
+ await processFontFiles(
185
+ sortedFiles,
186
+ title,
187
+ weightKeywordList,
188
+ italicKeywordList,
189
+ setStatus,
190
+ preserveShortenedNames,
191
+ preserveFileNames,
192
+ );
193
+
194
+ const { fontRefs, variableRefs } = await uploadFontFiles(
195
+ fontsObjects,
196
+ subfamilies,
197
+ client,
198
+ inputPrice,
199
+ stylesObject,
200
+ setStatus,
201
+ setError,
202
+ preserveFileNames,
203
+ );
204
+
205
+ await updateTypefaceDocument(
206
+ doc_id,
207
+ fontRefs,
208
+ variableRefs,
209
+ subfamilies,
210
+ uniqueSubfamilies,
211
+ subfamiliesArray,
212
+ preferredStyleRef,
213
+ newPreferredStyle,
214
+ stylesObject,
215
+ client,
216
+ setStatus,
217
+ setError,
218
+ );
219
+
220
+ handleCompletionStatus(failedFiles, setError, setStatus);
221
+ } catch (e) {
222
+ console.error(e.message);
223
+ setError(true);
224
+ setStatus('Error uploading font');
225
+ }
226
+
227
+ setReady(true);
228
+ setError(false);
229
+ }, [pendingFiles, stylesObject, title, slug, doc_id, inputPrice, weightKeywordList, italicKeywordList, client, preferredStyleRef, subfamiliesArray, preserveShortenedNames, preserveFileNames]);
230
+
231
+ /** Renames all existing font documents in this typeface by re-reading their TTF metadata. */
232
+ const handleRenameExistingFonts = useCallback(async () => {
233
+ try {
234
+ setStatus('Processing font documents...');
235
+ setReady('rename');
236
+ setError(false);
237
+
238
+ if (!title) {
239
+ setStatus('Typeface needs a title');
240
+ setError(true);
241
+ setReady(true);
242
+ return false;
243
+ }
244
+
245
+ const result = await renameFontDocuments({
246
+ client,
247
+ typefaceName: title,
248
+ slug,
249
+ weightKeywordList,
250
+ italicKeywordList,
251
+ preserveShortenedNames,
252
+ setStatus,
253
+ setError,
254
+ });
255
+
256
+ if (!result.success) setError(true);
257
+ } catch (err) {
258
+ console.error('Error renaming font documents:', err);
259
+ setError(true);
260
+ setStatus(`Error: ${err.message}`);
261
+ }
262
+ setReady(true);
263
+ }, [title, client, slug, weightKeywordList, italicKeywordList, preserveShortenedNames]);
264
+
265
+ /** Bulk-sets the same price on every font in this typeface. */
266
+ const handleChangeFontPrice = useCallback(async () => {
267
+ setStatus('Updating font prices...');
268
+ setReady('price');
269
+ setError(false);
270
+
271
+ await updateFontPrices({ client, title, slug, inputPrice, doc_id, setStatus, setError });
272
+
273
+ setReady(true);
274
+ }, [title, slug, client, doc_id, inputPrice]);
275
+
276
+ /** Regenerates the CSS @font-face file for every font in this typeface from its woff2 asset. */
277
+ const handleRegenerateCssFiles = useCallback(async () => {
278
+ try {
279
+ setStatus('Regenerating CSS files...');
280
+ setReady('css');
281
+ setError(false);
282
+
283
+ if (!title) { setStatus('Typeface needs a title'); setError(true); setReady(true); return false; }
284
+ if (!slug?.current) { setStatus('Typeface needs a slug'); setError(true); setReady(true); return false; }
285
+
286
+ const typeface = await client.fetch(
287
+ `*[_type == "typeface" && slug.current == $slug][0]`,
288
+ { slug: slug.current }
289
+ );
290
+
291
+ if (!typeface) { setStatus('Typeface not found'); setError(true); setReady(true); return false; }
292
+ if (!typeface.styles?.fonts?.length) { setStatus('No fonts found in typeface'); setError(true); setReady(true); return false; }
293
+
294
+ const fontRefs = typeface.styles.fonts;
295
+ setStatus(`Regenerating CSS for ${fontRefs.length} fonts...`);
296
+
297
+ let updatedCount = 0;
298
+ let errorCount = 0;
299
+
300
+ for (let i = 0; i < fontRefs.length; i++) {
301
+ try {
302
+ const fontDoc = await client.fetch(`*[_id == $id][0]`, { id: fontRefs[i]._ref });
303
+ if (!fontDoc) { errorCount++; continue; }
304
+ if (!fontDoc.fileInput?.woff2?.asset) { errorCount++; continue; }
305
+
306
+ const woff2Asset = await client.fetch(`*[_id == $id][0]`, { id: fontDoc.fileInput.woff2.asset._ref });
307
+ if (!woff2Asset?.url) { errorCount++; continue; }
308
+
309
+ const woff2Response = await fetch(woff2Asset.url);
310
+ const woff2Blob = await woff2Response.blob();
311
+ const woff2File = new File([woff2Blob], `${fontDoc._id}.woff2`, { type: 'font/woff2' });
312
+
313
+ setStatus(`Regenerating CSS for font ${i + 1}/${fontRefs.length}: ${fontDoc.title}`);
314
+
315
+ const updatedFileInput = await generateCssFile({
316
+ woff2File,
317
+ fileInput: fontDoc.fileInput,
318
+ fileName: fontDoc._id,
319
+ fontName: fontDoc.title,
320
+ variableFont: fontDoc.variableFont || false,
321
+ weight: fontDoc.weight || 400,
322
+ client,
323
+ style: fontDoc.style || 'normal',
324
+ });
325
+
326
+ await client.patch(fontRefs[i]._ref).set({ fileInput: updatedFileInput }).commit();
327
+ updatedCount++;
328
+ setStatus(`Regenerated CSS for ${updatedCount}/${fontRefs.length} fonts...`);
329
+ } catch (err) {
330
+ console.error(`Error regenerating CSS for font ${fontRefs[i]._ref}:`, err);
331
+ errorCount++;
332
+ }
333
+ }
334
+
335
+ const successMessage = `Successfully regenerated CSS for ${updatedCount} fonts${errorCount > 0 ? ` (${errorCount} errors)` : ''}`;
336
+ setStatus(successMessage);
337
+ if (errorCount > 0) setError(true);
338
+ } catch (err) {
339
+ console.error('Error regenerating CSS files:', err);
340
+ setError(true);
341
+ setStatus(`Error: ${err.message}`);
342
+ }
343
+ setReady(true);
344
+ }, [title, slug, client]);
345
+
346
+ /** Handles price field changes. */
347
+ const handleInputChange = (e) => {
348
+ setInputPrice(e.target.value);
349
+ setError(false);
350
+ setStatus('ready');
351
+ };
352
+
353
+ /** Renders an info-icon tooltip trigger wrapping a label. */
354
+ const renderTooltipLabel = (label, description) => (
355
+ <Tooltip
356
+ content={<Box padding={2} style={{ maxWidth: 260 }}><Text size={1} style={{ lineHeight: 1.6 }}>{description}</Text></Box>}
357
+ placement="top"
358
+ portal
359
+ >
360
+ <Flex align="center" gap={1} style={{ cursor: 'default' }}>
361
+ <Label>{label}</Label>
362
+ <InfoOutlineIcon style={{ opacity: 0.5, display: 'block' }} />
363
+ </Flex>
364
+ </Tooltip>
365
+ );
366
+
367
+ /** Renders the in-progress state: spinner, live status, elapsed time, and do-not-close warning. */
368
+ const renderProcessing = () => (
369
+ <Stack space={3} paddingY={2}>
370
+ <Flex align="center" gap={3}>
371
+ <Spinner />
372
+ <Text size={1} muted>{status}</Text>
373
+ </Flex>
374
+ <Card tone="caution" border radius={2} padding={2}>
375
+ <Flex align="center" justify="space-between" gap={2}>
376
+ <Flex align="center" gap={2}>
377
+ <WarningOutlineIcon style={{ flexShrink: 0 }} />
378
+ <Text size={1} weight="semibold">Do not close or reload this tab</Text>
379
+ </Flex>
380
+ <Text size={1} muted style={{ flexShrink: 0 }}>{formatElapsed(elapsedSeconds)}</Text>
381
+ </Flex>
382
+ </Card>
383
+ </Stack>
384
+ );
385
+
386
+ /** Renders the drag-and-drop zone. */
387
+ const renderDropZone = () => (
388
+ <Box
389
+ onDragEnter={handleDragEnter}
390
+ onDragOver={handleDragOver}
391
+ onDragLeave={handleDragLeave}
392
+ onDrop={handleDrop}
393
+ style={{
394
+ border: `2px dashed ${isDragging ? 'var(--card-focus-ring-color)' : 'var(--card-border-color)'}`,
395
+ borderRadius: 4,
396
+ padding: '28px 16px',
397
+ textAlign: 'center',
398
+ background: isDragging ? 'rgba(100, 153, 255, 0.06)' : 'transparent',
399
+ transition: 'border-color 0.12s, background 0.12s',
400
+ cursor: 'default',
401
+ }}
402
+ >
403
+ <input
404
+ ref={fileInputRef}
405
+ type="file"
406
+ multiple
407
+ hidden
408
+ accept=".ttf,.otf,.woff,.woff2,.eot,.svg"
409
+ onChange={handleFileSelect}
410
+ />
411
+ <Stack space={3}>
412
+ <Text size={1} muted>
413
+ {isDragging ? 'Release to add files' : 'Drop font files here'}
414
+ </Text>
415
+ <Flex justify="center">
416
+ <Button
417
+ mode="ghost"
418
+ tone="primary"
419
+ fontSize={1}
420
+ padding={2}
421
+ text="Browse files"
422
+ onClick={() => fileInputRef.current?.click()}
423
+ />
424
+ </Flex>
425
+ </Stack>
426
+ </Box>
427
+ );
428
+
429
+ /** Renders the sorted pending file list with a scrollable container, file count, and upload action. */
430
+ const renderFileList = () => {
431
+ const sorted = sortFilesByType(pendingFiles);
432
+ return (
433
+ <Stack space={2}>
434
+ {/* Header: file count + clear */}
435
+ <Flex align="center" justify="space-between">
436
+ <Text size={1} muted>
437
+ {pendingFiles.length} file{pendingFiles.length === 1 ? '' : 's'} selected
438
+ </Text>
439
+ <Button
440
+ mode="bleed"
441
+ tone="default"
442
+ fontSize={1}
443
+ padding={1}
444
+ text="Clear all"
445
+ onClick={() => setPendingFiles([])}
446
+ />
447
+ </Flex>
448
+
449
+ {/* Scrollable file list */}
450
+ <Box style={{ maxHeight: '260px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '4px' }}>
451
+ {sorted.map((file, i) => {
452
+ const ext = file.name.split('.').pop().toUpperCase();
453
+ return (
454
+ <Card key={`${file.name}-${file.size}-${i}`} border radius={1} paddingX={2} paddingY={2}>
455
+ <Flex justify="space-between" align="center" gap={2}>
456
+ <Flex gap={3} align="center" style={{ flex: 1, minWidth: 0 }}>
457
+ <Text
458
+ size={0}
459
+ style={{ fontFamily: 'monospace', minWidth: '2.5rem', flexShrink: 0 }}
460
+ >
461
+ {ext}
462
+ </Text>
463
+ <Box style={{ flex: 1, minWidth: 0 }}>
464
+ <Text size={1}>{file.name}</Text>
465
+ </Box>
466
+ </Flex>
467
+ <Button
468
+ mode="bleed"
469
+ tone="critical"
470
+ icon={TrashIcon}
471
+ padding={2}
472
+ onClick={() => handleRemoveFile(file)}
473
+ />
474
+ </Flex>
475
+ </Card>
476
+ );
477
+ })}
478
+ </Box>
479
+
480
+ {/* Add more files zone */}
481
+ <Box
482
+ onDragEnter={handleDragEnter}
483
+ onDragOver={handleDragOver}
484
+ onDragLeave={handleDragLeave}
485
+ onDrop={handleDrop}
486
+ style={{
487
+ border: `2px dashed ${isDragging ? 'var(--card-focus-ring-color)' : 'var(--card-border-color)'}`,
488
+ borderRadius: 4,
489
+ padding: '10px 16px',
490
+ textAlign: 'center',
491
+ background: isDragging ? 'rgba(100, 153, 255, 0.06)' : 'transparent',
492
+ transition: 'border-color 0.12s, background 0.12s',
493
+ }}
494
+ >
495
+ <input
496
+ ref={fileInputRef}
497
+ type="file"
498
+ multiple
499
+ hidden
500
+ accept=".ttf,.otf,.woff,.woff2,.eot,.svg"
501
+ onChange={handleFileSelect}
502
+ />
503
+ <Flex align="center" justify="center" gap={2}>
504
+ <Text size={1} muted>{isDragging ? 'Release to add' : 'Drop more files or'}</Text>
505
+ <Button
506
+ mode="bleed"
507
+ tone="primary"
508
+ fontSize={1}
509
+ padding={1}
510
+ text="browse"
511
+ onClick={() => fileInputRef.current?.click()}
512
+ />
513
+ </Flex>
514
+ </Box>
515
+
516
+ {/* Upload confirm */}
517
+ <Button
518
+ mode="ghost"
519
+ tone="primary"
520
+ icon={UploadIcon}
521
+ text={`Upload ${pendingFiles.length} Font${pendingFiles.length === 1 ? '' : 's'}`}
522
+ style={{ width: '100%' }}
523
+ onClick={handleConfirmUpload}
524
+ />
525
+ </Stack>
526
+ );
527
+ };
528
+
529
+ const hasRequiredFields = title && title !== '' && slug && slug !== '';
530
+
531
+ return (
532
+ <>
533
+ {!hasRequiredFields && (
534
+ <Card border padding={4} radius={2} tone="caution">
535
+ <Flex align="center" gap={3}>
536
+ <Text size={2}>
537
+ <WarningOutlineIcon />
538
+ </Text>
539
+ <Stack space={2}>
540
+ <Text size={1} weight="semibold">
541
+ {!title || title === '' ? 'Title required to use font uploader' : 'Slug required to use font uploader'}
542
+ </Text>
543
+ <Text size={1} muted>
544
+ Add a {!title || title === '' ? 'title' : 'slug'} to this typeface document, then return to the Styles tab to upload fonts.
545
+ </Text>
546
+ </Stack>
547
+ </Flex>
548
+ </Card>
549
+ )}
550
+ {hasRequiredFields &&
551
+ <>
552
+ <StatusDisplay
553
+ status={status}
554
+ error={error}
555
+ action={
556
+ <Button
557
+ mode={showUtilities ? 'default' : 'ghost'}
558
+ tone="primary"
559
+ icon={ControlsIcon}
560
+ text="Utilities"
561
+ fontSize={1}
562
+ padding={2}
563
+ onClick={() => setShowUtilities(v => !v)}
564
+ />
565
+ }
566
+ />
567
+
568
+ <Button
569
+ mode="default"
570
+ tone="primary"
571
+ icon={UploadIcon}
572
+ text="Upload Fonts"
573
+ fontSize={2}
574
+ padding={4}
575
+ onClick={() => setShowUploadModal(true)}
576
+ style={{ width: '100%' }}
577
+ />
578
+
579
+ {/* New upload modal */}
580
+ {showUploadModal && (
581
+ <Suspense fallback={<Spinner />}>
582
+ <UploadModal
583
+ open={showUploadModal}
584
+ onClose={() => setShowUploadModal(false)}
585
+ client={client}
586
+ docId={doc_id}
587
+ typefaceTitle={title}
588
+ stylesObject={stylesObject}
589
+ preferredStyleRef={preferredStyleRef}
590
+ slug={slug}
591
+ defaults={defaults}
592
+ />
593
+ </Suspense>
594
+ )}
595
+
596
+ {/* Utilities panel — toggled via the Utilities button */}
597
+ {showUtilities && (
598
+ <Card border padding={3} shadow={1} radius={2} marginTop={3}>
599
+ <Stack space={4}>
600
+
601
+ {/* Regenerate Subfamilies */}
602
+ <Stack space={2}>
603
+ <Text size={1} weight="semibold" style={{ lineHeight: 1.6 }}>Regenerate Subfamilies</Text>
604
+ <RegenerateSubfamiliesComponent />
605
+ </Stack>
606
+
607
+ {/* Rename Fonts */}
608
+ <Stack space={3}>
609
+ <Text size={1} weight="semibold" style={{ lineHeight: 1.6 }}>Rename Fonts (name table, Full Name)</Text>
610
+ <Flex align="center" gap={2}>
611
+ <Switch
612
+ checked={preserveShortenedNames}
613
+ onChange={(e) => setPreserveShortenedNames(e.target.checked)}
614
+ />
615
+ {renderTooltipLabel(
616
+ 'Preserve shortened names',
617
+ 'Abbreviations in font names are kept as-is (e.g. "XNarrow" stays "XNarrow", "Bd" stays "Bd").'
618
+ )}
619
+ </Flex>
620
+ {ready === 'rename'
621
+ ? renderProcessing()
622
+ : <Button mode="ghost" tone="primary" text="Rename Existing Fonts" style={{ width: '100%' }} onClick={handleRenameExistingFonts} disabled={ready !== true} />
623
+ }
624
+ </Stack>
625
+
626
+ {/* Update Font Prices */}
627
+ <Stack space={3}>
628
+ <Text size={1} weight="semibold" style={{ lineHeight: 1.6 }}>Update Font Prices</Text>
629
+ {ready === 'price'
630
+ ? renderProcessing()
631
+ : <Stack space={2}>
632
+ <PriceInput inputPrice={inputPrice} handleInputChange={handleInputChange} />
633
+ <Button mode="ghost" tone="primary" text="Update All Font Prices" style={{ width: '100%' }} onClick={handleChangeFontPrice} disabled={ready !== true} />
634
+ </Stack>
635
+ }
636
+ </Stack>
637
+
638
+ {/* Regenerate CSS */}
639
+ <Stack space={3}>
640
+ <Text size={1} weight="semibold" style={{ lineHeight: 1.6 }}>Regenerate CSS</Text>
641
+ <Text size={1} muted style={{ lineHeight: 1.6 }}>Rebuilds the CSS @font-face files for all fonts in the typeface fonts list.</Text>
642
+ {ready === 'css'
643
+ ? renderProcessing()
644
+ : <Button mode="ghost" tone="primary" text="Regenerate CSS Files" style={{ width: '100%' }} onClick={handleRegenerateCssFiles} disabled={ready !== true} />
645
+ }
646
+ </Stack>
647
+
648
+ </Stack>
649
+ </Card>
650
+ )}
651
+ </>
652
+ }
653
+ </>
654
+ );
655
+ };