@liiift-studio/sanity-font-manager 2.7.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 +1634 -17839
  4. package/dist/index.mjs +1551 -17745
  5. package/package.json +83 -83
  6. package/src/components/BatchUploadFonts.jsx +655 -655
  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 -309
  25. package/src/components/UploadScriptsComponent.jsx +539 -539
  26. package/src/components/UploadStep1Settings.jsx +272 -272
  27. package/src/components/UploadStep2Review.jsx +478 -478
  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 -281
  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 -531
  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 -154
  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,674 +1,674 @@
1
- // Per-font file manager — TTF/OTF/WOFF/WOFF2/CSS rows always visible; EOT/SVG/WEB/SUBSET/DATA behind an advanced toggle
2
-
3
- import React, { useState, useEffect, useCallback, useMemo } from 'react';
4
- import { Button, Grid, Stack, Flex, Box, Text, Card } from '@sanity/ui';
5
- import { TrashIcon, ControlsIcon } from '@sanity/icons';
6
- import { useFormValue, set, unset } from 'sanity';
7
- import { parseFont } from '../utils/parseFont';
8
-
9
- import { useSanityClient } from '../hooks/useSanityClient';
10
- import {
11
- readFontFile,
12
- extractFontMetadata,
13
- determineWeight,
14
- } from '../utils/processFontFiles';
15
- import { generateStyleKeywords } from '../utils/generateKeywords';
16
- import generateCssFile from '../utils/generateCssFile';
17
- import generateFontData from '../utils/generateFontData';
18
- import generateFontFile from '../utils/generateFontFile';
19
- import generateSubset from '../utils/generateSubset';
20
- import { parseVariableFontInstances } from '../utils/parseVariableFontInstances';
21
- import StatusDisplay from './StatusDisplay';
22
-
23
- /**
24
- * Font file manager rendered inside a font document.
25
- * Shows TTF/OTF/WOFF/WOFF2/WEB/SUBSET/EOT/SVG/CSS/DATA rows with Upload/Build/Delete controls.
26
- * @param {Object} props
27
- * @param {Object} props.elementProps
28
- * @param {Function} props.onChange
29
- */
30
- export const SingleUploaderTool = (props) => {
31
- const client = useSanityClient();
32
-
33
- const { elementProps: { ref }, onChange } = props;
34
-
35
- const [message, setMessage] = useState('');
36
- const [status, setStatus] = useState('ready');
37
- const [error, setError] = useState(false);
38
- const [filenames, setFilenames] = useState({});
39
- const [showAdvanced, setShowAdvanced] = useState(false);
40
-
41
- const fileInput = useFormValue(['fileInput']);
42
- const doc_id = useFormValue(['_id']);
43
- const doc_title = useFormValue(['title']);
44
- const doc_typefaceName = useFormValue(['typefaceName']);
45
- const doc_variableFont = useFormValue(['variableFont']);
46
- const doc_weight = useFormValue(['weight']);
47
- const doc_style = useFormValue(['style']);
48
- const doc_slug = useFormValue(['slug']);
49
- const doc_metaData = useFormValue(['metaData']);
50
-
51
- const { weightKeywordList, italicKeywordList } = useMemo(() => generateStyleKeywords(), []);
52
-
53
- useEffect(() => { handleSetFilenames(); }, [fileInput]);
54
-
55
- /** Fetches originalFilename for each asset ref in fileInput (including woff2_web/woff2_subset). */
56
- const handleSetFilenames = useCallback(async () => {
57
- const woff2WebRef = fileInput?.woff2_web?.asset?._ref ?? null;
58
- const woff2SubsetRef = fileInput?.woff2_subset?.asset?._ref ?? null;
59
-
60
- const assetIds = [
61
- fileInput?.ttf?.asset?._ref,
62
- fileInput?.otf?.asset?._ref,
63
- fileInput?.woff?.asset?._ref,
64
- fileInput?.woff2?.asset?._ref,
65
- fileInput?.eot?.asset?._ref,
66
- fileInput?.svg?.asset?._ref,
67
- fileInput?.css?.asset?._ref,
68
- woff2WebRef,
69
- woff2SubsetRef,
70
- ].filter(Boolean);
71
-
72
- if (assetIds.length === 0) { setFilenames({}); return; }
73
-
74
- const assetData = await client.fetch(
75
- `*[_id in $assetIds]{ _id, originalFilename }`,
76
- { assetIds }
77
- );
78
-
79
- const fontNames = assetData.reduce((acc, cur) => {
80
- if (cur.originalFilename.endsWith('.ttf')) acc.ttf = cur.originalFilename;
81
- else if (cur.originalFilename.endsWith('.otf')) acc.otf = cur.originalFilename;
82
- else if (cur.originalFilename.endsWith('.woff2') && cur._id === woff2WebRef) acc.woff2_web = cur.originalFilename;
83
- else if (cur.originalFilename.endsWith('.woff2') && cur._id === woff2SubsetRef) acc.woff2_subset = cur.originalFilename;
84
- else if (cur.originalFilename.endsWith('.woff2')) acc.woff2 = cur.originalFilename;
85
- else if (cur.originalFilename.endsWith('.woff')) acc.woff = cur.originalFilename;
86
- else if (cur.originalFilename.endsWith('.eot')) acc.eot = cur.originalFilename;
87
- else if (cur.originalFilename.endsWith('.svg')) acc.svg = cur.originalFilename;
88
- else if (cur.originalFilename.endsWith('.css')) acc.css = cur.originalFilename;
89
- return acc;
90
- }, {});
91
-
92
- setFilenames(fontNames);
93
- }, [fileInput, client]);
94
-
95
- /** Regenerates the @font-face CSS file from the stored woff2 asset. */
96
- const handleGenerateCssFile = useCallback(async () => {
97
- setMessage('Building CSS: ' + doc_title + '.css');
98
- setStatus('Building CSS file');
99
- setError(false);
100
-
101
- try {
102
- const woff2AssetRef = fileInput?.woff2?.asset?._ref;
103
- if (!woff2AssetRef) throw new Error('No woff2 file available');
104
-
105
- const [woff2Asset] = await client.fetch(
106
- `*[_id == $id]{ originalFilename, url }`,
107
- { id: woff2AssetRef }
108
- );
109
-
110
- const blob = await (await fetch(woff2Asset.url)).blob();
111
-
112
- const newFileInput = await generateCssFile({
113
- woff2File: blob,
114
- fileInput: fileInput,
115
- fontName: doc_title,
116
- fileName: woff2Asset.originalFilename.replace('.woff2', ''),
117
- variableFont: doc_variableFont,
118
- weight: doc_weight,
119
- client: client,
120
- });
121
-
122
- setMessage('CSS built');
123
- setStatus('CSS built successfully');
124
- setTimeout(() => { setMessage(''); setStatus('ready'); }, 2000);
125
- onChange(set(newFileInput));
126
- } catch (err) {
127
- console.error('Error building CSS file:', err);
128
- setMessage('Error building CSS file: ' + err.message);
129
- setStatus('Error building CSS file');
130
- setError(true);
131
- setTimeout(() => { setMessage(''); setStatus('ready'); setError(false); }, 3000);
132
- }
133
- }, [fileInput, onChange, doc_title, doc_variableFont, doc_weight, client]);
134
-
135
- /** Converts and uploads the source font file to one or more target formats. */
136
- const handleGenerateFontFile = useCallback(async (code, sourceFile) => {
137
- const isMissing = Array.isArray(code);
138
- const label = code === 'all' ? 'all font files' : isMissing ? 'missing files' : code + ' file';
139
- setMessage(`Building ${label}...`);
140
- setStatus(`Building ${label}`);
141
- setError(false);
142
-
143
- try {
144
- const url = `https://cdn.sanity.io/files/${process.env.SANITY_STUDIO_PROJECT_ID}/${process.env.SANITY_STUDIO_DATASET}/${sourceFile?.asset?._ref.replace('file-', '').replace('-', '.')}`;
145
- const codes = code === 'all' ? ['otf', 'woff', 'woff2', 'eot', 'svg', 'data'] : isMissing ? code : [code];
146
-
147
- await generateFontFile({
148
- codes,
149
- srcUrl: url,
150
- filename: doc_slug.current,
151
- documentId: doc_id,
152
- documentTitle: doc_title,
153
- documentVariableFont: doc_variableFont,
154
- documentStyle: doc_style,
155
- documentWeight: doc_weight,
156
- fileInput: fileInput,
157
- client: client,
158
- });
159
-
160
- setMessage('Files built');
161
- setStatus('Files built successfully');
162
- setTimeout(() => { setMessage(''); setStatus('ready'); }, 2000);
163
- } catch (err) {
164
- console.error('Error building font files:', err);
165
- setMessage('Error building font files: ' + err.message);
166
- setStatus('Error building font files');
167
- setError(true);
168
- setTimeout(() => { setMessage(''); setStatus('ready'); setError(false); }, 3000);
169
- }
170
- }, [doc_id, doc_title, doc_variableFont, doc_style, doc_weight, doc_slug, fileInput, client]);
171
-
172
- /** Re-extracts metadata from the stored TTF and regenerates font data fields. */
173
- const handleGenerateFontData = useCallback(async () => {
174
- setMessage('Building font data...');
175
- setStatus('Building font data');
176
- setError(false);
177
-
178
- try {
179
- if (!fileInput?.ttf?.asset?._ref) {
180
- setMessage('Error: TTF file is required for font data generation');
181
- setStatus('Error: TTF file is required');
182
- setError(true);
183
- setTimeout(() => { setMessage(''); setStatus('ready'); setError(false); }, 2000);
184
- return;
185
- }
186
-
187
- const [ttfAsset] = await client.fetch(
188
- `*[_id == $id]{ url }`,
189
- { id: fileInput.ttf.asset._ref }
190
- );
191
-
192
- if (!ttfAsset?.url) throw new Error('Could not fetch TTF file URL');
193
-
194
- const arrayBuffer = await (await fetch(ttfAsset.url)).arrayBuffer();
195
- const font = await parseFont(arrayBuffer, `${doc_id}.ttf`);
196
-
197
- const { weightName, subfamilyName, fontTitle, style, variableFont } = extractFontMetadata(
198
- font,
199
- doc_typefaceName,
200
- weightKeywordList,
201
- italicKeywordList,
202
- false,
203
- null,
204
- );
205
- const weight = determineWeight(font, weightName);
206
-
207
- await client.patch(doc_id).set({ title: fontTitle, weightName, subfamily: subfamilyName, style, variableFont, weight }).commit();
208
-
209
- const fontData = await generateFontData({
210
- url: ttfAsset.url,
211
- fontKit: font,
212
- fontId: doc_id,
213
- client: client,
214
- commit: true,
215
- });
216
-
217
- if (variableFont && fontData.variableInstances) {
218
- const fontObj = {
219
- _id: doc_id,
220
- typefaceName: doc_typefaceName,
221
- variableFont,
222
- variableInstances: fontData.variableInstances,
223
- };
224
- const instanceMappings = await parseVariableFontInstances(fontObj, client);
225
- if (instanceMappings.length > 0) {
226
- await client.patch(doc_id).set({ variableInstanceReferences: instanceMappings }).commit();
227
- }
228
- }
229
-
230
- setMessage('Font data built successfully');
231
- setStatus('Font data built successfully');
232
- setTimeout(() => { setMessage(''); setStatus('ready'); }, 2000);
233
- } catch (err) {
234
- console.error('Error building font data:', err);
235
- setMessage('Error building font data: ' + err.message);
236
- setStatus('Error building font data');
237
- setError(true);
238
- setTimeout(() => { setMessage(''); setStatus('ready'); setError(false); }, 3000);
239
- }
240
- }, [fileInput, doc_id, doc_typefaceName, client, weightKeywordList, italicKeywordList]);
241
-
242
- /** Builds woff2_web (DS-WEB fingerprinted) and woff2_subset from the existing woff2 via fontWorker. */
243
- const handleGenerateSubsetAndWeb = useCallback(async () => {
244
- try {
245
- const woff2AssetRef = fileInput?.woff2?.asset?._ref;
246
- if (!woff2AssetRef) throw new Error('No woff2 file available');
247
-
248
- setMessage('Building WEB + SUBSET files...');
249
- setStatus('Building WEB + SUBSET');
250
- setError(false);
251
-
252
- const [woff2Asset] = await client.fetch(
253
- `*[_id == $id]{ originalFilename, url }`,
254
- { id: woff2AssetRef }
255
- );
256
-
257
- await generateSubset({
258
- woff2Url: woff2Asset.url,
259
- filename: doc_slug.current,
260
- documentId: doc_id,
261
- documentTitle: doc_title,
262
- documentVariableFont: doc_variableFont,
263
- documentStyle: doc_style,
264
- documentWeight: doc_weight,
265
- });
266
-
267
- setMessage('WEB + SUBSET building in background');
268
- setStatus('Building in background');
269
- setTimeout(() => { setMessage(''); setStatus('ready'); }, 4000);
270
- } catch (err) {
271
- console.error('Error building WEB + SUBSET:', err);
272
- setMessage('Error: ' + err.message);
273
- setStatus('Error building WEB + SUBSET');
274
- setError(true);
275
- setTimeout(() => { setMessage(''); setStatus('ready'); setError(false); }, 3000);
276
- }
277
- }, [fileInput, doc_id, doc_title, doc_variableFont, doc_style, doc_weight, doc_slug, client]);
278
-
279
- /** Uploads a file into fileInput.[fieldName] (woff2_web, woff2_subset). */
280
- const handleUploadTopLevelFile = useCallback(async (event, fieldName) => {
281
- try {
282
- const file = event.target.files[0];
283
- if (!file) return;
284
-
285
- const ext = file.name.split('.').pop();
286
- const filename = `${doc_slug.current}-${fieldName}.${ext}`;
287
-
288
- setMessage(`Uploading ${fieldName}...`);
289
- setStatus(`Uploading ${fieldName}`);
290
- setError(false);
291
-
292
- const asset = await client.assets.upload('file', file, { filename });
293
- const newFileInput = {
294
- ...fileInput,
295
- [fieldName]: { _type: 'file', asset: { _ref: asset._id, _type: 'reference' } },
296
- };
297
- onChange(set(newFileInput));
298
-
299
- setMessage(`${fieldName} uploaded`);
300
- setStatus(`${fieldName} uploaded successfully`);
301
- setTimeout(() => { setMessage(''); setStatus('ready'); }, 2000);
302
- } catch (err) {
303
- console.error(`Error uploading ${fieldName}:`, err);
304
- setMessage('Error: ' + err.message);
305
- setStatus(`Error uploading ${fieldName}`);
306
- setError(true);
307
- setTimeout(() => { setMessage(''); setStatus('ready'); setError(false); }, 3000);
308
- }
309
- }, [fileInput, onChange, doc_slug, client]);
310
-
311
- /** Uploads a single font file and triggers CSS/metadata generation as appropriate. */
312
- const handleUpload = useCallback(async (event, code) => {
313
- try {
314
- const file = event.target.files[0];
315
- if (!file) { setMessage('No file selected'); setStatus('No file selected'); setError(true); return; }
316
-
317
- const ext = file.name.split('.').pop();
318
- const filename = doc_slug.current + '.' + ext;
319
-
320
- setMessage('Uploading: ' + filename);
321
- setStatus('Uploading: ' + filename);
322
- setError(false);
323
-
324
- const asset = await client.assets.upload('file', file, { filename });
325
-
326
- let newFileInput = {
327
- ...fileInput,
328
- [code]: { _type: 'file', asset: { _ref: asset._id, _type: 'reference' } },
329
- };
330
-
331
- setMessage(filename + ' uploaded');
332
- setStatus(filename + ' uploaded successfully');
333
-
334
- if (code === 'woff2') {
335
- setMessage('Building CSS: ' + doc_title + '.css');
336
- setStatus('Building CSS file');
337
- newFileInput = await generateCssFile({
338
- woff2File: file,
339
- fileInput: newFileInput,
340
- fontName: doc_title,
341
- fileName: filename.replace('.woff2', ''),
342
- variableFont: doc_variableFont,
343
- weight: doc_weight,
344
- style: doc_style || 'Normal',
345
- client: client,
346
- });
347
- setMessage(doc_title + '.css built');
348
- setStatus('CSS file built successfully');
349
- }
350
-
351
- if (code === 'ttf') {
352
- const fontBuffer = await readFontFile(file);
353
- const font = await parseFont(fontBuffer, file.name);
354
- const { weightName, subfamilyName, fontTitle, style, variableFont } = extractFontMetadata(
355
- font, doc_typefaceName, weightKeywordList, italicKeywordList, false, null
356
- );
357
- const weight = determineWeight(font, weightName);
358
- const normalizedId = doc_id.startsWith('drafts.') ? doc_id.replace('drafts.', '') : doc_id;
359
- await client.patch(normalizedId).set({ title: fontTitle, weightName, subfamily: subfamilyName, style, variableFont, weight }).commit();
360
- }
361
-
362
- onChange(set(newFileInput));
363
- setTimeout(() => { setMessage(''); setStatus('ready'); }, 2000);
364
- } catch (err) {
365
- console.error('Error uploading file:', err);
366
- setMessage('Error uploading file: ' + err.message);
367
- setStatus('Error uploading file');
368
- setError(true);
369
- setTimeout(() => { setMessage(''); setStatus('ready'); setError(false); }, 3000);
370
- }
371
- }, [fileInput, onChange, doc_title, doc_typefaceName, doc_variableFont, doc_weight, doc_slug, doc_id, client, weightKeywordList, italicKeywordList]);
372
-
373
- /** Deletes a single fileInput font file asset. */
374
- const handleDelete = useCallback(async (code) => {
375
- try {
376
- setMessage(`Deleting ${code} file...`);
377
- setStatus(`Deleting ${code} file`);
378
- setError(false);
379
-
380
- const asset = fileInput[code]?.asset?._ref;
381
- if (!asset) { setMessage(`No ${code} file to delete`); setStatus(`No ${code} file to delete`); setError(true); return; }
382
-
383
- onChange(unset([code]));
384
- await client.delete(asset);
385
-
386
- setMessage(`${code} file deleted`);
387
- setStatus(`${code} file deleted successfully`);
388
- setTimeout(() => { setMessage(''); setStatus('ready'); }, 2000);
389
- } catch (err) {
390
- console.error('Error deleting asset:', err);
391
- setMessage('WARNING: ' + err.message);
392
- setStatus('Error deleting asset');
393
- setError(true);
394
- setTimeout(() => { setMessage(''); setStatus('ready'); setError(false); }, 3000);
395
- }
396
- }, [fileInput, onChange, client]);
397
-
398
- /** Deletes a fileInput sub-field asset (woff2_web, woff2_subset). */
399
- const handleDeleteTopLevel = useCallback(async (fieldName) => {
400
- try {
401
- setMessage(`Deleting ${fieldName}...`);
402
- setStatus(`Deleting ${fieldName}`);
403
- setError(false);
404
-
405
- const asset = fileInput?.[fieldName]?.asset?._ref;
406
- if (!asset) { setMessage(`No ${fieldName} file to delete`); setStatus(`No ${fieldName} file to delete`); setError(true); return; }
407
-
408
- onChange(unset([fieldName]));
409
- await client.delete(asset);
410
-
411
- setMessage(`${fieldName} deleted`);
412
- setStatus(`${fieldName} deleted successfully`);
413
- setTimeout(() => { setMessage(''); setStatus('ready'); }, 2000);
414
- } catch (err) {
415
- console.error(`Error deleting ${fieldName}:`, err);
416
- setMessage('Error: ' + err.message);
417
- setStatus(`Error deleting ${fieldName}`);
418
- setError(true);
419
- setTimeout(() => { setMessage(''); setStatus('ready'); setError(false); }, 3000);
420
- }
421
- }, [fileInput, onChange, client]);
422
-
423
- /** Deletes all font file assets and resets all metadata fields. */
424
- const handleDeleteAll = useCallback(async () => {
425
- try {
426
- setMessage('Deleting all files and metadata...');
427
- setStatus('Deleting all files and metadata');
428
- setError(false);
429
-
430
- onChange(unset([]));
431
-
432
- await client.patch(doc_id).set({
433
- characterSet: { chars: [] },
434
- glyphCount: 0,
435
- metaData: undefined,
436
- metrics: undefined,
437
- normalWeight: undefined,
438
- price: 0,
439
- sell: false,
440
- style: 'Normal',
441
- variableAxes: undefined,
442
- variableFont: false,
443
- weight: 400,
444
- variableInstances: undefined,
445
- }).commit();
446
-
447
- const allAssets = Object.keys(fileInput)
448
- .filter(k => k !== 'documentInfo')
449
- .map(k => fileInput[k]?.asset?._ref)
450
- .filter(Boolean);
451
-
452
- for (const assetRef of allAssets) {
453
- try { await client.delete(assetRef); } catch (e) { console.error('Error deleting asset:', e.message); }
454
- }
455
-
456
- setMessage('All files and metadata deleted');
457
- setStatus('All files and metadata deleted successfully');
458
- setTimeout(() => { setMessage(''); setStatus('ready'); }, 2000);
459
- } catch (err) {
460
- console.error('Error deleting all files:', err);
461
- setMessage('Delete error: ' + err.message);
462
- setStatus('Error deleting all files');
463
- setError(true);
464
- setTimeout(() => { setMessage(''); setStatus('ready'); setError(false); }, 3000);
465
- }
466
- }, [fileInput, doc_id, onChange, client]);
467
-
468
- /** Renders a bordered upload/build/delete row for a fileInput format. */
469
- const renderFontSection = (format, buildSource = null) => {
470
- const formatUpper = format.toUpperCase();
471
- const hasFile = !!fileInput?.[format]?.asset?._ref;
472
- const fileUrl = hasFile
473
- ? `https://cdn.sanity.io/files/${process.env.SANITY_STUDIO_PROJECT_ID}/${process.env.SANITY_STUDIO_DATASET}/${fileInput[format].asset._ref.replace('file-', '').replace('-', '.')}`
474
- : null;
475
-
476
- return (
477
- <Card border radius={1} paddingX={2} paddingY={3}>
478
- <Flex justify="space-between" align="center" gap={2}>
479
- <Flex gap={3} align="center" style={{ flex: 1, minWidth: 0 }}>
480
- <Text size={0} style={{ fontFamily: 'monospace', minWidth: '2.5rem', flexShrink: 0, opacity: hasFile ? 1 : 0.5 }}>
481
- {formatUpper}
482
- </Text>
483
- {hasFile ? (
484
- <Box style={{ flex: 1, minWidth: 0 }}>
485
- <a href={fileUrl} target="_blank" rel="noreferrer">{filenames?.[format] || 'File'}</a>
486
- </Box>
487
- ) : (
488
- <Text size={1} muted>—</Text>
489
- )}
490
- </Flex>
491
- {status === 'ready' && (
492
- <Flex gap={1} align="center" style={{ flexShrink: 0 }}>
493
- {buildSource && fileInput?.[buildSource] && (
494
- <Button mode="ghost" tone="primary" fontSize={1} padding={2} onClick={() => handleGenerateFontFile(format, fileInput[buildSource])} text="Build" />
495
- )}
496
- <Button as="label" mode="ghost" tone="primary" fontSize={1} padding={2} style={{ cursor: 'pointer' }}>
497
- <Text size={1}>Upload</Text>
498
- <input ref={ref} type="file" hidden onChange={(e) => handleUpload(e, format)} />
499
- </Button>
500
- {hasFile && (
501
- <Button mode="bleed" tone="critical" icon={TrashIcon} padding={2} onClick={() => handleDelete(format)} />
502
- )}
503
- </Flex>
504
- )}
505
- </Flex>
506
- </Card>
507
- );
508
- };
509
-
510
- /** Renders an upload/build/delete row for a top-level document asset field (woff2_web, woff2_subset). */
511
- const renderTopLevelAssetSection = (label, fieldName, assetRef, filename, onBuild) => {
512
- const hasFile = !!assetRef;
513
- const fileUrl = hasFile
514
- ? `https://cdn.sanity.io/files/${process.env.SANITY_STUDIO_PROJECT_ID}/${process.env.SANITY_STUDIO_DATASET}/${assetRef.replace('file-', '').replace('-', '.')}`
515
- : null;
516
-
517
- return (
518
- <Card border radius={1} paddingX={2} paddingY={3}>
519
- <Flex justify="space-between" align="center" gap={2}>
520
- <Flex gap={3} align="center" style={{ flex: 1, minWidth: 0 }}>
521
- <Text size={0} style={{ fontFamily: 'monospace', minWidth: '2.5rem', flexShrink: 0, opacity: hasFile ? 1 : 0.5 }}>
522
- {label}
523
- </Text>
524
- {hasFile ? (
525
- <Box style={{ flex: 1, minWidth: 0 }}>
526
- <a href={fileUrl} target="_blank" rel="noreferrer">{filename || 'File'}</a>
527
- </Box>
528
- ) : (
529
- <Text size={1} muted>—</Text>
530
- )}
531
- </Flex>
532
- {status === 'ready' && (
533
- <Flex gap={1} align="center" style={{ flexShrink: 0 }}>
534
- {onBuild && fileInput?.woff2 && (
535
- <Button mode="ghost" tone="primary" fontSize={1} padding={2} onClick={onBuild} text="Build" />
536
- )}
537
- <Button as="label" mode="ghost" tone="primary" fontSize={1} padding={2} style={{ cursor: 'pointer' }}>
538
- <Text size={1}>Upload</Text>
539
- <input type="file" hidden onChange={(e) => handleUploadTopLevelFile(e, fieldName)} />
540
- </Button>
541
- {hasFile && (
542
- <Button mode="bleed" tone="critical" icon={TrashIcon} padding={2} onClick={() => handleDeleteTopLevel(fieldName)} />
543
- )}
544
- </Flex>
545
- )}
546
- </Flex>
547
- </Card>
548
- );
549
- };
550
-
551
- /** Renders the CSS row — build-only, no direct upload. */
552
- const renderCssSection = () => {
553
- const hasFile = !!fileInput?.css?.asset?._ref;
554
- const fileUrl = hasFile
555
- ? `https://cdn.sanity.io/files/${process.env.SANITY_STUDIO_PROJECT_ID}/${process.env.SANITY_STUDIO_DATASET}/${fileInput.css.asset._ref.replace('file-', '').replace('-', '.')}`
556
- : null;
557
-
558
- return (
559
- <Card border radius={1} paddingX={2} paddingY={3}>
560
- <Flex justify="space-between" align="center" gap={2}>
561
- <Flex gap={3} align="center" style={{ flex: 1, minWidth: 0 }}>
562
- <Text size={0} style={{ fontFamily: 'monospace', minWidth: '2.5rem', flexShrink: 0, opacity: hasFile ? 1 : 0.5 }}>
563
- CSS
564
- </Text>
565
- {hasFile ? (
566
- <Box style={{ flex: 1, minWidth: 0 }}>
567
- <a href={fileUrl} target="_blank" rel="noreferrer">{filenames?.css || 'File'}</a>
568
- </Box>
569
- ) : (
570
- <Text size={1} muted>—</Text>
571
- )}
572
- </Flex>
573
- {status === 'ready' && (
574
- <Flex gap={1} align="center" style={{ flexShrink: 0 }}>
575
- {fileInput?.woff2 && (
576
- <Button mode="ghost" tone="primary" fontSize={1} padding={2} onClick={() => handleGenerateCssFile()} text="Build" />
577
- )}
578
- {hasFile && (
579
- <Button mode="bleed" tone="critical" icon={TrashIcon} padding={2} onClick={() => handleDelete('css')} />
580
- )}
581
- </Flex>
582
- )}
583
- </Flex>
584
- </Card>
585
- );
586
- };
587
-
588
- /** Renders the Data row — shows metadata version and build button. */
589
- const renderDataSection = () => (
590
- <Card border radius={1} paddingX={2} paddingY={3}>
591
- <Flex justify="space-between" align="center" gap={2}>
592
- <Flex gap={3} align="center" style={{ flex: 1, minWidth: 0 }}>
593
- <Text size={0} style={{ fontFamily: 'monospace', minWidth: '2.5rem', flexShrink: 0, opacity: doc_metaData?.version ? 1 : 0.5 }}>
594
- DATA
595
- </Text>
596
- {doc_metaData?.version ? (
597
- <Text size={1}>v{doc_metaData.version} <Text as="span" size={1} muted>({doc_metaData.genDate})</Text></Text>
598
- ) : (
599
- <Text size={1} muted>—</Text>
600
- )}
601
- </Flex>
602
- {status === 'ready' && fileInput?.ttf && (
603
- <Flex gap={1} align="center" style={{ flexShrink: 0 }}>
604
- <Button mode="ghost" tone="primary" fontSize={1} padding={2} onClick={() => handleGenerateFontData()} text="Build" />
605
- </Flex>
606
- )}
607
- </Flex>
608
- </Card>
609
- );
610
-
611
- return (
612
- <Stack space={2}>
613
- <StatusDisplay
614
- status={status}
615
- error={error}
616
- action={
617
- <Button
618
- mode="bleed"
619
- icon={ControlsIcon}
620
- padding={2}
621
- tone={showAdvanced ? 'primary' : 'default'}
622
- title="Show advanced file formats"
623
- onClick={() => setShowAdvanced(v => !v)}
624
- />
625
- }
626
- />
627
-
628
- {renderFontSection('ttf')}
629
-
630
- {status === 'ready' && fileInput?.ttf && (
631
- <Grid columns={[1, 2]} gap={2}>
632
- <Button
633
- mode="ghost"
634
- tone="primary"
635
- onClick={() => handleGenerateFontFile('all', fileInput.ttf)}
636
- text="Rebuild All from TTF"
637
- style={{ width: '100%' }}
638
- />
639
- <Button
640
- mode="ghost"
641
- tone="primary"
642
- onClick={() => {
643
- const missing = [
644
- !fileInput?.otf?.asset?._ref && 'otf',
645
- !fileInput?.woff?.asset?._ref && 'woff',
646
- !fileInput?.woff2?.asset?._ref && 'woff2',
647
- !fileInput?.eot?.asset?._ref && 'eot',
648
- !fileInput?.svg?.asset?._ref && 'svg',
649
- !doc_metaData?.version && 'data',
650
- ].filter(Boolean);
651
- handleGenerateFontFile(missing, fileInput.ttf);
652
- }}
653
- text="Build Missing"
654
- style={{ width: '100%' }}
655
- />
656
- </Grid>
657
- )}
658
-
659
- {renderFontSection('otf', 'woff')}
660
- {renderFontSection('woff', 'ttf')}
661
- {renderFontSection('woff2', 'ttf')}
662
- {showAdvanced && renderTopLevelAssetSection('WEB', 'woff2_web', fileInput?.woff2_web?.asset?._ref, filenames?.woff2_web, handleGenerateSubsetAndWeb)}
663
- {showAdvanced && renderTopLevelAssetSection('SUBSET', 'woff2_subset', fileInput?.woff2_subset?.asset?._ref, filenames?.woff2_subset, handleGenerateSubsetAndWeb)}
664
- {showAdvanced && renderFontSection('eot', 'ttf')}
665
- {showAdvanced && renderFontSection('svg', 'ttf')}
666
- {renderCssSection()}
667
- {showAdvanced && renderDataSection()}
668
-
669
- {status === 'ready' && (fileInput?.ttf || fileInput?.otf || fileInput?.woff || fileInput?.woff2) && (
670
- <Button mode="ghost" tone="critical" onClick={() => handleDeleteAll()} text="Delete All" style={{ width: '100%' }} />
671
- )}
672
- </Stack>
673
- );
674
- };
1
+ // Per-font file manager — TTF/OTF/WOFF/WOFF2/CSS rows always visible; EOT/SVG/WEB/SUBSET/DATA behind an advanced toggle
2
+
3
+ import React, { useState, useEffect, useCallback, useMemo } from 'react';
4
+ import { Button, Grid, Stack, Flex, Box, Text, Card } from '@sanity/ui';
5
+ import { TrashIcon, ControlsIcon } from '@sanity/icons';
6
+ import { useFormValue, set, unset } from 'sanity';
7
+ import { parseFont } from '../utils/parseFont';
8
+
9
+ import { useSanityClient } from '../hooks/useSanityClient';
10
+ import {
11
+ readFontFile,
12
+ extractFontMetadata,
13
+ determineWeight,
14
+ } from '../utils/processFontFiles';
15
+ import { generateStyleKeywords } from '../utils/generateKeywords';
16
+ import generateCssFile from '../utils/generateCssFile';
17
+ import generateFontData from '../utils/generateFontData';
18
+ import generateFontFile from '../utils/generateFontFile';
19
+ import generateSubset from '../utils/generateSubset';
20
+ import { parseVariableFontInstances } from '../utils/parseVariableFontInstances';
21
+ import StatusDisplay from './StatusDisplay';
22
+
23
+ /**
24
+ * Font file manager rendered inside a font document.
25
+ * Shows TTF/OTF/WOFF/WOFF2/WEB/SUBSET/EOT/SVG/CSS/DATA rows with Upload/Build/Delete controls.
26
+ * @param {Object} props
27
+ * @param {Object} props.elementProps
28
+ * @param {Function} props.onChange
29
+ */
30
+ export const SingleUploaderTool = (props) => {
31
+ const client = useSanityClient();
32
+
33
+ const { elementProps: { ref }, onChange } = props;
34
+
35
+ const [message, setMessage] = useState('');
36
+ const [status, setStatus] = useState('ready');
37
+ const [error, setError] = useState(false);
38
+ const [filenames, setFilenames] = useState({});
39
+ const [showAdvanced, setShowAdvanced] = useState(false);
40
+
41
+ const fileInput = useFormValue(['fileInput']);
42
+ const doc_id = useFormValue(['_id']);
43
+ const doc_title = useFormValue(['title']);
44
+ const doc_typefaceName = useFormValue(['typefaceName']);
45
+ const doc_variableFont = useFormValue(['variableFont']);
46
+ const doc_weight = useFormValue(['weight']);
47
+ const doc_style = useFormValue(['style']);
48
+ const doc_slug = useFormValue(['slug']);
49
+ const doc_metaData = useFormValue(['metaData']);
50
+
51
+ const { weightKeywordList, italicKeywordList } = useMemo(() => generateStyleKeywords(), []);
52
+
53
+ useEffect(() => { handleSetFilenames(); }, [fileInput]);
54
+
55
+ /** Fetches originalFilename for each asset ref in fileInput (including woff2_web/woff2_subset). */
56
+ const handleSetFilenames = useCallback(async () => {
57
+ const woff2WebRef = fileInput?.woff2_web?.asset?._ref ?? null;
58
+ const woff2SubsetRef = fileInput?.woff2_subset?.asset?._ref ?? null;
59
+
60
+ const assetIds = [
61
+ fileInput?.ttf?.asset?._ref,
62
+ fileInput?.otf?.asset?._ref,
63
+ fileInput?.woff?.asset?._ref,
64
+ fileInput?.woff2?.asset?._ref,
65
+ fileInput?.eot?.asset?._ref,
66
+ fileInput?.svg?.asset?._ref,
67
+ fileInput?.css?.asset?._ref,
68
+ woff2WebRef,
69
+ woff2SubsetRef,
70
+ ].filter(Boolean);
71
+
72
+ if (assetIds.length === 0) { setFilenames({}); return; }
73
+
74
+ const assetData = await client.fetch(
75
+ `*[_id in $assetIds]{ _id, originalFilename }`,
76
+ { assetIds }
77
+ );
78
+
79
+ const fontNames = assetData.reduce((acc, cur) => {
80
+ if (cur.originalFilename.endsWith('.ttf')) acc.ttf = cur.originalFilename;
81
+ else if (cur.originalFilename.endsWith('.otf')) acc.otf = cur.originalFilename;
82
+ else if (cur.originalFilename.endsWith('.woff2') && cur._id === woff2WebRef) acc.woff2_web = cur.originalFilename;
83
+ else if (cur.originalFilename.endsWith('.woff2') && cur._id === woff2SubsetRef) acc.woff2_subset = cur.originalFilename;
84
+ else if (cur.originalFilename.endsWith('.woff2')) acc.woff2 = cur.originalFilename;
85
+ else if (cur.originalFilename.endsWith('.woff')) acc.woff = cur.originalFilename;
86
+ else if (cur.originalFilename.endsWith('.eot')) acc.eot = cur.originalFilename;
87
+ else if (cur.originalFilename.endsWith('.svg')) acc.svg = cur.originalFilename;
88
+ else if (cur.originalFilename.endsWith('.css')) acc.css = cur.originalFilename;
89
+ return acc;
90
+ }, {});
91
+
92
+ setFilenames(fontNames);
93
+ }, [fileInput, client]);
94
+
95
+ /** Regenerates the @font-face CSS file from the stored woff2 asset. */
96
+ const handleGenerateCssFile = useCallback(async () => {
97
+ setMessage('Building CSS: ' + doc_title + '.css');
98
+ setStatus('Building CSS file');
99
+ setError(false);
100
+
101
+ try {
102
+ const woff2AssetRef = fileInput?.woff2?.asset?._ref;
103
+ if (!woff2AssetRef) throw new Error('No woff2 file available');
104
+
105
+ const [woff2Asset] = await client.fetch(
106
+ `*[_id == $id]{ originalFilename, url }`,
107
+ { id: woff2AssetRef }
108
+ );
109
+
110
+ const blob = await (await fetch(woff2Asset.url)).blob();
111
+
112
+ const newFileInput = await generateCssFile({
113
+ woff2File: blob,
114
+ fileInput: fileInput,
115
+ fontName: doc_title,
116
+ fileName: woff2Asset.originalFilename.replace('.woff2', ''),
117
+ variableFont: doc_variableFont,
118
+ weight: doc_weight,
119
+ client: client,
120
+ });
121
+
122
+ setMessage('CSS built');
123
+ setStatus('CSS built successfully');
124
+ setTimeout(() => { setMessage(''); setStatus('ready'); }, 2000);
125
+ onChange(set(newFileInput));
126
+ } catch (err) {
127
+ console.error('Error building CSS file:', err);
128
+ setMessage('Error building CSS file: ' + err.message);
129
+ setStatus('Error building CSS file');
130
+ setError(true);
131
+ setTimeout(() => { setMessage(''); setStatus('ready'); setError(false); }, 3000);
132
+ }
133
+ }, [fileInput, onChange, doc_title, doc_variableFont, doc_weight, client]);
134
+
135
+ /** Converts and uploads the source font file to one or more target formats. */
136
+ const handleGenerateFontFile = useCallback(async (code, sourceFile) => {
137
+ const isMissing = Array.isArray(code);
138
+ const label = code === 'all' ? 'all font files' : isMissing ? 'missing files' : code + ' file';
139
+ setMessage(`Building ${label}...`);
140
+ setStatus(`Building ${label}`);
141
+ setError(false);
142
+
143
+ try {
144
+ const url = `https://cdn.sanity.io/files/${process.env.SANITY_STUDIO_PROJECT_ID}/${process.env.SANITY_STUDIO_DATASET}/${sourceFile?.asset?._ref.replace('file-', '').replace('-', '.')}`;
145
+ const codes = code === 'all' ? ['otf', 'woff', 'woff2', 'eot', 'svg', 'data'] : isMissing ? code : [code];
146
+
147
+ await generateFontFile({
148
+ codes,
149
+ srcUrl: url,
150
+ filename: doc_slug.current,
151
+ documentId: doc_id,
152
+ documentTitle: doc_title,
153
+ documentVariableFont: doc_variableFont,
154
+ documentStyle: doc_style,
155
+ documentWeight: doc_weight,
156
+ fileInput: fileInput,
157
+ client: client,
158
+ });
159
+
160
+ setMessage('Files built');
161
+ setStatus('Files built successfully');
162
+ setTimeout(() => { setMessage(''); setStatus('ready'); }, 2000);
163
+ } catch (err) {
164
+ console.error('Error building font files:', err);
165
+ setMessage('Error building font files: ' + err.message);
166
+ setStatus('Error building font files');
167
+ setError(true);
168
+ setTimeout(() => { setMessage(''); setStatus('ready'); setError(false); }, 3000);
169
+ }
170
+ }, [doc_id, doc_title, doc_variableFont, doc_style, doc_weight, doc_slug, fileInput, client]);
171
+
172
+ /** Re-extracts metadata from the stored TTF and regenerates font data fields. */
173
+ const handleGenerateFontData = useCallback(async () => {
174
+ setMessage('Building font data...');
175
+ setStatus('Building font data');
176
+ setError(false);
177
+
178
+ try {
179
+ if (!fileInput?.ttf?.asset?._ref) {
180
+ setMessage('Error: TTF file is required for font data generation');
181
+ setStatus('Error: TTF file is required');
182
+ setError(true);
183
+ setTimeout(() => { setMessage(''); setStatus('ready'); setError(false); }, 2000);
184
+ return;
185
+ }
186
+
187
+ const [ttfAsset] = await client.fetch(
188
+ `*[_id == $id]{ url }`,
189
+ { id: fileInput.ttf.asset._ref }
190
+ );
191
+
192
+ if (!ttfAsset?.url) throw new Error('Could not fetch TTF file URL');
193
+
194
+ const arrayBuffer = await (await fetch(ttfAsset.url)).arrayBuffer();
195
+ const font = await parseFont(arrayBuffer, `${doc_id}.ttf`);
196
+
197
+ const { weightName, subfamilyName, fontTitle, style, variableFont } = extractFontMetadata(
198
+ font,
199
+ doc_typefaceName,
200
+ weightKeywordList,
201
+ italicKeywordList,
202
+ false,
203
+ null,
204
+ );
205
+ const weight = determineWeight(font, weightName);
206
+
207
+ await client.patch(doc_id).set({ title: fontTitle, weightName, subfamily: subfamilyName, style, variableFont, weight }).commit();
208
+
209
+ const fontData = await generateFontData({
210
+ url: ttfAsset.url,
211
+ fontKit: font,
212
+ fontId: doc_id,
213
+ client: client,
214
+ commit: true,
215
+ });
216
+
217
+ if (variableFont && fontData.variableInstances) {
218
+ const fontObj = {
219
+ _id: doc_id,
220
+ typefaceName: doc_typefaceName,
221
+ variableFont,
222
+ variableInstances: fontData.variableInstances,
223
+ };
224
+ const instanceMappings = await parseVariableFontInstances(fontObj, client);
225
+ if (instanceMappings.length > 0) {
226
+ await client.patch(doc_id).set({ variableInstanceReferences: instanceMappings }).commit();
227
+ }
228
+ }
229
+
230
+ setMessage('Font data built successfully');
231
+ setStatus('Font data built successfully');
232
+ setTimeout(() => { setMessage(''); setStatus('ready'); }, 2000);
233
+ } catch (err) {
234
+ console.error('Error building font data:', err);
235
+ setMessage('Error building font data: ' + err.message);
236
+ setStatus('Error building font data');
237
+ setError(true);
238
+ setTimeout(() => { setMessage(''); setStatus('ready'); setError(false); }, 3000);
239
+ }
240
+ }, [fileInput, doc_id, doc_typefaceName, client, weightKeywordList, italicKeywordList]);
241
+
242
+ /** Builds woff2_web (DS-WEB fingerprinted) and woff2_subset from the existing woff2 via fontWorker. */
243
+ const handleGenerateSubsetAndWeb = useCallback(async () => {
244
+ try {
245
+ const woff2AssetRef = fileInput?.woff2?.asset?._ref;
246
+ if (!woff2AssetRef) throw new Error('No woff2 file available');
247
+
248
+ setMessage('Building WEB + SUBSET files...');
249
+ setStatus('Building WEB + SUBSET');
250
+ setError(false);
251
+
252
+ const [woff2Asset] = await client.fetch(
253
+ `*[_id == $id]{ originalFilename, url }`,
254
+ { id: woff2AssetRef }
255
+ );
256
+
257
+ await generateSubset({
258
+ woff2Url: woff2Asset.url,
259
+ filename: doc_slug.current,
260
+ documentId: doc_id,
261
+ documentTitle: doc_title,
262
+ documentVariableFont: doc_variableFont,
263
+ documentStyle: doc_style,
264
+ documentWeight: doc_weight,
265
+ });
266
+
267
+ setMessage('WEB + SUBSET building in background');
268
+ setStatus('Building in background');
269
+ setTimeout(() => { setMessage(''); setStatus('ready'); }, 4000);
270
+ } catch (err) {
271
+ console.error('Error building WEB + SUBSET:', err);
272
+ setMessage('Error: ' + err.message);
273
+ setStatus('Error building WEB + SUBSET');
274
+ setError(true);
275
+ setTimeout(() => { setMessage(''); setStatus('ready'); setError(false); }, 3000);
276
+ }
277
+ }, [fileInput, doc_id, doc_title, doc_variableFont, doc_style, doc_weight, doc_slug, client]);
278
+
279
+ /** Uploads a file into fileInput.[fieldName] (woff2_web, woff2_subset). */
280
+ const handleUploadTopLevelFile = useCallback(async (event, fieldName) => {
281
+ try {
282
+ const file = event.target.files[0];
283
+ if (!file) return;
284
+
285
+ const ext = file.name.split('.').pop();
286
+ const filename = `${doc_slug.current}-${fieldName}.${ext}`;
287
+
288
+ setMessage(`Uploading ${fieldName}...`);
289
+ setStatus(`Uploading ${fieldName}`);
290
+ setError(false);
291
+
292
+ const asset = await client.assets.upload('file', file, { filename });
293
+ const newFileInput = {
294
+ ...fileInput,
295
+ [fieldName]: { _type: 'file', asset: { _ref: asset._id, _type: 'reference' } },
296
+ };
297
+ onChange(set(newFileInput));
298
+
299
+ setMessage(`${fieldName} uploaded`);
300
+ setStatus(`${fieldName} uploaded successfully`);
301
+ setTimeout(() => { setMessage(''); setStatus('ready'); }, 2000);
302
+ } catch (err) {
303
+ console.error(`Error uploading ${fieldName}:`, err);
304
+ setMessage('Error: ' + err.message);
305
+ setStatus(`Error uploading ${fieldName}`);
306
+ setError(true);
307
+ setTimeout(() => { setMessage(''); setStatus('ready'); setError(false); }, 3000);
308
+ }
309
+ }, [fileInput, onChange, doc_slug, client]);
310
+
311
+ /** Uploads a single font file and triggers CSS/metadata generation as appropriate. */
312
+ const handleUpload = useCallback(async (event, code) => {
313
+ try {
314
+ const file = event.target.files[0];
315
+ if (!file) { setMessage('No file selected'); setStatus('No file selected'); setError(true); return; }
316
+
317
+ const ext = file.name.split('.').pop();
318
+ const filename = doc_slug.current + '.' + ext;
319
+
320
+ setMessage('Uploading: ' + filename);
321
+ setStatus('Uploading: ' + filename);
322
+ setError(false);
323
+
324
+ const asset = await client.assets.upload('file', file, { filename });
325
+
326
+ let newFileInput = {
327
+ ...fileInput,
328
+ [code]: { _type: 'file', asset: { _ref: asset._id, _type: 'reference' } },
329
+ };
330
+
331
+ setMessage(filename + ' uploaded');
332
+ setStatus(filename + ' uploaded successfully');
333
+
334
+ if (code === 'woff2') {
335
+ setMessage('Building CSS: ' + doc_title + '.css');
336
+ setStatus('Building CSS file');
337
+ newFileInput = await generateCssFile({
338
+ woff2File: file,
339
+ fileInput: newFileInput,
340
+ fontName: doc_title,
341
+ fileName: filename.replace('.woff2', ''),
342
+ variableFont: doc_variableFont,
343
+ weight: doc_weight,
344
+ style: doc_style || 'Normal',
345
+ client: client,
346
+ });
347
+ setMessage(doc_title + '.css built');
348
+ setStatus('CSS file built successfully');
349
+ }
350
+
351
+ if (code === 'ttf') {
352
+ const fontBuffer = await readFontFile(file);
353
+ const font = await parseFont(fontBuffer, file.name);
354
+ const { weightName, subfamilyName, fontTitle, style, variableFont } = extractFontMetadata(
355
+ font, doc_typefaceName, weightKeywordList, italicKeywordList, false, null
356
+ );
357
+ const weight = determineWeight(font, weightName);
358
+ const normalizedId = doc_id.startsWith('drafts.') ? doc_id.replace('drafts.', '') : doc_id;
359
+ await client.patch(normalizedId).set({ title: fontTitle, weightName, subfamily: subfamilyName, style, variableFont, weight }).commit();
360
+ }
361
+
362
+ onChange(set(newFileInput));
363
+ setTimeout(() => { setMessage(''); setStatus('ready'); }, 2000);
364
+ } catch (err) {
365
+ console.error('Error uploading file:', err);
366
+ setMessage('Error uploading file: ' + err.message);
367
+ setStatus('Error uploading file');
368
+ setError(true);
369
+ setTimeout(() => { setMessage(''); setStatus('ready'); setError(false); }, 3000);
370
+ }
371
+ }, [fileInput, onChange, doc_title, doc_typefaceName, doc_variableFont, doc_weight, doc_slug, doc_id, client, weightKeywordList, italicKeywordList]);
372
+
373
+ /** Deletes a single fileInput font file asset. */
374
+ const handleDelete = useCallback(async (code) => {
375
+ try {
376
+ setMessage(`Deleting ${code} file...`);
377
+ setStatus(`Deleting ${code} file`);
378
+ setError(false);
379
+
380
+ const asset = fileInput[code]?.asset?._ref;
381
+ if (!asset) { setMessage(`No ${code} file to delete`); setStatus(`No ${code} file to delete`); setError(true); return; }
382
+
383
+ onChange(unset([code]));
384
+ await client.delete(asset);
385
+
386
+ setMessage(`${code} file deleted`);
387
+ setStatus(`${code} file deleted successfully`);
388
+ setTimeout(() => { setMessage(''); setStatus('ready'); }, 2000);
389
+ } catch (err) {
390
+ console.error('Error deleting asset:', err);
391
+ setMessage('WARNING: ' + err.message);
392
+ setStatus('Error deleting asset');
393
+ setError(true);
394
+ setTimeout(() => { setMessage(''); setStatus('ready'); setError(false); }, 3000);
395
+ }
396
+ }, [fileInput, onChange, client]);
397
+
398
+ /** Deletes a fileInput sub-field asset (woff2_web, woff2_subset). */
399
+ const handleDeleteTopLevel = useCallback(async (fieldName) => {
400
+ try {
401
+ setMessage(`Deleting ${fieldName}...`);
402
+ setStatus(`Deleting ${fieldName}`);
403
+ setError(false);
404
+
405
+ const asset = fileInput?.[fieldName]?.asset?._ref;
406
+ if (!asset) { setMessage(`No ${fieldName} file to delete`); setStatus(`No ${fieldName} file to delete`); setError(true); return; }
407
+
408
+ onChange(unset([fieldName]));
409
+ await client.delete(asset);
410
+
411
+ setMessage(`${fieldName} deleted`);
412
+ setStatus(`${fieldName} deleted successfully`);
413
+ setTimeout(() => { setMessage(''); setStatus('ready'); }, 2000);
414
+ } catch (err) {
415
+ console.error(`Error deleting ${fieldName}:`, err);
416
+ setMessage('Error: ' + err.message);
417
+ setStatus(`Error deleting ${fieldName}`);
418
+ setError(true);
419
+ setTimeout(() => { setMessage(''); setStatus('ready'); setError(false); }, 3000);
420
+ }
421
+ }, [fileInput, onChange, client]);
422
+
423
+ /** Deletes all font file assets and resets all metadata fields. */
424
+ const handleDeleteAll = useCallback(async () => {
425
+ try {
426
+ setMessage('Deleting all files and metadata...');
427
+ setStatus('Deleting all files and metadata');
428
+ setError(false);
429
+
430
+ onChange(unset([]));
431
+
432
+ await client.patch(doc_id).set({
433
+ characterSet: { chars: [] },
434
+ glyphCount: 0,
435
+ metaData: undefined,
436
+ metrics: undefined,
437
+ normalWeight: undefined,
438
+ price: 0,
439
+ sell: false,
440
+ style: 'Normal',
441
+ variableAxes: undefined,
442
+ variableFont: false,
443
+ weight: 400,
444
+ variableInstances: undefined,
445
+ }).commit();
446
+
447
+ const allAssets = Object.keys(fileInput)
448
+ .filter(k => k !== 'documentInfo')
449
+ .map(k => fileInput[k]?.asset?._ref)
450
+ .filter(Boolean);
451
+
452
+ for (const assetRef of allAssets) {
453
+ try { await client.delete(assetRef); } catch (e) { console.error('Error deleting asset:', e.message); }
454
+ }
455
+
456
+ setMessage('All files and metadata deleted');
457
+ setStatus('All files and metadata deleted successfully');
458
+ setTimeout(() => { setMessage(''); setStatus('ready'); }, 2000);
459
+ } catch (err) {
460
+ console.error('Error deleting all files:', err);
461
+ setMessage('Delete error: ' + err.message);
462
+ setStatus('Error deleting all files');
463
+ setError(true);
464
+ setTimeout(() => { setMessage(''); setStatus('ready'); setError(false); }, 3000);
465
+ }
466
+ }, [fileInput, doc_id, onChange, client]);
467
+
468
+ /** Renders a bordered upload/build/delete row for a fileInput format. */
469
+ const renderFontSection = (format, buildSource = null) => {
470
+ const formatUpper = format.toUpperCase();
471
+ const hasFile = !!fileInput?.[format]?.asset?._ref;
472
+ const fileUrl = hasFile
473
+ ? `https://cdn.sanity.io/files/${process.env.SANITY_STUDIO_PROJECT_ID}/${process.env.SANITY_STUDIO_DATASET}/${fileInput[format].asset._ref.replace('file-', '').replace('-', '.')}`
474
+ : null;
475
+
476
+ return (
477
+ <Card border radius={1} paddingX={2} paddingY={3}>
478
+ <Flex justify="space-between" align="center" gap={2}>
479
+ <Flex gap={3} align="center" style={{ flex: 1, minWidth: 0 }}>
480
+ <Text size={0} style={{ fontFamily: 'monospace', minWidth: '2.5rem', flexShrink: 0, opacity: hasFile ? 1 : 0.5 }}>
481
+ {formatUpper}
482
+ </Text>
483
+ {hasFile ? (
484
+ <Box style={{ flex: 1, minWidth: 0 }}>
485
+ <a href={fileUrl} target="_blank" rel="noreferrer">{filenames?.[format] || 'File'}</a>
486
+ </Box>
487
+ ) : (
488
+ <Text size={1} muted>—</Text>
489
+ )}
490
+ </Flex>
491
+ {status === 'ready' && (
492
+ <Flex gap={1} align="center" style={{ flexShrink: 0 }}>
493
+ {buildSource && fileInput?.[buildSource] && (
494
+ <Button mode="ghost" tone="primary" fontSize={1} padding={2} onClick={() => handleGenerateFontFile(format, fileInput[buildSource])} text="Build" />
495
+ )}
496
+ <Button as="label" mode="ghost" tone="primary" fontSize={1} padding={2} style={{ cursor: 'pointer' }}>
497
+ <Text size={1}>Upload</Text>
498
+ <input ref={ref} type="file" hidden onChange={(e) => handleUpload(e, format)} />
499
+ </Button>
500
+ {hasFile && (
501
+ <Button mode="bleed" tone="critical" icon={TrashIcon} padding={2} onClick={() => handleDelete(format)} />
502
+ )}
503
+ </Flex>
504
+ )}
505
+ </Flex>
506
+ </Card>
507
+ );
508
+ };
509
+
510
+ /** Renders an upload/build/delete row for a top-level document asset field (woff2_web, woff2_subset). */
511
+ const renderTopLevelAssetSection = (label, fieldName, assetRef, filename, onBuild) => {
512
+ const hasFile = !!assetRef;
513
+ const fileUrl = hasFile
514
+ ? `https://cdn.sanity.io/files/${process.env.SANITY_STUDIO_PROJECT_ID}/${process.env.SANITY_STUDIO_DATASET}/${assetRef.replace('file-', '').replace('-', '.')}`
515
+ : null;
516
+
517
+ return (
518
+ <Card border radius={1} paddingX={2} paddingY={3}>
519
+ <Flex justify="space-between" align="center" gap={2}>
520
+ <Flex gap={3} align="center" style={{ flex: 1, minWidth: 0 }}>
521
+ <Text size={0} style={{ fontFamily: 'monospace', minWidth: '2.5rem', flexShrink: 0, opacity: hasFile ? 1 : 0.5 }}>
522
+ {label}
523
+ </Text>
524
+ {hasFile ? (
525
+ <Box style={{ flex: 1, minWidth: 0 }}>
526
+ <a href={fileUrl} target="_blank" rel="noreferrer">{filename || 'File'}</a>
527
+ </Box>
528
+ ) : (
529
+ <Text size={1} muted>—</Text>
530
+ )}
531
+ </Flex>
532
+ {status === 'ready' && (
533
+ <Flex gap={1} align="center" style={{ flexShrink: 0 }}>
534
+ {onBuild && fileInput?.woff2 && (
535
+ <Button mode="ghost" tone="primary" fontSize={1} padding={2} onClick={onBuild} text="Build" />
536
+ )}
537
+ <Button as="label" mode="ghost" tone="primary" fontSize={1} padding={2} style={{ cursor: 'pointer' }}>
538
+ <Text size={1}>Upload</Text>
539
+ <input type="file" hidden onChange={(e) => handleUploadTopLevelFile(e, fieldName)} />
540
+ </Button>
541
+ {hasFile && (
542
+ <Button mode="bleed" tone="critical" icon={TrashIcon} padding={2} onClick={() => handleDeleteTopLevel(fieldName)} />
543
+ )}
544
+ </Flex>
545
+ )}
546
+ </Flex>
547
+ </Card>
548
+ );
549
+ };
550
+
551
+ /** Renders the CSS row — build-only, no direct upload. */
552
+ const renderCssSection = () => {
553
+ const hasFile = !!fileInput?.css?.asset?._ref;
554
+ const fileUrl = hasFile
555
+ ? `https://cdn.sanity.io/files/${process.env.SANITY_STUDIO_PROJECT_ID}/${process.env.SANITY_STUDIO_DATASET}/${fileInput.css.asset._ref.replace('file-', '').replace('-', '.')}`
556
+ : null;
557
+
558
+ return (
559
+ <Card border radius={1} paddingX={2} paddingY={3}>
560
+ <Flex justify="space-between" align="center" gap={2}>
561
+ <Flex gap={3} align="center" style={{ flex: 1, minWidth: 0 }}>
562
+ <Text size={0} style={{ fontFamily: 'monospace', minWidth: '2.5rem', flexShrink: 0, opacity: hasFile ? 1 : 0.5 }}>
563
+ CSS
564
+ </Text>
565
+ {hasFile ? (
566
+ <Box style={{ flex: 1, minWidth: 0 }}>
567
+ <a href={fileUrl} target="_blank" rel="noreferrer">{filenames?.css || 'File'}</a>
568
+ </Box>
569
+ ) : (
570
+ <Text size={1} muted>—</Text>
571
+ )}
572
+ </Flex>
573
+ {status === 'ready' && (
574
+ <Flex gap={1} align="center" style={{ flexShrink: 0 }}>
575
+ {fileInput?.woff2 && (
576
+ <Button mode="ghost" tone="primary" fontSize={1} padding={2} onClick={() => handleGenerateCssFile()} text="Build" />
577
+ )}
578
+ {hasFile && (
579
+ <Button mode="bleed" tone="critical" icon={TrashIcon} padding={2} onClick={() => handleDelete('css')} />
580
+ )}
581
+ </Flex>
582
+ )}
583
+ </Flex>
584
+ </Card>
585
+ );
586
+ };
587
+
588
+ /** Renders the Data row — shows metadata version and build button. */
589
+ const renderDataSection = () => (
590
+ <Card border radius={1} paddingX={2} paddingY={3}>
591
+ <Flex justify="space-between" align="center" gap={2}>
592
+ <Flex gap={3} align="center" style={{ flex: 1, minWidth: 0 }}>
593
+ <Text size={0} style={{ fontFamily: 'monospace', minWidth: '2.5rem', flexShrink: 0, opacity: doc_metaData?.version ? 1 : 0.5 }}>
594
+ DATA
595
+ </Text>
596
+ {doc_metaData?.version ? (
597
+ <Text size={1}>v{doc_metaData.version} <Text as="span" size={1} muted>({doc_metaData.genDate})</Text></Text>
598
+ ) : (
599
+ <Text size={1} muted>—</Text>
600
+ )}
601
+ </Flex>
602
+ {status === 'ready' && fileInput?.ttf && (
603
+ <Flex gap={1} align="center" style={{ flexShrink: 0 }}>
604
+ <Button mode="ghost" tone="primary" fontSize={1} padding={2} onClick={() => handleGenerateFontData()} text="Build" />
605
+ </Flex>
606
+ )}
607
+ </Flex>
608
+ </Card>
609
+ );
610
+
611
+ return (
612
+ <Stack space={2}>
613
+ <StatusDisplay
614
+ status={status}
615
+ error={error}
616
+ action={
617
+ <Button
618
+ mode="bleed"
619
+ icon={ControlsIcon}
620
+ padding={2}
621
+ tone={showAdvanced ? 'primary' : 'default'}
622
+ title="Show advanced file formats"
623
+ onClick={() => setShowAdvanced(v => !v)}
624
+ />
625
+ }
626
+ />
627
+
628
+ {renderFontSection('ttf')}
629
+
630
+ {status === 'ready' && fileInput?.ttf && (
631
+ <Grid columns={[1, 2]} gap={2}>
632
+ <Button
633
+ mode="ghost"
634
+ tone="primary"
635
+ onClick={() => handleGenerateFontFile('all', fileInput.ttf)}
636
+ text="Rebuild All from TTF"
637
+ style={{ width: '100%' }}
638
+ />
639
+ <Button
640
+ mode="ghost"
641
+ tone="primary"
642
+ onClick={() => {
643
+ const missing = [
644
+ !fileInput?.otf?.asset?._ref && 'otf',
645
+ !fileInput?.woff?.asset?._ref && 'woff',
646
+ !fileInput?.woff2?.asset?._ref && 'woff2',
647
+ !fileInput?.eot?.asset?._ref && 'eot',
648
+ !fileInput?.svg?.asset?._ref && 'svg',
649
+ !doc_metaData?.version && 'data',
650
+ ].filter(Boolean);
651
+ handleGenerateFontFile(missing, fileInput.ttf);
652
+ }}
653
+ text="Build Missing"
654
+ style={{ width: '100%' }}
655
+ />
656
+ </Grid>
657
+ )}
658
+
659
+ {renderFontSection('otf', 'woff')}
660
+ {renderFontSection('woff', 'ttf')}
661
+ {renderFontSection('woff2', 'ttf')}
662
+ {showAdvanced && renderTopLevelAssetSection('WEB', 'woff2_web', fileInput?.woff2_web?.asset?._ref, filenames?.woff2_web, handleGenerateSubsetAndWeb)}
663
+ {showAdvanced && renderTopLevelAssetSection('SUBSET', 'woff2_subset', fileInput?.woff2_subset?.asset?._ref, filenames?.woff2_subset, handleGenerateSubsetAndWeb)}
664
+ {showAdvanced && renderFontSection('eot', 'ttf')}
665
+ {showAdvanced && renderFontSection('svg', 'ttf')}
666
+ {renderCssSection()}
667
+ {showAdvanced && renderDataSection()}
668
+
669
+ {status === 'ready' && (fileInput?.ttf || fileInput?.otf || fileInput?.woff || fileInput?.woff2) && (
670
+ <Button mode="ghost" tone="critical" onClick={() => handleDeleteAll()} text="Delete All" style={{ width: '100%' }} />
671
+ )}
672
+ </Stack>
673
+ );
674
+ };