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