@liiift-studio/sanity-font-manager 2.4.0 → 2.5.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 (33) hide show
  1. package/dist/UploadModal-6LIX7XOK.js +6 -0
  2. package/dist/UploadModal-NME2W53V.mjs +6 -0
  3. package/dist/chunk-646WCBRR.mjs +7276 -0
  4. package/dist/chunk-FH4QKHOH.js +7276 -0
  5. package/dist/index.js +664 -1647
  6. package/dist/index.mjs +317 -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 +415 -0
  12. package/src/components/SingleUploaderTool.jsx +3 -4
  13. package/src/components/UploadModal.jsx +268 -0
  14. package/src/components/UploadScriptsComponent.jsx +23 -21
  15. package/src/components/UploadStep1Settings.jsx +272 -0
  16. package/src/components/UploadStep2Review.jsx +472 -0
  17. package/src/components/UploadStep3Execute.jsx +234 -0
  18. package/src/components/UploadSummary.jsx +196 -0
  19. package/src/index.js +45 -0
  20. package/src/utils/buildUploadPlan.js +325 -0
  21. package/src/utils/executeUploadPlan.js +437 -0
  22. package/src/utils/executionReducer.js +56 -0
  23. package/src/utils/fontHelpers.js +267 -0
  24. package/src/utils/generateCssFile.js +79 -77
  25. package/src/utils/generateFontData.js +47 -94
  26. package/src/utils/getEmptyFontKit.js +19 -17
  27. package/src/utils/parseFont.js +55 -0
  28. package/src/utils/planReducer.js +517 -0
  29. package/src/utils/planTypes.js +183 -0
  30. package/src/utils/processFontFiles.js +120 -78
  31. package/src/utils/regenerateFontData.js +2 -2
  32. package/src/utils/resolveExistingFont.js +87 -0
  33. package/src/utils/uploadFontFiles.js +405 -405
@@ -0,0 +1,268 @@
1
+ // Upload modal — 3-step state machine: Settings → Review → Execute
2
+
3
+ import React, { useReducer, useCallback, useState, useMemo, useRef, useEffect } from 'react';
4
+ import { Dialog, Box, Flex, Text, Badge, Button } from '@sanity/ui';
5
+ import { planReducer } from '../utils/planReducer';
6
+ import { createEmptyPlan, PLAN_PHASE } from '../utils/planTypes';
7
+ import { buildUploadPlan } from '../utils/buildUploadPlan';
8
+ import { generateStyleKeywords } from '../utils/generateKeywords';
9
+ import UploadStep1Settings from './UploadStep1Settings';
10
+ import UploadStep2Review from './UploadStep2Review';
11
+ import UploadStep3Execute from './UploadStep3Execute';
12
+ import UploadSummary from './UploadSummary';
13
+
14
+ /** Step labels for the step indicator */
15
+ const STEPS = [
16
+ { key: 1, label: 'Upload Files' },
17
+ { key: 2, label: 'Review' },
18
+ { key: 3, label: 'Upload' },
19
+ ];
20
+
21
+ /** Maps plan phase to active step number */
22
+ function phaseToStep(phase) {
23
+ switch (phase) {
24
+ case PLAN_PHASE.IDLE: return 1;
25
+ case PLAN_PHASE.PROCESSING: return 2;
26
+ case PLAN_PHASE.REVIEWING: return 2;
27
+ case PLAN_PHASE.READY: return 2;
28
+ case PLAN_PHASE.EXECUTING: return 3;
29
+ case PLAN_PHASE.COMPLETE: return 3;
30
+ case PLAN_PHASE.ERROR: return 3;
31
+ default: return 1;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Upload modal — wraps the 3-step upload workflow in a Sanity UI Dialog.
37
+ */
38
+ export default function UploadModal({
39
+ open,
40
+ onClose,
41
+ client,
42
+ docId,
43
+ typefaceTitle,
44
+ stylesObject,
45
+ preferredStyleRef,
46
+ slug,
47
+ }) {
48
+ const [plan, dispatch] = useReducer(planReducer, null, () => createEmptyPlan());
49
+ const [processingCancelled, setProcessingCancelled] = useState(false);
50
+ const [executionResult, setExecutionResult] = useState(null);
51
+ const [retryTempIds, setRetryTempIds] = useState(null);
52
+ const cancelRef = useRef(false);
53
+ const focusRef = useRef(null);
54
+
55
+ const { weightKeywordList, italicKeywordList } = useMemo(() => generateStyleKeywords(), []);
56
+ const currentStep = phaseToStep(plan.phase);
57
+ const isExecuting = plan.phase === PLAN_PHASE.EXECUTING;
58
+
59
+ // Prevent accidental close during upload
60
+ useEffect(() => {
61
+ if (!open || !isExecuting) return;
62
+ const handler = (e) => { e.preventDefault(); e.returnValue = ''; };
63
+ window.addEventListener('beforeunload', handler);
64
+ return () => window.removeEventListener('beforeunload', handler);
65
+ }, [open, isExecuting]);
66
+
67
+ // Focus management on step transitions
68
+ useEffect(() => {
69
+ if (focusRef.current) {
70
+ focusRef.current.focus();
71
+ }
72
+ }, [currentStep]);
73
+
74
+ /** Handle close — confirm if plan has data, block during execution */
75
+ const handleClose = useCallback(() => {
76
+ if (isExecuting) return;
77
+ const hasFonts = Object.keys(plan.fonts).length > 0;
78
+ if (hasFonts && plan.phase !== PLAN_PHASE.COMPLETE) {
79
+ if (!window.confirm('Close the upload modal? All progress will be lost.')) return;
80
+ }
81
+ dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.IDLE });
82
+ setExecutionResult(null);
83
+ onClose();
84
+ }, [plan, isExecuting, onClose]);
85
+
86
+ /** Start processing — transition to Step 2 and build the plan */
87
+ const handleStartProcessing = useCallback(async (files, settings) => {
88
+ dispatch({ type: 'SET_SETTINGS', settings });
89
+ dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.PROCESSING, totalFiles: files.length });
90
+ cancelRef.current = false;
91
+ setProcessingCancelled(false);
92
+ setExecutionResult(null);
93
+
94
+ try {
95
+ const builtPlan = await buildUploadPlan({
96
+ files,
97
+ typefaceTitle,
98
+ docId,
99
+ settings,
100
+ client,
101
+ stylesObject,
102
+ weightKeywordList,
103
+ italicKeywordList,
104
+ onProgress: (event) => {
105
+ if (cancelRef.current) return;
106
+ // Update progress counter only — don't add entries yet (they haven't been merged by documentId)
107
+ if (event.type === 'font-processed' || event.type === 'font-error') {
108
+ dispatch({ type: 'UPDATE_PROCESSING_PROGRESS', progress: event.progress });
109
+ }
110
+ },
111
+ });
112
+
113
+ if (!cancelRef.current) {
114
+ // Dispatch the final merged plan entries (files with the same documentId are already grouped)
115
+ for (const [tempId, entry] of Object.entries(builtPlan.fonts)) {
116
+ dispatch({ type: 'ADD_PROCESSED_FONT', tempId, fontEntry: entry });
117
+ }
118
+ dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.REVIEWING });
119
+ }
120
+ } catch (err) {
121
+ console.error('Processing failed:', err);
122
+ dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.REVIEWING });
123
+ }
124
+ }, [typefaceTitle, docId, client, stylesObject, weightKeywordList, italicKeywordList]);
125
+
126
+ /** Cancel processing and return to Step 1 */
127
+ const handleCancelProcessing = useCallback(() => {
128
+ cancelRef.current = true;
129
+ setProcessingCancelled(true);
130
+ dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.IDLE });
131
+ }, []);
132
+
133
+ /** Transition to execution */
134
+ const handleStartExecution = useCallback(() => {
135
+ dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.EXECUTING });
136
+ }, []);
137
+
138
+ /** Receive execution result and mark complete */
139
+ const handleExecutionComplete = useCallback((result) => {
140
+ setExecutionResult(result);
141
+ dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.COMPLETE });
142
+ }, []);
143
+
144
+ if (!open) return null;
145
+
146
+ /** Navigate to a step by clicking the step indicator */
147
+ const handleStepClick = useCallback((stepKey) => {
148
+ if (isExecuting) return;
149
+ if (stepKey === currentStep) return;
150
+ // Can only go back to step 1 (reset to settings)
151
+ if (stepKey === 1 && currentStep > 1) {
152
+ if (Object.keys(plan.fonts).length > 0) {
153
+ if (!window.confirm('Go back to settings? Current review progress will be lost.')) return;
154
+ }
155
+ dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.IDLE });
156
+ }
157
+ }, [currentStep, isExecuting, plan.fonts, dispatch]);
158
+
159
+ return (
160
+ <Dialog
161
+ id="upload-modal"
162
+ header={
163
+ <Flex direction="column" gap={3} style={{ width: '100%' }}>
164
+ <Text weight="semibold" size={2}>Upload Fonts</Text>
165
+ <Flex gap={1} style={{ width: '100%' }}>
166
+ {STEPS.map((step, i) => {
167
+ const isActive = currentStep === step.key;
168
+ const isCompleted = currentStep > step.key;
169
+ const isClickable = !isExecuting && step.key < currentStep;
170
+ return (
171
+ <Box
172
+ key={step.key}
173
+ as={isClickable ? 'button' : 'div'}
174
+ onClick={isClickable ? () => handleStepClick(step.key) : undefined}
175
+ style={{
176
+ flex: 1,
177
+ padding: '10px 12px',
178
+ border: 'none',
179
+ borderRadius: 4,
180
+ cursor: isClickable ? 'pointer' : 'default',
181
+ background: isActive
182
+ ? 'var(--card-badge-primary-bg-color)'
183
+ : isCompleted
184
+ ? 'var(--card-badge-positive-bg-color)'
185
+ : 'var(--card-muted-bg-color)',
186
+ color: isActive || isCompleted
187
+ ? '#fff'
188
+ : 'var(--card-muted-fg-color)',
189
+ textAlign: 'center',
190
+ transition: 'background 0.15s ease',
191
+ opacity: !isActive && !isCompleted ? 0.6 : 1,
192
+ }}
193
+ >
194
+ <Text
195
+ size={1}
196
+ weight={isActive ? 'bold' : 'medium'}
197
+ style={{ color: 'inherit' }}
198
+ >
199
+ {step.key}. {step.label}
200
+ </Text>
201
+ </Box>
202
+ );
203
+ })}
204
+ </Flex>
205
+ </Flex>
206
+ }
207
+ width={2}
208
+ onClose={isExecuting ? undefined : handleClose}
209
+ onClickOutside={() => {}}
210
+ >
211
+ <Box padding={4}>
212
+ {/* Step 1: Settings & File Selection */}
213
+ {currentStep === 1 && (
214
+ <UploadStep1Settings
215
+ settings={plan.settings}
216
+ onStartProcessing={handleStartProcessing}
217
+ />
218
+ )}
219
+
220
+ {/* Step 2: Processing & Review */}
221
+ {currentStep === 2 && (
222
+ <UploadStep2Review
223
+ plan={plan}
224
+ dispatch={dispatch}
225
+ onCancelProcessing={handleCancelProcessing}
226
+ onStartExecution={handleStartExecution}
227
+ processingCancelled={processingCancelled}
228
+ />
229
+ )}
230
+
231
+ {/* Step 3: Upload Execution */}
232
+ {currentStep === 3 && plan.phase !== PLAN_PHASE.COMPLETE && (
233
+ <UploadStep3Execute
234
+ key={retryTempIds ? 'retry' : 'initial'}
235
+ plan={plan}
236
+ client={client}
237
+ docId={docId}
238
+ stylesObject={stylesObject}
239
+ preferredStyleRef={preferredStyleRef}
240
+ retryTempIds={retryTempIds}
241
+ onComplete={(result) => {
242
+ setRetryTempIds(null);
243
+ handleExecutionComplete(result);
244
+ }}
245
+ />
246
+ )}
247
+
248
+ {/* Post-completion Summary */}
249
+ {plan.phase === PLAN_PHASE.COMPLETE && (
250
+ <UploadSummary
251
+ plan={plan}
252
+ result={executionResult}
253
+ onClose={handleClose}
254
+ onRetry={(failedTempIds) => {
255
+ setRetryTempIds(failedTempIds || null);
256
+ setExecutionResult(null);
257
+ dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.EXECUTING });
258
+ }}
259
+ client={client}
260
+ docId={docId}
261
+ stylesObject={stylesObject}
262
+ preferredStyleRef={preferredStyleRef}
263
+ />
264
+ )}
265
+ </Box>
266
+ </Dialog>
267
+ );
268
+ }
@@ -2,7 +2,8 @@
2
2
 
3
3
  import React, { useCallback, useEffect, useMemo, useState } from 'react';
4
4
  import { Button, Flex, Grid, Stack, Text, TextInput, MenuButton, Menu, MenuItem, Select } from '@sanity/ui';
5
- import * as fontkit from 'fontkit';
5
+ import { parseFont } from '../utils/parseFont';
6
+ import { getNameString, getVariationAxes, getItalicAngle, getWeightClass } from '../utils/fontHelpers';
6
7
  import slugify from 'slugify';
7
8
  import { useSanityClient } from '../hooks/useSanityClient';
8
9
  import { useFormValue } from 'sanity';
@@ -91,25 +92,26 @@ export const UploadScriptsComponent = (props) => {
91
92
 
92
93
  const file = event.target.files[i];
93
94
  const fontBuffer = await readFontFile(file);
94
- const font = fontkit.create(fontBuffer);
95
+ const font = await parseFont(fontBuffer, file.name);
95
96
 
96
- console.log('reading font : ', font.fullName +' '+file.name, font.name.records);
97
+ const fullName = getNameString(font, 4);
98
+ const familyName = getNameString(font, 1);
99
+ console.log('Reading font:', fullName, file.name);
97
100
 
98
- let weightName = font?.name?.records?.preferredSubfamily ? font?.name?.records?.preferredSubfamily: font?.name?.records?.fontSubfamily;
99
- weightName = weightName?.en ? weightName.en: weightName.constructor == Object ? weightName[Object.keys(weightName)[0]] : weightName;
100
- weightName = weightName?.replace("Italic", "").replace("It", "").trim();
101
+ let weightName = getNameString(font, 17) || getNameString(font, 2) || '';
102
+ weightName = weightName.replace("Italic", "").replace("It", "").trim();
101
103
 
102
- if ((weightName == '' || weightName.toLowerCase() == 'roman') && font?.name?.records?.fullName) {
103
- weightName = font?.name?.records?.fullName;
104
- weightName = weightName?.en ? weightName.en: weightName.constructor == Object ? weightName[Object.keys(weightName)[0]] : weightName;
105
- weightName = weightName?.replace(title + " ", "").replace(title, "").trim();
106
- weightName = weightName?.replace("Italic", "").replace("It", "").trim();
104
+ if ((weightName == '' || weightName.toLowerCase() == 'roman') && fullName) {
105
+ weightName = fullName.replace(title + " ", "").replace(title, "").trim();
106
+ weightName = weightName.replace("Italic", "").replace("It", "").trim();
107
107
  }
108
108
 
109
- let variableFont = font?.variationAxes && Object.keys(font.variationAxes).length > 0 ? true: false;
110
- let subfamilyName = font.familyName.toLowerCase().trim().replace(title.toLowerCase().trim(),'').trim();
111
- let fontTitle = font?.fullName;
112
- let style = (font?.italicAngle !== 0 || font?.fullName.toLowerCase().includes('italic')) ? 'Italic' : 'Regular';
109
+ const axes = getVariationAxes(font);
110
+ let variableFont = axes !== null;
111
+ let subfamilyName = familyName.toLowerCase().trim().replace(title.toLowerCase().trim(),'').trim();
112
+ let fontTitle = fullName;
113
+ const italicAngle = getItalicAngle(font);
114
+ let style = (italicAngle !== 0 || fullName.toLowerCase().includes('italic')) ? 'Italic' : 'Regular';
113
115
 
114
116
  if(fontTitle.toLowerCase().trim().includes(script)){
115
117
  fontTitle = fontTitle.toLowerCase().trim().replace(script, '').trim();
@@ -177,8 +179,8 @@ export const UploadScriptsComponent = (props) => {
177
179
  console.log(' ')
178
180
  console.log('font id : ', id);
179
181
  console.log('font title : ', fontTitle);
180
- console.log('fontkit fullName : ', font.fullName );
181
- console.log('fontkit family name: ', font.familyName);
182
+ console.log('Full name:', fullName);
183
+ console.log('Family name:', familyName);
182
184
  console.log('file name : ', file.name);
183
185
  console.log('subfamily : ', subfamilyName);
184
186
  console.log('style : ', style);
@@ -199,11 +201,11 @@ export const UploadScriptsComponent = (props) => {
199
201
  title: fontTitle,
200
202
  slug: {_type:'slug', current:id},
201
203
  typefaceName: title, // Change to match Typeface Document
202
- style: (font?.italicAngle !== 0 || font?.fullName.toLowerCase().includes('italic')) ? 'Italic' : 'Regular',
204
+ style: style,
203
205
  variableFont: variableFont,
204
206
  weightName: weightName,
205
- normalWeight:true, // TODO : check if weight is normal ??
206
- weight: font['OS/2']?.usWeightClass ? Number(font['OS/2']?.usWeightClass) :
207
+ normalWeight:true,
208
+ weight: getWeightClass(font) || (
207
209
  /hairline|extra thin|extrathin/.test(weightName?.toLowerCase()) ? 100 :
208
210
  /thin|extra light|extralight/.test(weightName?.toLowerCase()) ? 200 :
209
211
  /light|book/.test(weightName?.toLowerCase()) ? 300 :
@@ -213,7 +215,7 @@ export const UploadScriptsComponent = (props) => {
213
215
  /bold/.test(weightName?.toLowerCase()) ? 700 :
214
216
  /extra bold|extrabold/.test(weightName?.toLowerCase()) ? 800 :
215
217
  /black|ultra/.test(weightName?.toLowerCase()) ? 900 :
216
- 400,
218
+ 400),
217
219
  files : [file],
218
220
  fontKit: font,
219
221
  scriptFileInput: {[script]:{}},
@@ -0,0 +1,272 @@
1
+ // Step 1 — File upload: drag-and-drop zone, file list table, type breakdown with mismatch detection
2
+
3
+ import React, { useState, useCallback, useRef, useMemo } from 'react';
4
+ import { Box, Flex, Stack, Text, Button, Card, Badge } from '@sanity/ui';
5
+ import { UploadIcon, TrashIcon } from '@sanity/icons';
6
+
7
+ /** Accepted font file extensions */
8
+ const ACCEPTED_EXTENSIONS = ['ttf', 'otf', 'woff', 'woff2', 'eot', 'svg'];
9
+
10
+ /** Sort order: TTF/OTF before web formats so metadata is available for webfont fallback */
11
+ const TYPE_ORDER = ['ttf', 'otf', 'eot', 'svg', 'woff', 'woff2'];
12
+
13
+ /** Returns only files with accepted font extensions */
14
+ const filterFontFiles = (files) =>
15
+ Array.from(files).filter(f => ACCEPTED_EXTENSIONS.includes(f.name.split('.').pop().toLowerCase()));
16
+
17
+ /** Sorts font files so TTF/OTF are processed before web formats */
18
+ const sortFilesByType = (files) =>
19
+ Array.from(files).sort((a, b) => {
20
+ const aIdx = TYPE_ORDER.indexOf(a.name.split('.').pop().toLowerCase());
21
+ const bIdx = TYPE_ORDER.indexOf(b.name.split('.').pop().toLowerCase());
22
+ if (aIdx === bIdx) return a.name.localeCompare(b.name);
23
+ return aIdx - bIdx;
24
+ });
25
+
26
+ /**
27
+ * Step 1 component — file selection only. Settings are in Step 2.
28
+ *
29
+ * @param {object} props
30
+ * @param {object} props.settings - Current plan settings
31
+ * @param {function} props.onStartProcessing - Called with (sortedFiles, settings) when user clicks "Process Files"
32
+ */
33
+ export default function UploadStep1Settings({ settings, onStartProcessing }) {
34
+ const [pendingFiles, setPendingFiles] = useState([]);
35
+ const [isDragging, setIsDragging] = useState(false);
36
+ const [filterType, setFilterType] = useState(null);
37
+ const fileInputRef = useRef(null);
38
+
39
+ const handleFileSelect = useCallback((e) => {
40
+ const files = filterFontFiles(e.target.files);
41
+ if (files.length > 0) setPendingFiles(prev => [...prev, ...files]);
42
+ e.target.value = '';
43
+ }, []);
44
+
45
+ const handleRemoveFile = useCallback((file) => {
46
+ setPendingFiles(prev => prev.filter(f => f !== file));
47
+ }, []);
48
+
49
+ const handleDragEnter = useCallback((e) => { e.preventDefault(); setIsDragging(true); }, []);
50
+ const handleDragOver = useCallback((e) => { e.preventDefault(); }, []);
51
+ const handleDragLeave = useCallback((e) => { e.preventDefault(); setIsDragging(false); }, []);
52
+ const handleDrop = useCallback((e) => {
53
+ e.preventDefault();
54
+ setIsDragging(false);
55
+ const files = filterFontFiles(e.dataTransfer.files);
56
+ if (files.length > 0) setPendingFiles(prev => [...prev, ...files]);
57
+ }, []);
58
+
59
+ const handleProcess = useCallback(() => {
60
+ const sorted = sortFilesByType(pendingFiles);
61
+ onStartProcessing(sorted, settings);
62
+ }, [pendingFiles, settings, onStartProcessing]);
63
+
64
+ /** Count files by extension and detect outliers (types whose count differs from the majority) */
65
+ const typeBreakdown = useMemo(() => {
66
+ const counts = {};
67
+ pendingFiles.forEach(f => {
68
+ const ext = f.name.split('.').pop().toLowerCase();
69
+ counts[ext] = (counts[ext] || 0) + 1;
70
+ });
71
+
72
+ const values = Object.values(counts);
73
+ if (values.length <= 1) return { counts, modeCount: 0, outlierExts: new Set() };
74
+
75
+ // Find the mode (most frequent count) — types matching the mode are normal, others are outliers
76
+ const freq = {};
77
+ values.forEach(v => { freq[v] = (freq[v] || 0) + 1; });
78
+ const modeCount = Number(Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0]);
79
+
80
+ const outlierExts = new Set();
81
+ Object.entries(counts).forEach(([ext, count]) => {
82
+ if (count !== modeCount) outlierExts.add(ext);
83
+ });
84
+
85
+ return { counts, modeCount, outlierExts };
86
+ }, [pendingFiles]);
87
+
88
+ /** Files filtered by the active type filter — displayed in upload order */
89
+ const displayedFiles = useMemo(() => {
90
+ if (!filterType) return pendingFiles;
91
+ return pendingFiles.filter(f => f.name.split('.').pop().toLowerCase() === filterType);
92
+ }, [pendingFiles, filterType]);
93
+
94
+ const dropZoneStyle = {
95
+ border: `2px dashed ${isDragging ? 'var(--card-focus-ring-color)' : 'var(--card-border-color)'}`,
96
+ borderRadius: 4,
97
+ padding: pendingFiles.length > 0 ? '10px 16px' : '28px 16px',
98
+ textAlign: 'center',
99
+ background: isDragging ? 'rgba(100, 153, 255, 0.06)' : 'transparent',
100
+ transition: 'border-color 0.12s, background 0.12s',
101
+ };
102
+
103
+ return (
104
+ <Stack space={4}>
105
+ {/* Drop zone */}
106
+ <Box
107
+ onDragEnter={handleDragEnter}
108
+ onDragOver={handleDragOver}
109
+ onDragLeave={handleDragLeave}
110
+ onDrop={handleDrop}
111
+ style={dropZoneStyle}
112
+ >
113
+ <input
114
+ ref={fileInputRef}
115
+ type="file"
116
+ multiple
117
+ hidden
118
+ accept=".ttf,.otf,.woff,.woff2,.eot,.svg"
119
+ onChange={handleFileSelect}
120
+ />
121
+ {pendingFiles.length === 0 ? (
122
+ <Stack space={3}>
123
+ <Text size={1} muted>{isDragging ? 'Release to add files' : 'Drop font files here'}</Text>
124
+ <Flex justify="center">
125
+ <Button
126
+ mode="ghost"
127
+ tone="primary"
128
+ fontSize={1}
129
+ padding={2}
130
+ text="Browse files"
131
+ onClick={() => fileInputRef.current?.click()}
132
+ />
133
+ </Flex>
134
+ </Stack>
135
+ ) : (
136
+ <Flex align="center" justify="center" gap={2}>
137
+ <Text size={1} muted>{isDragging ? 'Release to add' : 'Drop more files or'}</Text>
138
+ <Button
139
+ mode="bleed"
140
+ tone="primary"
141
+ fontSize={1}
142
+ padding={1}
143
+ text="browse"
144
+ onClick={() => fileInputRef.current?.click()}
145
+ />
146
+ </Flex>
147
+ )}
148
+ </Box>
149
+
150
+ {/* File breakdown + list */}
151
+ {pendingFiles.length > 0 && (
152
+ <Stack space={3}>
153
+ {/* Summary: total + type breakdown */}
154
+ <Flex align="center" justify="space-between">
155
+ <Flex align="center" gap={2}>
156
+ <Text size={1} weight="semibold">
157
+ {filterType
158
+ ? `${displayedFiles.length} of ${pendingFiles.length} files (${filterType.toUpperCase()})`
159
+ : `${pendingFiles.length} file${pendingFiles.length === 1 ? '' : 's'}`
160
+ }
161
+ </Text>
162
+ <Flex gap={1}>
163
+ {TYPE_ORDER.filter(ext => typeBreakdown.counts[ext]).map(ext => {
164
+ const count = typeBreakdown.counts[ext];
165
+ const isOutlier = typeBreakdown.outlierExts.has(ext);
166
+ const isActive = filterType === ext;
167
+ return (
168
+ <Badge
169
+ key={ext}
170
+ tone={isOutlier ? 'critical' : isActive ? 'primary' : 'default'}
171
+ mode={isActive ? 'default' : isOutlier ? 'default' : 'outline'}
172
+ fontSize={0}
173
+ style={{ cursor: 'pointer' }}
174
+ onClick={() => setFilterType(isActive ? null : ext)}
175
+ >
176
+ {count} {ext.toUpperCase()}
177
+ </Badge>
178
+ );
179
+ })}
180
+ {filterType && (
181
+ <Badge
182
+ mode="outline"
183
+ fontSize={0}
184
+ style={{ cursor: 'pointer' }}
185
+ onClick={() => setFilterType(null)}
186
+ >
187
+ Clear filter
188
+ </Badge>
189
+ )}
190
+ </Flex>
191
+ </Flex>
192
+ <Button
193
+ mode="bleed"
194
+ tone="default"
195
+ fontSize={1}
196
+ padding={1}
197
+ text="Clear all"
198
+ onClick={() => setPendingFiles([])}
199
+ />
200
+ </Flex>
201
+
202
+ {/* File table */}
203
+ <Box style={{ maxHeight: 350, overflowY: 'auto' }}>
204
+ {/* Table header */}
205
+ <Flex
206
+ align="center"
207
+ gap={2}
208
+ paddingX={2}
209
+ paddingY={1}
210
+ style={{ borderBottom: '1px solid var(--card-border-color)' }}
211
+ >
212
+ <Text size={0} weight="semibold" muted style={{ width: 56, flexShrink: 0 }}>Type</Text>
213
+ <Text size={0} weight="semibold" muted style={{ flex: 1 }}>File Name</Text>
214
+ <Box style={{ width: 32 }} />
215
+ </Flex>
216
+ <Stack space={0}>
217
+ {displayedFiles.map((file, i) => {
218
+ const ext = file.name.split('.').pop().toLowerCase();
219
+ return (
220
+ <Flex
221
+ key={`${file.name}-${file.size}-${i}`}
222
+ align="center"
223
+ gap={2}
224
+ paddingX={2}
225
+ paddingY={2}
226
+ style={{
227
+ borderBottom: '1px solid var(--card-border-color)',
228
+ }}
229
+ >
230
+ <Badge
231
+ tone="primary"
232
+ mode="outline"
233
+ fontSize={0}
234
+ style={{ width: 56, flexShrink: 0, textAlign: 'center' }}
235
+ >
236
+ {ext.toUpperCase()}
237
+ </Badge>
238
+ <Text size={1} style={{ flex: 1, textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
239
+ {file.name}
240
+ </Text>
241
+ <Button
242
+ mode="bleed"
243
+ tone="critical"
244
+ icon={TrashIcon}
245
+ padding={1}
246
+ onClick={() => handleRemoveFile(file)}
247
+ style={{ flexShrink: 0 }}
248
+ />
249
+ </Flex>
250
+ );
251
+ })}
252
+ </Stack>
253
+ </Box>
254
+ </Stack>
255
+ )}
256
+
257
+ {/* Process button */}
258
+ {pendingFiles.length > 0 && (
259
+ <Button
260
+ mode="default"
261
+ tone="primary"
262
+ icon={UploadIcon}
263
+ text={`Process ${pendingFiles.length} File${pendingFiles.length === 1 ? '' : 's'}`}
264
+ style={{ width: '100%' }}
265
+ fontSize={2}
266
+ padding={4}
267
+ onClick={handleProcess}
268
+ />
269
+ )}
270
+ </Stack>
271
+ );
272
+ }