@liiift-studio/sanity-font-manager 2.5.0 → 2.5.2

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,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
+ }
package/src/index.js CHANGED
@@ -76,6 +76,7 @@ export { default as UploadModal } from './components/UploadModal.jsx';
76
76
  export { default as UploadStep1Settings } from './components/UploadStep1Settings.jsx';
77
77
  export { default as UploadStep2Review } from './components/UploadStep2Review.jsx';
78
78
  export { default as UploadStep3Execute } from './components/UploadStep3Execute.jsx';
79
+ export { default as UploadStep3bInstances } from './components/UploadStep3bInstances.jsx';
79
80
  export { default as UploadSummary } from './components/UploadSummary.jsx';
80
81
  export { default as FontReviewCard } from './components/FontReviewCard.jsx';
81
82
  export { default as ExistingDocumentResolver } from './components/ExistingDocumentResolver.jsx';
@@ -2,6 +2,8 @@
2
2
  import React from 'react';
3
3
  import { AdvancedRefArray } from '@liiift-studio/sanity-advanced-reference-array';
4
4
  import { RegenerateSubfamiliesComponent } from '../components/RegenerateSubfamiliesComponent.jsx';
5
+ import { GenerateCollectionsPairsComponent } from '../components/GenerateCollectionsPairsComponent.jsx';
6
+ import { PrimaryCollectionGeneratorTypeface } from '../components/PrimaryCollectionGeneratorTypeface.jsx';
5
7
 
6
8
  // Returns extra GROQ params scoped to the current typeface document
7
9
  const typefaceParams = (doc) => ({ typefaceName: doc?.title || '' });
@@ -93,6 +95,8 @@ export function createStylesField({
93
95
  subfamilyFontFilter = false,
94
96
  subfamilyPreview = false,
95
97
  pairs = true,
98
+ generateCollections = false,
99
+ generateFullFamilyCollection = false,
96
100
  } = {}) {
97
101
 
98
102
  const subfamilyFields = [
@@ -236,6 +240,22 @@ export function createStylesField({
236
240
  type: 'array',
237
241
  of: [subfamilyItem],
238
242
  },
243
+ ...field(generateCollections, {
244
+ title: 'Generate Collections and Pairs',
245
+ name: 'generateCollections',
246
+ type: 'string',
247
+ description: "Generate Collections and Pairs from the typeface's fonts.",
248
+ components: { input: GenerateCollectionsPairsComponent },
249
+ hidden: ({ parent }) => !parent?.fonts?.length,
250
+ }),
251
+ ...field(generateFullFamilyCollection, {
252
+ title: 'Generate Full Family Collection',
253
+ name: 'generateCollectionGroup',
254
+ type: 'string',
255
+ description: 'Generate a Collection that includes all styles from this typeface.',
256
+ components: { input: PrimaryCollectionGeneratorTypeface },
257
+ hidden: ({ parent }) => !parent?.fonts?.length,
258
+ }),
239
259
  {
240
260
  title: 'Collections',
241
261
  name: 'collections',
@@ -57,6 +57,7 @@ export async function buildUploadPlan({
57
57
  onProgress,
58
58
  }) {
59
59
  const plan = createEmptyPlan(settings);
60
+ plan.settings.typefaceTitle = typefaceTitle;
60
61
  plan.phase = PLAN_PHASE.PROCESSING;
61
62
  plan.processingProgress.total = files.length;
62
63
 
@@ -327,9 +327,7 @@ async function executeSingleFont({ entry, plan, client, progress, onProgress })
327
327
  _key: nanoid(),
328
328
  title: entry.title,
329
329
  slug: { _type: 'slug', current: fontDocId },
330
- typefaceName: plan.fonts[entry.tempId]?.decisions?.title?.original
331
- ? entry.title.split(' ').slice(0, -1).join(' ') || entry.title
332
- : entry.title,
330
+ typefaceName: plan.settings.typefaceTitle || entry.title,
333
331
  style: entry.style,
334
332
  variableFont: entry.variableFont,
335
333
  weightName: entry.weightName,
@@ -341,11 +339,6 @@ async function executeSingleFont({ entry, plan, client, progress, onProgress })
341
339
  fileInput,
342
340
  };
343
341
 
344
- // Set typefaceName from the typeface title, not derived from font title
345
- fontDoc.typefaceName = Object.values(plan.fonts)[0]?.decisions?.title?.original
346
- ? plan.settings.typefaceTitle || fontDoc.typefaceName
347
- : fontDoc.typefaceName;
348
-
349
342
  // Add metadata fields if available
350
343
  if (entry.metaData) fontDoc.metaData = entry.metaData;
351
344
  if (entry.metrics) fontDoc.metrics = entry.metrics;