@liiift-studio/sanity-font-manager 2.4.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/UploadModal-ADNRGQUI.mjs +6 -0
- package/dist/UploadModal-WPK2CXLR.js +6 -0
- package/dist/chunk-JCDZ7SWZ.js +7711 -0
- package/dist/chunk-TMDE4A54.mjs +7711 -0
- package/dist/index.js +666 -1647
- package/dist/index.mjs +319 -1209
- package/package.json +5 -5
- package/src/components/BatchUploadFonts.jsx +57 -44
- package/src/components/BulkActions.jsx +99 -0
- package/src/components/ExistingDocumentResolver.jsx +152 -0
- package/src/components/FontReviewCard.jsx +455 -0
- package/src/components/SingleUploaderTool.jsx +3 -4
- package/src/components/UploadModal.jsx +304 -0
- package/src/components/UploadScriptsComponent.jsx +23 -21
- package/src/components/UploadStep1Settings.jsx +272 -0
- package/src/components/UploadStep2Review.jsx +474 -0
- package/src/components/UploadStep3Execute.jsx +234 -0
- package/src/components/UploadStep3bInstances.jsx +396 -0
- package/src/components/UploadSummary.jsx +196 -0
- package/src/index.js +46 -0
- package/src/utils/buildUploadPlan.js +326 -0
- package/src/utils/executeUploadPlan.js +430 -0
- package/src/utils/executionReducer.js +56 -0
- package/src/utils/fontHelpers.js +267 -0
- package/src/utils/generateCssFile.js +79 -77
- package/src/utils/generateFontData.js +47 -94
- package/src/utils/getEmptyFontKit.js +19 -17
- package/src/utils/parseFont.js +55 -0
- package/src/utils/parseVariableFontInstances.js +237 -147
- package/src/utils/planReducer.js +517 -0
- package/src/utils/planTypes.js +183 -0
- package/src/utils/processFontFiles.js +121 -78
- package/src/utils/regenerateFontData.js +2 -2
- package/src/utils/resolveExistingFont.js +87 -0
- package/src/utils/updateTypefaceDocument.js +15 -2
- package/src/utils/uploadFontFiles.js +405 -405
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Step 3 — Upload execution with per-font progress
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useReducer, useRef, useState, useMemo } from 'react';
|
|
4
|
+
import { Box, Stack, Flex, Text, Card, Spinner, Badge } from '@sanity/ui';
|
|
5
|
+
import { WarningOutlineIcon, CheckmarkCircleIcon } from '@sanity/icons';
|
|
6
|
+
import { executeUploadPlan } from '../utils/executeUploadPlan';
|
|
7
|
+
import { executionReducer, createInitialExecutionState } from '../utils/executionReducer';
|
|
8
|
+
import { EXECUTION_STATUS } from '../utils/planTypes';
|
|
9
|
+
|
|
10
|
+
/** Formats elapsed seconds as "Xm Ys" or "Ys" */
|
|
11
|
+
const formatElapsed = (s) => {
|
|
12
|
+
const m = Math.floor(s / 60);
|
|
13
|
+
const sec = s % 60;
|
|
14
|
+
return m > 0 ? `${m}m ${sec}s` : `${sec}s`;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Step 3 — executes the upload plan, showing per-font progress.
|
|
19
|
+
* Uses a separate executionReducer to avoid re-rendering the review UI.
|
|
20
|
+
*/
|
|
21
|
+
export default function UploadStep3Execute({
|
|
22
|
+
plan,
|
|
23
|
+
client,
|
|
24
|
+
docId,
|
|
25
|
+
stylesObject,
|
|
26
|
+
preferredStyleRef,
|
|
27
|
+
retryTempIds,
|
|
28
|
+
onComplete,
|
|
29
|
+
}) {
|
|
30
|
+
const [execState, execDispatch] = useReducer(executionReducer, null, createInitialExecutionState);
|
|
31
|
+
const [result, setResult] = useState(null);
|
|
32
|
+
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
|
33
|
+
const startedRef = useRef(false);
|
|
34
|
+
const timerRef = useRef(null);
|
|
35
|
+
const wakeLockRef = useRef(null);
|
|
36
|
+
|
|
37
|
+
// Build the execution plan — filter to only failed fonts when retrying
|
|
38
|
+
const executionPlan = useMemo(() => {
|
|
39
|
+
if (!retryTempIds) return plan;
|
|
40
|
+
return {
|
|
41
|
+
...plan,
|
|
42
|
+
fonts: Object.fromEntries(
|
|
43
|
+
Object.entries(plan.fonts).filter(([tempId]) => retryTempIds.includes(tempId))
|
|
44
|
+
),
|
|
45
|
+
};
|
|
46
|
+
}, [plan, retryTempIds]);
|
|
47
|
+
|
|
48
|
+
const fontEntries = useMemo(() =>
|
|
49
|
+
Object.values(executionPlan.fonts).filter(f => f.status !== 'error'),
|
|
50
|
+
[executionPlan]
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Start execution once on mount
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (startedRef.current) return;
|
|
56
|
+
startedRef.current = true;
|
|
57
|
+
|
|
58
|
+
// Start elapsed timer
|
|
59
|
+
timerRef.current = setInterval(() => setElapsedSeconds(s => s + 1), 1000);
|
|
60
|
+
|
|
61
|
+
// Request wake lock
|
|
62
|
+
navigator.wakeLock?.request('screen')
|
|
63
|
+
.then(lock => { wakeLockRef.current = lock; })
|
|
64
|
+
.catch(() => {});
|
|
65
|
+
|
|
66
|
+
execDispatch({ type: 'SET_EXECUTION_STATUS', status: 'uploading' });
|
|
67
|
+
|
|
68
|
+
executeUploadPlan({
|
|
69
|
+
plan: executionPlan,
|
|
70
|
+
client,
|
|
71
|
+
docId,
|
|
72
|
+
stylesObject,
|
|
73
|
+
preferredStyleRef,
|
|
74
|
+
onProgress: (event) => {
|
|
75
|
+
if (event.type === 'font-upload-start' || event.type === 'file-uploaded' ||
|
|
76
|
+
event.type === 'css-generated' || event.type === 'metadata-generated' ||
|
|
77
|
+
event.type === 'document-created') {
|
|
78
|
+
if (event.fontProgress) {
|
|
79
|
+
execDispatch({
|
|
80
|
+
type: 'SET_FONT_EXECUTION_PROGRESS',
|
|
81
|
+
tempId: event.tempId,
|
|
82
|
+
progress: event.fontProgress,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
} else if (event.type === 'typeface-patching') {
|
|
86
|
+
execDispatch({ type: 'SET_EXECUTION_STATUS', status: 'patching-typeface' });
|
|
87
|
+
} else if (event.type === 'typeface-error') {
|
|
88
|
+
execDispatch({ type: 'SET_EXECUTION_ERROR', error: event.error });
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
}).then((executionResult) => {
|
|
92
|
+
setResult(executionResult);
|
|
93
|
+
clearInterval(timerRef.current);
|
|
94
|
+
wakeLockRef.current?.release().catch(() => {});
|
|
95
|
+
execDispatch({ type: 'SET_EXECUTION_STATUS', status: executionResult.success ? 'complete' : 'error' });
|
|
96
|
+
onComplete(executionResult);
|
|
97
|
+
}).catch((err) => {
|
|
98
|
+
clearInterval(timerRef.current);
|
|
99
|
+
wakeLockRef.current?.release().catch(() => {});
|
|
100
|
+
execDispatch({ type: 'SET_EXECUTION_ERROR', error: err.message });
|
|
101
|
+
const errorResult = {
|
|
102
|
+
success: false, created: 0, updated: 0, failed: 1, skipped: 0,
|
|
103
|
+
failedFonts: [{ title: 'Unknown', tempId: 'unknown', error: err.message, failedAt: 'unknown' }],
|
|
104
|
+
fontRefs: [], variableRefs: [], typefacePatchError: err.message,
|
|
105
|
+
};
|
|
106
|
+
setResult(errorResult);
|
|
107
|
+
onComplete(errorResult);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return () => {
|
|
111
|
+
clearInterval(timerRef.current);
|
|
112
|
+
wakeLockRef.current?.release().catch(() => {});
|
|
113
|
+
};
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
116
|
+
const completedCount = Object.values(execState.progress).filter(p =>
|
|
117
|
+
p.status === EXECUTION_STATUS.COMPLETE || p.status === EXECUTION_STATUS.ERROR
|
|
118
|
+
).length;
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<Stack space={4}>
|
|
122
|
+
{/* Global progress */}
|
|
123
|
+
<Card border padding={3} radius={2}>
|
|
124
|
+
<Stack space={3}>
|
|
125
|
+
<Flex align="center" gap={3}>
|
|
126
|
+
{execState.status === 'complete'
|
|
127
|
+
? <CheckmarkCircleIcon style={{ color: '#43b649', fontSize: 20 }} />
|
|
128
|
+
: execState.status === 'error'
|
|
129
|
+
? <WarningOutlineIcon style={{ color: 'var(--card-badge-critical-bg-color)', fontSize: 20 }} />
|
|
130
|
+
: <Spinner />
|
|
131
|
+
}
|
|
132
|
+
<Text size={1} weight="semibold">
|
|
133
|
+
{execState.status === 'patching-typeface'
|
|
134
|
+
? 'Updating typeface document...'
|
|
135
|
+
: execState.status === 'complete'
|
|
136
|
+
? 'Upload complete'
|
|
137
|
+
: execState.status === 'error'
|
|
138
|
+
? 'Upload failed'
|
|
139
|
+
: `Uploading ${completedCount} of ${fontEntries.length} fonts...`
|
|
140
|
+
}
|
|
141
|
+
</Text>
|
|
142
|
+
<Text size={1} muted style={{ marginLeft: 'auto' }}>{formatElapsed(elapsedSeconds)}</Text>
|
|
143
|
+
</Flex>
|
|
144
|
+
|
|
145
|
+
{/* Progress bar */}
|
|
146
|
+
<Box style={{ height: 4, background: 'var(--card-border-color)', borderRadius: 2, overflow: 'hidden' }}>
|
|
147
|
+
<Box
|
|
148
|
+
style={{
|
|
149
|
+
height: '100%',
|
|
150
|
+
width: '100%',
|
|
151
|
+
transformOrigin: 'left',
|
|
152
|
+
transform: `scaleX(${fontEntries.length > 0 ? completedCount / fontEntries.length : 0})`,
|
|
153
|
+
background: execState.status === 'error'
|
|
154
|
+
? 'var(--card-badge-critical-bg-color)'
|
|
155
|
+
: 'var(--card-badge-positive-bg-color)',
|
|
156
|
+
transition: 'transform 0.3s ease-out',
|
|
157
|
+
borderRadius: 2,
|
|
158
|
+
}}
|
|
159
|
+
/>
|
|
160
|
+
</Box>
|
|
161
|
+
</Stack>
|
|
162
|
+
</Card>
|
|
163
|
+
|
|
164
|
+
{/* Do-not-close warning */}
|
|
165
|
+
{execState.status !== 'complete' && execState.status !== 'error' && (
|
|
166
|
+
<Card tone="caution" border radius={2} padding={2}>
|
|
167
|
+
<Flex align="center" gap={2}>
|
|
168
|
+
<WarningOutlineIcon style={{ flexShrink: 0 }} />
|
|
169
|
+
<Text size={1} weight="semibold">Do not close or reload this tab</Text>
|
|
170
|
+
</Flex>
|
|
171
|
+
</Card>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{/* Per-font progress list */}
|
|
175
|
+
<Box style={{ maxHeight: 400, overflowY: 'auto' }}>
|
|
176
|
+
<Stack space={1}>
|
|
177
|
+
{fontEntries.map(entry => {
|
|
178
|
+
const progress = execState.progress[entry.tempId];
|
|
179
|
+
const status = progress?.status || EXECUTION_STATUS.QUEUED;
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<Card key={entry.tempId} border radius={1} padding={2}>
|
|
183
|
+
<Flex align="center" gap={2}>
|
|
184
|
+
<Text size={1} style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
|
|
185
|
+
{entry.title}
|
|
186
|
+
</Text>
|
|
187
|
+
<Box style={{ width: 120, flexShrink: 0, textAlign: 'right' }}>
|
|
188
|
+
{status === EXECUTION_STATUS.QUEUED && (
|
|
189
|
+
<Badge mode="outline" fontSize={0}>Queued</Badge>
|
|
190
|
+
)}
|
|
191
|
+
{status === EXECUTION_STATUS.UPLOADING_ASSETS && (
|
|
192
|
+
<Flex gap={1} align="center" justify="flex-end">
|
|
193
|
+
<Text size={0} muted>{progress?.currentFile || 'Uploading...'}</Text>
|
|
194
|
+
<Spinner style={{ width: 12, height: 12 }} />
|
|
195
|
+
</Flex>
|
|
196
|
+
)}
|
|
197
|
+
{(status === EXECUTION_STATUS.GENERATING_CSS || status === EXECUTION_STATUS.GENERATING_METADATA) && (
|
|
198
|
+
<Flex gap={1} align="center" justify="flex-end">
|
|
199
|
+
<Text size={0} muted>{status === EXECUTION_STATUS.GENERATING_CSS ? 'CSS' : 'Metadata'}</Text>
|
|
200
|
+
<Spinner style={{ width: 12, height: 12 }} />
|
|
201
|
+
</Flex>
|
|
202
|
+
)}
|
|
203
|
+
{status === EXECUTION_STATUS.CREATING_DOCUMENT && (
|
|
204
|
+
<Flex gap={1} align="center" justify="flex-end">
|
|
205
|
+
<Text size={0} muted>Creating doc</Text>
|
|
206
|
+
<Spinner style={{ width: 12, height: 12 }} />
|
|
207
|
+
</Flex>
|
|
208
|
+
)}
|
|
209
|
+
{status === EXECUTION_STATUS.COMPLETE && (
|
|
210
|
+
<Badge tone="positive" fontSize={0}>Done</Badge>
|
|
211
|
+
)}
|
|
212
|
+
{status === EXECUTION_STATUS.ERROR && (
|
|
213
|
+
<Badge tone="critical" fontSize={0}>Failed</Badge>
|
|
214
|
+
)}
|
|
215
|
+
</Box>
|
|
216
|
+
</Flex>
|
|
217
|
+
{status === EXECUTION_STATUS.ERROR && progress?.error && (
|
|
218
|
+
<Text size={0} muted style={{ marginTop: 4 }}>{progress.error}</Text>
|
|
219
|
+
)}
|
|
220
|
+
</Card>
|
|
221
|
+
);
|
|
222
|
+
})}
|
|
223
|
+
</Stack>
|
|
224
|
+
</Box>
|
|
225
|
+
|
|
226
|
+
{/* Error details */}
|
|
227
|
+
{execState.error && (
|
|
228
|
+
<Card tone="critical" border padding={3} radius={2}>
|
|
229
|
+
<Text size={1}>{execState.error}</Text>
|
|
230
|
+
</Card>
|
|
231
|
+
)}
|
|
232
|
+
</Stack>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// Step 3.5 — Variable font instance mapping using the production parseVariableFontInstances matcher
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
|
4
|
+
import { Box, Stack, Flex, Text, Card, Badge, Button, Spinner, Autocomplete } from '@sanity/ui';
|
|
5
|
+
import { CheckmarkCircleIcon, CloseCircleIcon, SearchIcon } from '@sanity/icons';
|
|
6
|
+
import { nanoid } from 'nanoid';
|
|
7
|
+
import { parseVariableFontInstances } from '../utils/parseVariableFontInstances';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Step 3.5 — Variable font instance mapping.
|
|
11
|
+
* After upload, fetches the VF documents from Sanity and uses the production
|
|
12
|
+
* parseVariableFontInstances matcher to auto-match instances to static fonts.
|
|
13
|
+
* User can review, override via searchable autocomplete, and save.
|
|
14
|
+
*/
|
|
15
|
+
export default function UploadStep3bInstances({
|
|
16
|
+
plan,
|
|
17
|
+
executionResult,
|
|
18
|
+
client,
|
|
19
|
+
typefaceTitle,
|
|
20
|
+
onComplete,
|
|
21
|
+
}) {
|
|
22
|
+
const [loading, setLoading] = useState(true);
|
|
23
|
+
const [saving, setSaving] = useState(false);
|
|
24
|
+
const [vfMappings, setVfMappings] = useState({});
|
|
25
|
+
const [allStaticFonts, setAllStaticFonts] = useState([]);
|
|
26
|
+
const [filterUnmatched, setFilterUnmatched] = useState(false);
|
|
27
|
+
|
|
28
|
+
// Identify VF entries from the plan
|
|
29
|
+
const vfEntries = useMemo(() =>
|
|
30
|
+
Object.values(plan.fonts).filter(f => f.variableFont && f.status !== 'error'),
|
|
31
|
+
[plan.fonts]
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
/** Run the production matcher for all VFs — callable on mount and via re-run button */
|
|
35
|
+
const runMatching = useCallback(async () => {
|
|
36
|
+
setLoading(true);
|
|
37
|
+
const mappings = {};
|
|
38
|
+
|
|
39
|
+
// Fetch all static fonts for the autocomplete picker
|
|
40
|
+
let staticFonts = [];
|
|
41
|
+
try {
|
|
42
|
+
const typeface = await client.fetch(
|
|
43
|
+
`*[_type == 'typeface' && title == $typefaceTitle][0]{
|
|
44
|
+
'fonts': styles.fonts[]-> {
|
|
45
|
+
_id, title, subfamily, style, weight, weightName, metaData, variableFont
|
|
46
|
+
}
|
|
47
|
+
}`,
|
|
48
|
+
{ typefaceTitle }
|
|
49
|
+
);
|
|
50
|
+
if (typeface?.fonts?.length > 0) {
|
|
51
|
+
staticFonts = typeface.fonts.filter(f => !f.variableFont);
|
|
52
|
+
}
|
|
53
|
+
if (staticFonts.length === 0) {
|
|
54
|
+
staticFonts = await client.fetch(
|
|
55
|
+
`*[_type == 'font' && typefaceName == $typefaceTitle && variableFont != true]{
|
|
56
|
+
_id, title, subfamily, style, weight, weightName, metaData
|
|
57
|
+
}`,
|
|
58
|
+
{ typefaceTitle }
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error('[InstanceMapper] Failed to fetch static fonts:', err);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Deduplicate by _id (draft + published versions can cause duplicates)
|
|
66
|
+
const deduped = new Map();
|
|
67
|
+
staticFonts.forEach(f => { if (f._id && !deduped.has(f._id)) deduped.set(f._id, f); });
|
|
68
|
+
staticFonts = [...deduped.values()];
|
|
69
|
+
|
|
70
|
+
console.log(`[InstanceMapper] Found ${staticFonts.length} static fonts for "${typefaceTitle}"`);
|
|
71
|
+
setAllStaticFonts(staticFonts);
|
|
72
|
+
|
|
73
|
+
for (const vf of vfEntries) {
|
|
74
|
+
try {
|
|
75
|
+
const vfDoc = await client.fetch(
|
|
76
|
+
`*[_id == $id][0]{
|
|
77
|
+
_id, title, typefaceName, variableFont, variableInstances, metaData
|
|
78
|
+
}`,
|
|
79
|
+
{ id: vf.documentId }
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (!vfDoc) {
|
|
83
|
+
console.warn(`[InstanceMapper] VF document not found: ${vf.documentId}`);
|
|
84
|
+
mappings[vf.tempId] = [];
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
console.log(`[InstanceMapper] Running matcher for VF: ${vfDoc.title}, variableInstances: ${vfDoc.variableInstances ? 'present' : 'missing'}`);
|
|
89
|
+
|
|
90
|
+
const instanceMappings = await parseVariableFontInstances(vfDoc, client);
|
|
91
|
+
|
|
92
|
+
console.log(`[InstanceMapper] Matched ${instanceMappings.filter(m => m.value).length}/${instanceMappings.length} instances for ${vfDoc.title}`);
|
|
93
|
+
|
|
94
|
+
mappings[vf.tempId] = instanceMappings.map(m => ({
|
|
95
|
+
instanceName: m.key,
|
|
96
|
+
matchedFontId: m.value?._ref || '',
|
|
97
|
+
matchedFontTitle: '',
|
|
98
|
+
_key: m._key || nanoid(),
|
|
99
|
+
}));
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.error(`[InstanceMapper] Error matching VF ${vf.documentId}:`, err);
|
|
102
|
+
mappings[vf.tempId] = [];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Resolve matched font titles
|
|
107
|
+
const allMatchedIds = new Set();
|
|
108
|
+
Object.values(mappings).forEach(m => m.forEach(i => { if (i.matchedFontId) allMatchedIds.add(i.matchedFontId); }));
|
|
109
|
+
if (allMatchedIds.size > 0) {
|
|
110
|
+
try {
|
|
111
|
+
const titles = await client.fetch(`*[_id in $ids]{ _id, title }`, { ids: [...allMatchedIds] });
|
|
112
|
+
const titleMap = {};
|
|
113
|
+
titles.forEach(t => { titleMap[t._id] = t.title; });
|
|
114
|
+
Object.values(mappings).forEach(m => {
|
|
115
|
+
m.forEach(i => { if (i.matchedFontId) i.matchedFontTitle = titleMap[i.matchedFontId] || i.matchedFontId; });
|
|
116
|
+
});
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.warn('[InstanceMapper] Failed to resolve font titles:', err);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
setVfMappings(mappings);
|
|
123
|
+
setLoading(false);
|
|
124
|
+
}, [vfEntries, client, typefaceTitle]);
|
|
125
|
+
|
|
126
|
+
// Run on mount
|
|
127
|
+
useEffect(() => { runMatching(); }, [runMatching]);
|
|
128
|
+
|
|
129
|
+
// Track claimed font IDs to prevent duplicates in manual selection
|
|
130
|
+
const claimedFontIds = useMemo(() => {
|
|
131
|
+
const claimed = new Set();
|
|
132
|
+
Object.values(vfMappings).forEach(mappings => {
|
|
133
|
+
mappings.forEach(m => { if (m.matchedFontId) claimed.add(m.matchedFontId); });
|
|
134
|
+
});
|
|
135
|
+
return claimed;
|
|
136
|
+
}, [vfMappings]);
|
|
137
|
+
|
|
138
|
+
/** Update a single instance mapping */
|
|
139
|
+
const handleMappingChange = useCallback((vfTempId, instanceKey, fontId) => {
|
|
140
|
+
const font = allStaticFonts.find(sf => sf._id === fontId);
|
|
141
|
+
setVfMappings(prev => ({
|
|
142
|
+
...prev,
|
|
143
|
+
[vfTempId]: prev[vfTempId].map(m =>
|
|
144
|
+
m._key === instanceKey
|
|
145
|
+
? { ...m, matchedFontId: fontId, matchedFontTitle: font?.title || fontId }
|
|
146
|
+
: m
|
|
147
|
+
),
|
|
148
|
+
}));
|
|
149
|
+
}, [allStaticFonts]);
|
|
150
|
+
|
|
151
|
+
/** Save all mappings — patch each VF document */
|
|
152
|
+
const handleSave = useCallback(async () => {
|
|
153
|
+
setSaving(true);
|
|
154
|
+
const errors = [];
|
|
155
|
+
|
|
156
|
+
for (const vf of vfEntries) {
|
|
157
|
+
const mappings = vfMappings[vf.tempId] || [];
|
|
158
|
+
const references = mappings
|
|
159
|
+
.filter(m => m.matchedFontId)
|
|
160
|
+
.map(m => ({
|
|
161
|
+
_key: nanoid(),
|
|
162
|
+
_type: 'object',
|
|
163
|
+
key: m.instanceName,
|
|
164
|
+
value: {
|
|
165
|
+
_type: 'reference',
|
|
166
|
+
_ref: m.matchedFontId,
|
|
167
|
+
_weak: true,
|
|
168
|
+
},
|
|
169
|
+
}));
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
await client.patch(vf.documentId).set({
|
|
173
|
+
variableInstanceReferences: references,
|
|
174
|
+
}).commit();
|
|
175
|
+
console.log(`Patched VF instance mappings: ${vf.documentId} (${references.length} instances)`);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.error(`Failed to patch VF ${vf.documentId}:`, err);
|
|
178
|
+
errors.push({ vfTitle: vf.title, error: err.message });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
setSaving(false);
|
|
183
|
+
onComplete({ success: errors.length === 0, errors });
|
|
184
|
+
}, [vfEntries, vfMappings, client, onComplete]);
|
|
185
|
+
|
|
186
|
+
// Stats
|
|
187
|
+
const totalInstances = Object.values(vfMappings).reduce((sum, m) => sum + m.length, 0);
|
|
188
|
+
const matchedInstances = Object.values(vfMappings).reduce(
|
|
189
|
+
(sum, m) => sum + m.filter(i => i.matchedFontId).length, 0
|
|
190
|
+
);
|
|
191
|
+
const unmatchedInstances = totalInstances - matchedInstances;
|
|
192
|
+
|
|
193
|
+
// Autocomplete options — exclude already-claimed fonts
|
|
194
|
+
const getAutocompleteOptions = useCallback((currentFontId) => {
|
|
195
|
+
return allStaticFonts
|
|
196
|
+
.filter(sf => !claimedFontIds.has(sf._id) || sf._id === currentFontId)
|
|
197
|
+
.map(sf => ({
|
|
198
|
+
value: sf._id,
|
|
199
|
+
payload: sf,
|
|
200
|
+
}));
|
|
201
|
+
}, [allStaticFonts, claimedFontIds]);
|
|
202
|
+
|
|
203
|
+
if (loading) {
|
|
204
|
+
return (
|
|
205
|
+
<Card border padding={4} radius={2}>
|
|
206
|
+
<Flex align="center" gap={3} justify="center">
|
|
207
|
+
<Spinner />
|
|
208
|
+
<Text size={1}>Matching variable font instances to static fonts...</Text>
|
|
209
|
+
</Flex>
|
|
210
|
+
</Card>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (vfEntries.length === 0) {
|
|
215
|
+
onComplete({ success: true, errors: [] });
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<Stack space={4}>
|
|
221
|
+
{/* Header */}
|
|
222
|
+
<Stack space={2}>
|
|
223
|
+
<Text size={2} weight="semibold">Map Variable Font Instances</Text>
|
|
224
|
+
<Text size={1} muted>
|
|
225
|
+
Review the auto-matched instances below. Each named instance should map to its corresponding static font document.
|
|
226
|
+
</Text>
|
|
227
|
+
</Stack>
|
|
228
|
+
|
|
229
|
+
{/* Stats */}
|
|
230
|
+
<Flex gap={2} align="center">
|
|
231
|
+
<Button
|
|
232
|
+
mode="ghost"
|
|
233
|
+
tone="primary"
|
|
234
|
+
icon={SearchIcon}
|
|
235
|
+
text="Re-run Matching"
|
|
236
|
+
fontSize={0}
|
|
237
|
+
padding={2}
|
|
238
|
+
onClick={runMatching}
|
|
239
|
+
disabled={loading}
|
|
240
|
+
style={{ cursor: 'pointer' }}
|
|
241
|
+
/>
|
|
242
|
+
<Badge tone="positive" fontSize={0}>{matchedInstances} matched</Badge>
|
|
243
|
+
{unmatchedInstances > 0 && (
|
|
244
|
+
<Badge
|
|
245
|
+
tone="critical"
|
|
246
|
+
fontSize={0}
|
|
247
|
+
style={{ cursor: 'pointer' }}
|
|
248
|
+
onClick={() => setFilterUnmatched(v => !v)}
|
|
249
|
+
>
|
|
250
|
+
{unmatchedInstances} unmatched {filterUnmatched ? '(showing)' : ''}
|
|
251
|
+
</Badge>
|
|
252
|
+
)}
|
|
253
|
+
{filterUnmatched && (
|
|
254
|
+
<Badge
|
|
255
|
+
mode="outline"
|
|
256
|
+
fontSize={0}
|
|
257
|
+
style={{ cursor: 'pointer' }}
|
|
258
|
+
onClick={() => setFilterUnmatched(false)}
|
|
259
|
+
>
|
|
260
|
+
Clear filter
|
|
261
|
+
</Badge>
|
|
262
|
+
)}
|
|
263
|
+
<Badge mode="outline" fontSize={0}>{allStaticFonts.length} fonts available</Badge>
|
|
264
|
+
</Flex>
|
|
265
|
+
|
|
266
|
+
{/* Per-VF mapping sections */}
|
|
267
|
+
{vfEntries.map(vf => {
|
|
268
|
+
const mappings = vfMappings[vf.tempId] || [];
|
|
269
|
+
const displayMappings = filterUnmatched
|
|
270
|
+
? mappings.filter(m => !m.matchedFontId)
|
|
271
|
+
: mappings;
|
|
272
|
+
const vfMatched = mappings.filter(m => m.matchedFontId).length;
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<Card key={vf.tempId} border padding={3} radius={2}>
|
|
276
|
+
<Stack space={3}>
|
|
277
|
+
{/* VF header */}
|
|
278
|
+
<Flex align="center" gap={2}>
|
|
279
|
+
<Badge tone="primary" fontSize={0}>VF</Badge>
|
|
280
|
+
<Text size={1} weight="semibold">{vf.title}</Text>
|
|
281
|
+
<Text size={0} muted style={{ marginLeft: 'auto' }}>
|
|
282
|
+
{vfMatched}/{mappings.length} matched
|
|
283
|
+
</Text>
|
|
284
|
+
</Flex>
|
|
285
|
+
|
|
286
|
+
{/* Column headers */}
|
|
287
|
+
<Flex align="center" gap={2} paddingY={1} style={{ borderBottom: '1px solid var(--card-border-color)' }}>
|
|
288
|
+
<Box style={{ width: 20 }} />
|
|
289
|
+
<Text size={0} weight="semibold" muted style={{ flex: 1 }}>Instance</Text>
|
|
290
|
+
<Text size={0} weight="semibold" muted style={{ flex: 2 }}>Static Font Document</Text>
|
|
291
|
+
</Flex>
|
|
292
|
+
|
|
293
|
+
{/* Instance rows */}
|
|
294
|
+
<Stack space={1}>
|
|
295
|
+
{displayMappings.map(mapping => {
|
|
296
|
+
const isMatched = !!mapping.matchedFontId;
|
|
297
|
+
const options = getAutocompleteOptions(mapping.matchedFontId);
|
|
298
|
+
|
|
299
|
+
return (
|
|
300
|
+
<Flex
|
|
301
|
+
key={mapping._key}
|
|
302
|
+
align="center"
|
|
303
|
+
gap={2}
|
|
304
|
+
paddingY={2}
|
|
305
|
+
style={{ borderBottom: '1px solid var(--card-border-color)' }}
|
|
306
|
+
>
|
|
307
|
+
<Box style={{ width: 20, flexShrink: 0 }}>
|
|
308
|
+
{isMatched
|
|
309
|
+
? <CheckmarkCircleIcon style={{ color: '#43b649', fontSize: 16 }} />
|
|
310
|
+
: <CloseCircleIcon style={{ color: '#f03e2f', fontSize: 16 }} />
|
|
311
|
+
}
|
|
312
|
+
</Box>
|
|
313
|
+
|
|
314
|
+
<Text size={1} style={{ flex: 1, whiteSpace: 'nowrap' }}>{mapping.instanceName}</Text>
|
|
315
|
+
|
|
316
|
+
<Box style={{ flex: 2 }}>
|
|
317
|
+
<Autocomplete
|
|
318
|
+
id={`instance-${mapping._key}`}
|
|
319
|
+
options={options}
|
|
320
|
+
value={mapping.matchedFontId}
|
|
321
|
+
placeholder="Search for a font..."
|
|
322
|
+
icon={SearchIcon}
|
|
323
|
+
fontSize={1}
|
|
324
|
+
filterOption={(query, option) => {
|
|
325
|
+
const sf = option.payload;
|
|
326
|
+
const q = query.toLowerCase();
|
|
327
|
+
return (
|
|
328
|
+
sf.title?.toLowerCase().includes(q) ||
|
|
329
|
+
sf._id?.toLowerCase().includes(q) ||
|
|
330
|
+
sf.weightName?.toLowerCase().includes(q) ||
|
|
331
|
+
String(sf.weight).includes(q) ||
|
|
332
|
+
sf.subfamily?.toLowerCase().includes(q)
|
|
333
|
+
);
|
|
334
|
+
}}
|
|
335
|
+
renderOption={(option) => {
|
|
336
|
+
const sf = option.payload;
|
|
337
|
+
const isClaimed = claimedFontIds.has(sf._id) && sf._id !== mapping.matchedFontId;
|
|
338
|
+
return (
|
|
339
|
+
<Card as="button" padding={2} style={{ opacity: isClaimed ? 0.4 : 1 }}>
|
|
340
|
+
<Flex align="center" gap={2}>
|
|
341
|
+
<Text size={1}>{sf.title}</Text>
|
|
342
|
+
<Text size={0} muted>{sf.weight} {sf.style}</Text>
|
|
343
|
+
{sf.subfamily && sf.subfamily !== 'Regular' && (
|
|
344
|
+
<Badge mode="outline" fontSize={0}>{sf.subfamily}</Badge>
|
|
345
|
+
)}
|
|
346
|
+
</Flex>
|
|
347
|
+
</Card>
|
|
348
|
+
);
|
|
349
|
+
}}
|
|
350
|
+
renderValue={(value, option) => {
|
|
351
|
+
if (option?.payload) return option.payload.title;
|
|
352
|
+
if (mapping.matchedFontTitle) return mapping.matchedFontTitle;
|
|
353
|
+
const font = allStaticFonts.find(sf => sf._id === value);
|
|
354
|
+
return font?.title || value;
|
|
355
|
+
}}
|
|
356
|
+
onSelect={(value) => handleMappingChange(vf.tempId, mapping._key, value)}
|
|
357
|
+
openButton
|
|
358
|
+
/>
|
|
359
|
+
</Box>
|
|
360
|
+
</Flex>
|
|
361
|
+
);
|
|
362
|
+
})}
|
|
363
|
+
</Stack>
|
|
364
|
+
|
|
365
|
+
{mappings.length === 0 && (
|
|
366
|
+
<Text size={1} muted>No named instances found in this variable font.</Text>
|
|
367
|
+
)}
|
|
368
|
+
</Stack>
|
|
369
|
+
</Card>
|
|
370
|
+
);
|
|
371
|
+
})}
|
|
372
|
+
|
|
373
|
+
{/* Actions */}
|
|
374
|
+
<Flex justify="flex-end" gap={2} style={{ position: 'sticky', bottom: 0, background: 'var(--card-bg-color)', paddingTop: 8, paddingBottom: 4 }}>
|
|
375
|
+
<Button
|
|
376
|
+
mode="ghost"
|
|
377
|
+
text="Skip — I'll map instances later"
|
|
378
|
+
fontSize={1}
|
|
379
|
+
padding={3}
|
|
380
|
+
onClick={() => onComplete({ success: true, errors: [], skipped: true })}
|
|
381
|
+
style={{ cursor: 'pointer' }}
|
|
382
|
+
/>
|
|
383
|
+
<Button
|
|
384
|
+
mode="default"
|
|
385
|
+
tone="positive"
|
|
386
|
+
text={saving ? 'Saving...' : `Save Mappings (${matchedInstances}/${totalInstances})`}
|
|
387
|
+
fontSize={1}
|
|
388
|
+
padding={3}
|
|
389
|
+
disabled={saving}
|
|
390
|
+
onClick={handleSave}
|
|
391
|
+
style={{ cursor: 'pointer' }}
|
|
392
|
+
/>
|
|
393
|
+
</Flex>
|
|
394
|
+
</Stack>
|
|
395
|
+
);
|
|
396
|
+
}
|