@liiift-studio/sanity-font-manager 2.6.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 +1628 -17701
  4. package/dist/index.mjs +1537 -17599
  5. package/package.json +83 -83
  6. package/src/components/BatchUploadFonts.jsx +655 -653
  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 -304
  25. package/src/components/UploadScriptsComponent.jsx +539 -539
  26. package/src/components/UploadStep1Settings.jsx +272 -272
  27. package/src/components/UploadStep2Review.jsx +478 -474
  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 -267
  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 -517
  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 -0
  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,304 +1,309 @@
1
- // Upload modal — multi-step state machine: Upload Files → Review → Execute → Map Instances → Summary
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 UploadStep3bInstances from './UploadStep3bInstances';
13
- import UploadSummary from './UploadSummary';
14
-
15
- /** Step labels for the step indicator */
16
- const STEPS = [
17
- { key: 1, label: 'Upload Files' },
18
- { key: 2, label: 'Review' },
19
- { key: 3, label: 'Upload' },
20
- { key: 4, label: 'Map Instances' },
21
- ];
22
-
23
- /** Maps plan phase to active step number */
24
- function phaseToStep(phase) {
25
- switch (phase) {
26
- case PLAN_PHASE.IDLE: return 1;
27
- case PLAN_PHASE.PROCESSING: return 2;
28
- case PLAN_PHASE.REVIEWING: return 2;
29
- case PLAN_PHASE.READY: return 2;
30
- case PLAN_PHASE.EXECUTING: return 3;
31
- case PLAN_PHASE.COMPLETE: return 3;
32
- case PLAN_PHASE.ERROR: return 3;
33
- default: return 1;
34
- }
35
- }
36
-
37
- /**
38
- * Upload modal — wraps the 3-step upload workflow in a Sanity UI Dialog.
39
- */
40
- export default function UploadModal({
41
- open,
42
- onClose,
43
- client,
44
- docId,
45
- typefaceTitle,
46
- stylesObject,
47
- preferredStyleRef,
48
- slug,
49
- }) {
50
- const [plan, dispatch] = useReducer(planReducer, null, () => createEmptyPlan());
51
- const [processingCancelled, setProcessingCancelled] = useState(false);
52
- const [executionResult, setExecutionResult] = useState(null);
53
- const [retryTempIds, setRetryTempIds] = useState(null);
54
- const [instanceMappingPhase, setInstanceMappingPhase] = useState(false);
55
- const [instanceMappingResult, setInstanceMappingResult] = useState(null);
56
- const cancelRef = useRef(false);
57
- const focusRef = useRef(null);
58
-
59
- const { weightKeywordList, italicKeywordList } = useMemo(() => generateStyleKeywords(), []);
60
- const hasVFs = useMemo(() =>
61
- Object.values(plan.fonts).some(f => f.variableFont && f.status !== 'error'),
62
- [plan.fonts]
63
- );
64
- const baseStep = phaseToStep(plan.phase);
65
- // Instance mapping is step 4 — only shown after execution completes with VFs
66
- const currentStep = instanceMappingPhase ? 4 : (plan.phase === PLAN_PHASE.COMPLETE && !instanceMappingResult ? baseStep : baseStep);
67
- const isExecuting = plan.phase === PLAN_PHASE.EXECUTING;
68
-
69
- // Prevent accidental close during upload
70
- useEffect(() => {
71
- if (!open || !isExecuting) return;
72
- const handler = (e) => { e.preventDefault(); e.returnValue = ''; };
73
- window.addEventListener('beforeunload', handler);
74
- return () => window.removeEventListener('beforeunload', handler);
75
- }, [open, isExecuting]);
76
-
77
- // Focus management on step transitions
78
- useEffect(() => {
79
- if (focusRef.current) {
80
- focusRef.current.focus();
81
- }
82
- }, [currentStep]);
83
-
84
- /** Handle close — confirm if plan has data, block during execution */
85
- const handleClose = useCallback(() => {
86
- if (isExecuting) return;
87
- const hasFonts = Object.keys(plan.fonts).length > 0;
88
- if (hasFonts && plan.phase !== PLAN_PHASE.COMPLETE) {
89
- if (!window.confirm('Close the upload modal? All progress will be lost.')) return;
90
- }
91
- dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.IDLE });
92
- setExecutionResult(null);
93
- onClose();
94
- }, [plan, isExecuting, onClose]);
95
-
96
- /** Start processing transition to Step 2 and build the plan */
97
- const handleStartProcessing = useCallback(async (files, settings) => {
98
- dispatch({ type: 'SET_SETTINGS', settings: { ...settings, typefaceTitle } });
99
- dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.PROCESSING, totalFiles: files.length });
100
- cancelRef.current = false;
101
- setProcessingCancelled(false);
102
- setExecutionResult(null);
103
-
104
- try {
105
- const builtPlan = await buildUploadPlan({
106
- files,
107
- typefaceTitle,
108
- docId,
109
- settings,
110
- client,
111
- stylesObject,
112
- weightKeywordList,
113
- italicKeywordList,
114
- onProgress: (event) => {
115
- if (cancelRef.current) return;
116
- // Update progress counter only — don't add entries yet (they haven't been merged by documentId)
117
- if (event.type === 'font-processed' || event.type === 'font-error') {
118
- dispatch({ type: 'UPDATE_PROCESSING_PROGRESS', progress: event.progress });
119
- }
120
- },
121
- });
122
-
123
- if (!cancelRef.current) {
124
- // Dispatch the final merged plan entries (files with the same documentId are already grouped)
125
- for (const [tempId, entry] of Object.entries(builtPlan.fonts)) {
126
- dispatch({ type: 'ADD_PROCESSED_FONT', tempId, fontEntry: entry });
127
- }
128
- dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.REVIEWING });
129
- }
130
- } catch (err) {
131
- console.error('Processing failed:', err);
132
- dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.REVIEWING });
133
- }
134
- }, [typefaceTitle, docId, client, stylesObject, weightKeywordList, italicKeywordList]);
135
-
136
- /** Cancel processing and return to Step 1 */
137
- const handleCancelProcessing = useCallback(() => {
138
- cancelRef.current = true;
139
- setProcessingCancelled(true);
140
- dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.IDLE });
141
- }, []);
142
-
143
- /** Transition to execution */
144
- const handleStartExecution = useCallback(() => {
145
- dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.EXECUTING });
146
- }, []);
147
-
148
- /** Receive execution result — transition to instance mapping if VFs exist, otherwise complete */
149
- const handleExecutionComplete = useCallback((result) => {
150
- setExecutionResult(result);
151
- if (hasVFs && result.success !== false) {
152
- // Show instance mapping step before summary
153
- setInstanceMappingPhase(true);
154
- dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.COMPLETE });
155
- } else {
156
- dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.COMPLETE });
157
- }
158
- }, [hasVFs]);
159
-
160
- /** Handle instance mapping completion */
161
- const handleInstanceMappingComplete = useCallback((result) => {
162
- setInstanceMappingResult(result);
163
- setInstanceMappingPhase(false);
164
- }, []);
165
-
166
- if (!open) return null;
167
-
168
- /** Navigate to a step by clicking the step indicator */
169
- const handleStepClick = useCallback((stepKey) => {
170
- if (isExecuting) return;
171
- if (stepKey === currentStep) return;
172
- // Can only go back to step 1 (reset to settings)
173
- if (stepKey === 1 && currentStep > 1) {
174
- if (Object.keys(plan.fonts).length > 0) {
175
- if (!window.confirm('Go back to settings? Current review progress will be lost.')) return;
176
- }
177
- dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.IDLE });
178
- }
179
- }, [currentStep, isExecuting, plan.fonts, dispatch]);
180
-
181
- return (
182
- <Dialog
183
- id="upload-modal"
184
- header={
185
- <Flex direction="column" gap={3} style={{ width: '100%' }}>
186
- <Text weight="semibold" size={2}>Upload Fonts</Text>
187
- <Flex gap={1} style={{ width: '100%' }}>
188
- {STEPS.filter(step => step.key !== 4 || hasVFs).map((step, i) => {
189
- const isActive = currentStep === step.key;
190
- const isCompleted = currentStep > step.key;
191
- const isClickable = !isExecuting && step.key < currentStep;
192
- return (
193
- <Box
194
- key={step.key}
195
- as={isClickable ? 'button' : 'div'}
196
- onClick={isClickable ? () => handleStepClick(step.key) : undefined}
197
- style={{
198
- flex: 1,
199
- padding: '10px 12px',
200
- border: 'none',
201
- borderRadius: 4,
202
- cursor: isClickable ? 'pointer' : 'default',
203
- background: isActive
204
- ? 'var(--card-badge-primary-bg-color)'
205
- : isCompleted
206
- ? 'var(--card-badge-positive-bg-color)'
207
- : 'var(--card-muted-bg-color)',
208
- color: isActive || isCompleted
209
- ? '#fff'
210
- : 'var(--card-muted-fg-color)',
211
- textAlign: 'center',
212
- transition: 'background 0.15s ease',
213
- opacity: !isActive && !isCompleted ? 0.6 : 1,
214
- }}
215
- >
216
- <Text
217
- size={1}
218
- weight={isActive ? 'bold' : 'medium'}
219
- style={{ color: 'inherit' }}
220
- >
221
- {step.key}. {step.label}
222
- </Text>
223
- </Box>
224
- );
225
- })}
226
- </Flex>
227
- </Flex>
228
- }
229
- width={2}
230
- onClose={isExecuting ? undefined : handleClose}
231
- onClickOutside={() => {}}
232
- >
233
- <Box padding={4}>
234
- {/* Step 1: Settings & File Selection */}
235
- {currentStep === 1 && (
236
- <UploadStep1Settings
237
- settings={plan.settings}
238
- onStartProcessing={handleStartProcessing}
239
- />
240
- )}
241
-
242
- {/* Step 2: Processing & Review */}
243
- {currentStep === 2 && (
244
- <UploadStep2Review
245
- plan={plan}
246
- dispatch={dispatch}
247
- onCancelProcessing={handleCancelProcessing}
248
- onStartExecution={handleStartExecution}
249
- processingCancelled={processingCancelled}
250
- />
251
- )}
252
-
253
- {/* Step 3: Upload Execution */}
254
- {currentStep === 3 && plan.phase !== PLAN_PHASE.COMPLETE && (
255
- <UploadStep3Execute
256
- key={retryTempIds ? 'retry' : 'initial'}
257
- plan={plan}
258
- client={client}
259
- docId={docId}
260
- stylesObject={stylesObject}
261
- preferredStyleRef={preferredStyleRef}
262
- retryTempIds={retryTempIds}
263
- onComplete={(result) => {
264
- setRetryTempIds(null);
265
- handleExecutionComplete(result);
266
- }}
267
- />
268
- )}
269
-
270
- {/* Step 4: Variable Font Instance Mapping (only if VFs in batch) */}
271
- {plan.phase === PLAN_PHASE.COMPLETE && instanceMappingPhase && (
272
- <UploadStep3bInstances
273
- plan={plan}
274
- executionResult={executionResult}
275
- client={client}
276
- typefaceTitle={typefaceTitle}
277
- onComplete={handleInstanceMappingComplete}
278
- />
279
- )}
280
-
281
- {/* Post-completion Summary */}
282
- {plan.phase === PLAN_PHASE.COMPLETE && !instanceMappingPhase && (
283
- <UploadSummary
284
- plan={plan}
285
- result={executionResult}
286
- instanceMappingResult={instanceMappingResult}
287
- onClose={handleClose}
288
- onRetry={(failedTempIds) => {
289
- setRetryTempIds(failedTempIds || null);
290
- setExecutionResult(null);
291
- setInstanceMappingPhase(false);
292
- setInstanceMappingResult(null);
293
- dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.EXECUTING });
294
- }}
295
- client={client}
296
- docId={docId}
297
- stylesObject={stylesObject}
298
- preferredStyleRef={preferredStyleRef}
299
- />
300
- )}
301
- </Box>
302
- </Dialog>
303
- );
304
- }
1
+ // Upload modal — multi-step state machine: Upload Files → Review → Execute → Map Instances → Summary
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 UploadStep3bInstances from './UploadStep3bInstances';
13
+ import UploadSummary from './UploadSummary';
14
+
15
+ /** Step labels for the step indicator */
16
+ const STEPS = [
17
+ { key: 1, label: 'Upload Files' },
18
+ { key: 2, label: 'Review' },
19
+ { key: 3, label: 'Upload' },
20
+ { key: 4, label: 'Map Instances' },
21
+ ];
22
+
23
+ /** Maps plan phase to active step number */
24
+ function phaseToStep(phase) {
25
+ switch (phase) {
26
+ case PLAN_PHASE.IDLE: return 1;
27
+ case PLAN_PHASE.PROCESSING: return 2;
28
+ case PLAN_PHASE.REVIEWING: return 2;
29
+ case PLAN_PHASE.READY: return 2;
30
+ case PLAN_PHASE.EXECUTING: return 3;
31
+ case PLAN_PHASE.COMPLETE: return 3;
32
+ case PLAN_PHASE.ERROR: return 3;
33
+ default: return 1;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Upload modal — wraps the 3-step upload workflow in a Sanity UI Dialog.
39
+ */
40
+ export default function UploadModal({
41
+ open,
42
+ onClose,
43
+ client,
44
+ docId,
45
+ typefaceTitle,
46
+ stylesObject,
47
+ preferredStyleRef,
48
+ slug,
49
+ defaults = {},
50
+ }) {
51
+ const [plan, dispatch] = useReducer(planReducer, null, () => createEmptyPlan({
52
+ price: defaults.price ?? 0,
53
+ preserveShortenedNames: defaults.preserveShortenedNames ?? false,
54
+ preserveFileNames: defaults.preserveFileNames ?? false,
55
+ }));
56
+ const [processingCancelled, setProcessingCancelled] = useState(false);
57
+ const [executionResult, setExecutionResult] = useState(null);
58
+ const [retryTempIds, setRetryTempIds] = useState(null);
59
+ const [instanceMappingPhase, setInstanceMappingPhase] = useState(false);
60
+ const [instanceMappingResult, setInstanceMappingResult] = useState(null);
61
+ const cancelRef = useRef(false);
62
+ const focusRef = useRef(null);
63
+
64
+ const { weightKeywordList, italicKeywordList } = useMemo(() => generateStyleKeywords(), []);
65
+ const hasVFs = useMemo(() =>
66
+ Object.values(plan.fonts).some(f => f.variableFont && f.status !== 'error'),
67
+ [plan.fonts]
68
+ );
69
+ const baseStep = phaseToStep(plan.phase);
70
+ // Instance mapping is step 4 — only shown after execution completes with VFs
71
+ const currentStep = instanceMappingPhase ? 4 : (plan.phase === PLAN_PHASE.COMPLETE && !instanceMappingResult ? baseStep : baseStep);
72
+ const isExecuting = plan.phase === PLAN_PHASE.EXECUTING;
73
+
74
+ // Prevent accidental close during upload
75
+ useEffect(() => {
76
+ if (!open || !isExecuting) return;
77
+ const handler = (e) => { e.preventDefault(); e.returnValue = ''; };
78
+ window.addEventListener('beforeunload', handler);
79
+ return () => window.removeEventListener('beforeunload', handler);
80
+ }, [open, isExecuting]);
81
+
82
+ // Focus management on step transitions
83
+ useEffect(() => {
84
+ if (focusRef.current) {
85
+ focusRef.current.focus();
86
+ }
87
+ }, [currentStep]);
88
+
89
+ /** Handle close — confirm if plan has data, block during execution */
90
+ const handleClose = useCallback(() => {
91
+ if (isExecuting) return;
92
+ const hasFonts = Object.keys(plan.fonts).length > 0;
93
+ if (hasFonts && plan.phase !== PLAN_PHASE.COMPLETE) {
94
+ if (!window.confirm('Close the upload modal? All progress will be lost.')) return;
95
+ }
96
+ dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.IDLE });
97
+ setExecutionResult(null);
98
+ onClose();
99
+ }, [plan, isExecuting, onClose]);
100
+
101
+ /** Start processing — transition to Step 2 and build the plan */
102
+ const handleStartProcessing = useCallback(async (files, settings) => {
103
+ dispatch({ type: 'SET_SETTINGS', settings: { ...settings, typefaceTitle } });
104
+ dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.PROCESSING, totalFiles: files.length });
105
+ cancelRef.current = false;
106
+ setProcessingCancelled(false);
107
+ setExecutionResult(null);
108
+
109
+ try {
110
+ const builtPlan = await buildUploadPlan({
111
+ files,
112
+ typefaceTitle,
113
+ docId,
114
+ settings,
115
+ client,
116
+ stylesObject,
117
+ weightKeywordList,
118
+ italicKeywordList,
119
+ onProgress: (event) => {
120
+ if (cancelRef.current) return;
121
+ // Update progress counter only — don't add entries yet (they haven't been merged by documentId)
122
+ if (event.type === 'font-processed' || event.type === 'font-error') {
123
+ dispatch({ type: 'UPDATE_PROCESSING_PROGRESS', progress: event.progress });
124
+ }
125
+ },
126
+ });
127
+
128
+ if (!cancelRef.current) {
129
+ // Dispatch the final merged plan entries (files with the same documentId are already grouped)
130
+ for (const [tempId, entry] of Object.entries(builtPlan.fonts)) {
131
+ dispatch({ type: 'ADD_PROCESSED_FONT', tempId, fontEntry: entry });
132
+ }
133
+ dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.REVIEWING });
134
+ }
135
+ } catch (err) {
136
+ console.error('Processing failed:', err);
137
+ dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.REVIEWING });
138
+ }
139
+ }, [typefaceTitle, docId, client, stylesObject, weightKeywordList, italicKeywordList]);
140
+
141
+ /** Cancel processing and return to Step 1 */
142
+ const handleCancelProcessing = useCallback(() => {
143
+ cancelRef.current = true;
144
+ setProcessingCancelled(true);
145
+ dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.IDLE });
146
+ }, []);
147
+
148
+ /** Transition to execution */
149
+ const handleStartExecution = useCallback(() => {
150
+ dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.EXECUTING });
151
+ }, []);
152
+
153
+ /** Receive execution result — transition to instance mapping if VFs exist, otherwise complete */
154
+ const handleExecutionComplete = useCallback((result) => {
155
+ setExecutionResult(result);
156
+ if (hasVFs && result.success !== false) {
157
+ // Show instance mapping step before summary
158
+ setInstanceMappingPhase(true);
159
+ dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.COMPLETE });
160
+ } else {
161
+ dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.COMPLETE });
162
+ }
163
+ }, [hasVFs]);
164
+
165
+ /** Handle instance mapping completion */
166
+ const handleInstanceMappingComplete = useCallback((result) => {
167
+ setInstanceMappingResult(result);
168
+ setInstanceMappingPhase(false);
169
+ }, []);
170
+
171
+ if (!open) return null;
172
+
173
+ /** Navigate to a step by clicking the step indicator */
174
+ const handleStepClick = useCallback((stepKey) => {
175
+ if (isExecuting) return;
176
+ if (stepKey === currentStep) return;
177
+ // Can only go back to step 1 (reset to settings)
178
+ if (stepKey === 1 && currentStep > 1) {
179
+ if (Object.keys(plan.fonts).length > 0) {
180
+ if (!window.confirm('Go back to settings? Current review progress will be lost.')) return;
181
+ }
182
+ dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.IDLE });
183
+ }
184
+ }, [currentStep, isExecuting, plan.fonts, dispatch]);
185
+
186
+ return (
187
+ <Dialog
188
+ id="upload-modal"
189
+ header={
190
+ <Flex direction="column" gap={3} style={{ width: '100%' }}>
191
+ <Text weight="semibold" size={2}>Upload Fonts</Text>
192
+ <Flex gap={1} style={{ width: '100%' }}>
193
+ {STEPS.filter(step => step.key !== 4 || hasVFs).map((step, i) => {
194
+ const isActive = currentStep === step.key;
195
+ const isCompleted = currentStep > step.key;
196
+ const isClickable = !isExecuting && step.key < currentStep;
197
+ return (
198
+ <Box
199
+ key={step.key}
200
+ as={isClickable ? 'button' : 'div'}
201
+ onClick={isClickable ? () => handleStepClick(step.key) : undefined}
202
+ style={{
203
+ flex: 1,
204
+ padding: '10px 12px',
205
+ border: 'none',
206
+ borderRadius: 4,
207
+ cursor: isClickable ? 'pointer' : 'default',
208
+ background: isActive
209
+ ? 'var(--card-badge-primary-bg-color)'
210
+ : isCompleted
211
+ ? 'var(--card-badge-positive-bg-color)'
212
+ : 'var(--card-muted-bg-color)',
213
+ color: isActive || isCompleted
214
+ ? '#fff'
215
+ : 'var(--card-muted-fg-color)',
216
+ textAlign: 'center',
217
+ transition: 'background 0.15s ease',
218
+ opacity: !isActive && !isCompleted ? 0.6 : 1,
219
+ }}
220
+ >
221
+ <Text
222
+ size={1}
223
+ weight={isActive ? 'bold' : 'medium'}
224
+ style={{ color: 'inherit' }}
225
+ >
226
+ {step.key}. {step.label}
227
+ </Text>
228
+ </Box>
229
+ );
230
+ })}
231
+ </Flex>
232
+ </Flex>
233
+ }
234
+ width={2}
235
+ onClose={isExecuting ? undefined : handleClose}
236
+ onClickOutside={() => {}}
237
+ >
238
+ <Box padding={4}>
239
+ {/* Step 1: Settings & File Selection */}
240
+ {currentStep === 1 && (
241
+ <UploadStep1Settings
242
+ settings={plan.settings}
243
+ onStartProcessing={handleStartProcessing}
244
+ />
245
+ )}
246
+
247
+ {/* Step 2: Processing & Review */}
248
+ {currentStep === 2 && (
249
+ <UploadStep2Review
250
+ plan={plan}
251
+ dispatch={dispatch}
252
+ onCancelProcessing={handleCancelProcessing}
253
+ onStartExecution={handleStartExecution}
254
+ processingCancelled={processingCancelled}
255
+ />
256
+ )}
257
+
258
+ {/* Step 3: Upload Execution */}
259
+ {currentStep === 3 && plan.phase !== PLAN_PHASE.COMPLETE && (
260
+ <UploadStep3Execute
261
+ key={retryTempIds ? 'retry' : 'initial'}
262
+ plan={plan}
263
+ client={client}
264
+ docId={docId}
265
+ stylesObject={stylesObject}
266
+ preferredStyleRef={preferredStyleRef}
267
+ retryTempIds={retryTempIds}
268
+ onComplete={(result) => {
269
+ setRetryTempIds(null);
270
+ handleExecutionComplete(result);
271
+ }}
272
+ />
273
+ )}
274
+
275
+ {/* Step 4: Variable Font Instance Mapping (only if VFs in batch) */}
276
+ {plan.phase === PLAN_PHASE.COMPLETE && instanceMappingPhase && (
277
+ <UploadStep3bInstances
278
+ plan={plan}
279
+ executionResult={executionResult}
280
+ client={client}
281
+ typefaceTitle={typefaceTitle}
282
+ onComplete={handleInstanceMappingComplete}
283
+ />
284
+ )}
285
+
286
+ {/* Post-completion Summary */}
287
+ {plan.phase === PLAN_PHASE.COMPLETE && !instanceMappingPhase && (
288
+ <UploadSummary
289
+ plan={plan}
290
+ result={executionResult}
291
+ instanceMappingResult={instanceMappingResult}
292
+ onClose={handleClose}
293
+ onRetry={(failedTempIds) => {
294
+ setRetryTempIds(failedTempIds || null);
295
+ setExecutionResult(null);
296
+ setInstanceMappingPhase(false);
297
+ setInstanceMappingResult(null);
298
+ dispatch({ type: 'SET_PHASE', phase: PLAN_PHASE.EXECUTING });
299
+ }}
300
+ client={client}
301
+ docId={docId}
302
+ stylesObject={stylesObject}
303
+ preferredStyleRef={preferredStyleRef}
304
+ />
305
+ )}
306
+ </Box>
307
+ </Dialog>
308
+ );
309
+ }