@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.
Files changed (36) hide show
  1. package/dist/UploadModal-ADNRGQUI.mjs +6 -0
  2. package/dist/UploadModal-WPK2CXLR.js +6 -0
  3. package/dist/chunk-JCDZ7SWZ.js +7711 -0
  4. package/dist/chunk-TMDE4A54.mjs +7711 -0
  5. package/dist/index.js +666 -1647
  6. package/dist/index.mjs +319 -1209
  7. package/package.json +5 -5
  8. package/src/components/BatchUploadFonts.jsx +57 -44
  9. package/src/components/BulkActions.jsx +99 -0
  10. package/src/components/ExistingDocumentResolver.jsx +152 -0
  11. package/src/components/FontReviewCard.jsx +455 -0
  12. package/src/components/SingleUploaderTool.jsx +3 -4
  13. package/src/components/UploadModal.jsx +304 -0
  14. package/src/components/UploadScriptsComponent.jsx +23 -21
  15. package/src/components/UploadStep1Settings.jsx +272 -0
  16. package/src/components/UploadStep2Review.jsx +474 -0
  17. package/src/components/UploadStep3Execute.jsx +234 -0
  18. package/src/components/UploadStep3bInstances.jsx +396 -0
  19. package/src/components/UploadSummary.jsx +196 -0
  20. package/src/index.js +46 -0
  21. package/src/utils/buildUploadPlan.js +326 -0
  22. package/src/utils/executeUploadPlan.js +430 -0
  23. package/src/utils/executionReducer.js +56 -0
  24. package/src/utils/fontHelpers.js +267 -0
  25. package/src/utils/generateCssFile.js +79 -77
  26. package/src/utils/generateFontData.js +47 -94
  27. package/src/utils/getEmptyFontKit.js +19 -17
  28. package/src/utils/parseFont.js +55 -0
  29. package/src/utils/parseVariableFontInstances.js +237 -147
  30. package/src/utils/planReducer.js +517 -0
  31. package/src/utils/planTypes.js +183 -0
  32. package/src/utils/processFontFiles.js +121 -78
  33. package/src/utils/regenerateFontData.js +2 -2
  34. package/src/utils/resolveExistingFont.js +87 -0
  35. package/src/utils/updateTypefaceDocument.js +15 -2
  36. package/src/utils/uploadFontFiles.js +405 -405
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liiift-studio/sanity-font-manager",
3
- "version": "2.4.0",
3
+ "version": "2.5.1",
4
4
  "description": "Sanity Studio plugin — full font management suite with batch upload, format conversion, metadata extraction, CSS generation, collection/pair generation, and script variant support. Supports Sanity v3, v4, and v5.",
5
5
  "license": "MIT",
6
6
  "author": "Liiift Studio",
@@ -51,17 +51,17 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "base-64": "^1.0.0",
54
- "buffer": "^6.0.3",
55
- "fontkit": "^2.0.2",
54
+ "lib-font": "^3.0.1",
56
55
  "nanoid": "^5.0.0",
56
+ "pako": "^2.1.0",
57
57
  "slugify": "^1.6.6"
58
58
  },
59
59
  "peerDependencies": {
60
+ "@liiift-studio/sanity-advanced-reference-array": ">=1",
60
61
  "@sanity/icons": ">=3",
61
62
  "@sanity/ui": ">=3",
62
63
  "react": ">=18",
63
- "sanity": ">=3",
64
- "@liiift-studio/sanity-advanced-reference-array": ">=1"
64
+ "sanity": ">=3"
65
65
  },
66
66
  "peerDependenciesMeta": {
67
67
  "@liiift-studio/sanity-advanced-reference-array": {
@@ -1,10 +1,12 @@
1
1
  // Batch font uploader — drag-and-drop file list, confirm-to-upload, elapsed timer, Wake Lock, and beforeunload guard for long uploads
2
2
 
3
- import React, { useCallback, useState, useMemo, useRef, useEffect } from 'react';
3
+ import React, { useCallback, useState, useMemo, useRef, useEffect, lazy, Suspense } from 'react';
4
4
  import { Card, Box, Flex, Grid, Text, Label, Switch, Button, Spinner, Tooltip, Stack } from '@sanity/ui';
5
5
  import { ControlsIcon, InfoOutlineIcon, TrashIcon, UploadIcon, WarningOutlineIcon } from '@sanity/icons';
6
6
  import { useFormValue } from 'sanity';
7
7
 
8
+ const UploadModal = lazy(() => import('./UploadModal'));
9
+
8
10
  import { useSanityClient } from '../hooks/useSanityClient';
9
11
  import { processFontFiles } from '../utils/processFontFiles';
10
12
  import { uploadFontFiles } from '../utils/uploadFontFiles';
@@ -39,6 +41,7 @@ export const BatchUploadFonts = () => {
39
41
  const [pendingFiles, setPendingFiles] = useState([]);
40
42
  const [isDragging, setIsDragging] = useState(false);
41
43
  const [elapsedSeconds, setElapsedSeconds] = useState(0);
44
+ const [showUploadModal, setShowUploadModal] = useState(false);
42
45
 
43
46
  const fileInputRef = useRef(null);
44
47
  const elapsedTimerRef = useRef(null);
@@ -522,9 +525,28 @@ export const BatchUploadFonts = () => {
522
525
  );
523
526
  };
524
527
 
528
+ const hasRequiredFields = title && title !== '' && slug && slug !== '';
529
+
525
530
  return (
526
531
  <>
527
- {title && title !== '' && slug && slug !== '' &&
532
+ {!hasRequiredFields && (
533
+ <Card border padding={4} radius={2} tone="caution">
534
+ <Flex align="center" gap={3}>
535
+ <Text size={2}>
536
+ <WarningOutlineIcon />
537
+ </Text>
538
+ <Stack space={2}>
539
+ <Text size={1} weight="semibold">
540
+ {!title || title === '' ? 'Title required to use font uploader' : 'Slug required to use font uploader'}
541
+ </Text>
542
+ <Text size={1} muted>
543
+ Add a {!title || title === '' ? 'title' : 'slug'} to this typeface document, then return to the Styles tab to upload fonts.
544
+ </Text>
545
+ </Stack>
546
+ </Flex>
547
+ </Card>
548
+ )}
549
+ {hasRequiredFields &&
528
550
  <>
529
551
  <StatusDisplay
530
552
  status={status}
@@ -542,9 +564,37 @@ export const BatchUploadFonts = () => {
542
564
  }
543
565
  />
544
566
 
545
- <Card border padding={2} shadow={1} radius={2}>
546
- {showUtilities ? (
547
- <Stack space={4} marginTop={2}>
567
+ <Button
568
+ mode="default"
569
+ tone="primary"
570
+ icon={UploadIcon}
571
+ text="Upload Fonts"
572
+ fontSize={2}
573
+ padding={4}
574
+ onClick={() => setShowUploadModal(true)}
575
+ style={{ width: '100%' }}
576
+ />
577
+
578
+ {/* New upload modal */}
579
+ {showUploadModal && (
580
+ <Suspense fallback={<Spinner />}>
581
+ <UploadModal
582
+ open={showUploadModal}
583
+ onClose={() => setShowUploadModal(false)}
584
+ client={client}
585
+ docId={doc_id}
586
+ typefaceTitle={title}
587
+ stylesObject={stylesObject}
588
+ preferredStyleRef={preferredStyleRef}
589
+ slug={slug}
590
+ />
591
+ </Suspense>
592
+ )}
593
+
594
+ {/* Utilities panel — toggled via the Utilities button */}
595
+ {showUtilities && (
596
+ <Card border padding={3} shadow={1} radius={2} marginTop={3}>
597
+ <Stack space={4}>
548
598
 
549
599
  {/* Regenerate Subfamilies */}
550
600
  <Stack space={2}>
@@ -594,45 +644,8 @@ export const BatchUploadFonts = () => {
594
644
  </Stack>
595
645
 
596
646
  </Stack>
597
- ) : (
598
- ready
599
- ? <>
600
- <Grid columns={[2]} gap={4} marginTop={1} marginBottom={1}>
601
- {/* Left: price */}
602
- <Box>
603
- <PriceInput inputPrice={inputPrice} handleInputChange={handleInputChange} />
604
- </Box>
605
- {/* Right: toggles */}
606
- <Stack space={3}>
607
- <Flex align="center" gap={2}>
608
- <Switch
609
- checked={preserveShortenedNames}
610
- onChange={(e) => setPreserveShortenedNames(e.target.checked)}
611
- />
612
- {renderTooltipLabel(
613
- 'Preserve shortened names',
614
- 'Abbreviations in font names are kept as-is (e.g. "XNarrow" stays "XNarrow", "Bd" stays "Bd").'
615
- )}
616
- </Flex>
617
- <Flex align="center" gap={2}>
618
- <Switch
619
- checked={preserveFileNames}
620
- onChange={(e) => setPreserveFileNames(e.target.checked)}
621
- />
622
- {renderTooltipLabel(
623
- 'Preserve file names',
624
- 'Original filename capitalisation is used for asset naming instead of the normalised font title.'
625
- )}
626
- </Flex>
627
- </Stack>
628
- </Grid>
629
- <Box marginTop={3}>
630
- {pendingFiles.length === 0 ? renderDropZone() : renderFileList()}
631
- </Box>
632
- </>
633
- : renderProcessing()
634
- )}
635
- </Card>
647
+ </Card>
648
+ )}
636
649
  </>
637
650
  }
638
651
  </>
@@ -0,0 +1,99 @@
1
+ // Bulk actions bar — expand/collapse all, search, filter with counts
2
+
3
+ import React, { useMemo } from 'react';
4
+ import { Flex, Box, Button, TextInput, Select, Text, Label } from '@sanity/ui';
5
+ import { SearchIcon } from '@sanity/icons';
6
+ import { FONT_STATUS, RECOMMENDATION } from '../utils/planTypes';
7
+
8
+ /** Determines whether a font entry will create or update a document */
9
+ function isUpdateEntry(entry) {
10
+ const d = entry.decisions?.existingDocument;
11
+ const choice = d?.userChoice;
12
+ const rec = d?.recommendation;
13
+ return choice === 'update' || (!choice && (rec === RECOMMENDATION.USE_EXACT || rec === RECOMMENDATION.USE_CANDIDATE));
14
+ }
15
+
16
+ /**
17
+ * Sticky bulk actions bar for the review step.
18
+ */
19
+ export default function BulkActions({
20
+ fonts,
21
+ dispatch,
22
+ searchQuery,
23
+ onSearchChange,
24
+ filterBy,
25
+ onFilterChange,
26
+ allExpanded,
27
+ onToggleExpandAll,
28
+ visibleTempIds,
29
+ }) {
30
+ const fontEntries = useMemo(() => Object.values(fonts), [fonts]);
31
+ const fontCount = fontEntries.length;
32
+ const visibleCount = visibleTempIds.length;
33
+
34
+ // Compute counts for each filter category
35
+ const filterCounts = useMemo(() => {
36
+ const createCount = fontEntries.filter(f => f.status !== FONT_STATUS.ERROR && !isUpdateEntry(f)).length;
37
+ const updateCount = fontEntries.filter(f => f.status !== FONT_STATUS.ERROR && isUpdateEntry(f)).length;
38
+ const errorCount = fontEntries.filter(f => f.status === FONT_STATUS.ERROR).length;
39
+ const conflictCount = fontEntries.filter(f => f._idConflict).length;
40
+ const italicCount = fontEntries.filter(f => f.style === 'Italic' && f.status !== FONT_STATUS.ERROR).length;
41
+ const regularCount = fontEntries.filter(f => f.style === 'Regular' && f.status !== FONT_STATUS.ERROR).length;
42
+
43
+ // Subfamily counts
44
+ const subfamilyCounts = {};
45
+ fontEntries.forEach(f => {
46
+ if (f.status === FONT_STATUS.ERROR) return;
47
+ const sf = f.subfamily || 'Regular';
48
+ subfamilyCounts[sf] = (subfamilyCounts[sf] || 0) + 1;
49
+ });
50
+
51
+ return { createCount, updateCount, errorCount, conflictCount, italicCount, regularCount, subfamilyCounts };
52
+ }, [fontEntries]);
53
+
54
+ const subfamilies = useMemo(() =>
55
+ Object.keys(filterCounts.subfamilyCounts).sort((a, b) => {
56
+ if (a === 'Regular') return -1;
57
+ if (b === 'Regular') return 1;
58
+ return a.localeCompare(b);
59
+ }),
60
+ [filterCounts]
61
+ );
62
+
63
+ return (
64
+ <Flex gap={2} align="center" wrap="wrap" style={{ position: 'sticky', top: 0, zIndex: 10, background: 'var(--card-bg-color)', paddingBottom: 8, paddingTop: 4 }}>
65
+ {/* Search */}
66
+ <Box style={{ flex: 1, minWidth: 150 }}>
67
+ <TextInput
68
+ icon={SearchIcon}
69
+ placeholder="Search fonts..."
70
+ value={searchQuery}
71
+ onChange={(e) => onSearchChange(e.target.value)}
72
+ fontSize={1}
73
+ />
74
+ </Box>
75
+
76
+ {/* Filter by */}
77
+ <Flex align="center" gap={1}>
78
+ <Label size={0} style={{ whiteSpace: 'nowrap' }}>Filter</Label>
79
+ <Select value={filterBy} onChange={(e) => onFilterChange(e.target.value)} fontSize={1} style={{ minWidth: 140 }}>
80
+ <option value="all">All ({fontCount})</option>
81
+ {filterCounts.createCount > 0 && <option value="create">Create ({filterCounts.createCount})</option>}
82
+ {filterCounts.updateCount > 0 && <option value="update">Update ({filterCounts.updateCount})</option>}
83
+ {filterCounts.regularCount > 0 && <option value="style:regular">Regular ({filterCounts.regularCount})</option>}
84
+ {filterCounts.italicCount > 0 && <option value="style:italic">Italic ({filterCounts.italicCount})</option>}
85
+ {filterCounts.errorCount > 0 && <option value="error">Errors ({filterCounts.errorCount})</option>}
86
+ {filterCounts.conflictCount > 0 && <option value="conflict">Conflicts ({filterCounts.conflictCount})</option>}
87
+ {subfamilies.length > 1 && subfamilies.map(sf => (
88
+ <option key={sf} value={`sf:${sf}`}>{sf} ({filterCounts.subfamilyCounts[sf]})</option>
89
+ ))}
90
+ </Select>
91
+ </Flex>
92
+
93
+ {/* Visible count */}
94
+ {visibleCount !== fontCount && (
95
+ <Text size={0} muted>{visibleCount} of {fontCount}</Text>
96
+ )}
97
+ </Flex>
98
+ );
99
+ }
@@ -0,0 +1,152 @@
1
+ // Existing document resolution UI — toggle between update existing and create new
2
+
3
+ import React from 'react';
4
+ import { Card, Stack, Flex, Text, Badge, Button, Label, Switch, Tooltip, Box } from '@sanity/ui';
5
+ import { InfoOutlineIcon } from '@sanity/icons';
6
+ import { RECOMMENDATION } from '../utils/planTypes';
7
+
8
+ /**
9
+ * Displays the existing document resolution decision as a toggle.
10
+ * When "update" is on, shows the matched document. When off, indicates a new document will be created.
11
+ */
12
+ export default function ExistingDocumentResolver({ decision, tempId, dispatch }) {
13
+ if (!decision) return null;
14
+
15
+ const { recommendation, exact, candidates, userChoice, selectedCandidate, lookupFailed } = decision;
16
+
17
+ const effectiveAction = userChoice ||
18
+ (recommendation === RECOMMENDATION.USE_EXACT || recommendation === RECOMMENDATION.USE_CANDIDATE ? 'update' : 'create');
19
+ const isUpdating = effectiveAction === 'update';
20
+ const hasMatch = exact || candidates?.length > 0;
21
+
22
+ const handleToggle = () => {
23
+ dispatch({ type: 'SET_FONT_ACTION', tempId, decision: isUpdating ? 'create' : 'update' });
24
+ };
25
+
26
+ const handleSelectCandidate = (candidate) => {
27
+ dispatch({ type: 'SET_FONT_CANDIDATE', tempId, candidate });
28
+ };
29
+
30
+ // Lookup failed
31
+ if (lookupFailed) {
32
+ return (
33
+ <Card tone="caution" border padding={2} radius={1}>
34
+ <Text size={0}>Could not check for existing documents — will create new.</Text>
35
+ </Card>
36
+ );
37
+ }
38
+
39
+ // No match at all — just creating
40
+ if (recommendation === RECOMMENDATION.CREATE && !userChoice) {
41
+ return (
42
+ <Stack space={2}>
43
+ <Label size={0}>Existing Document</Label>
44
+ <Card tone="default" border padding={2} radius={1}>
45
+ <Text size={1}>No existing document found — will create new.</Text>
46
+ </Card>
47
+ </Stack>
48
+ );
49
+ }
50
+
51
+ // Has a match (exact, candidate, or ambiguous) — show toggle
52
+ if (hasMatch) {
53
+ const matchDoc = exact || selectedCandidate || candidates?.[0];
54
+ const matchType = exact ? 'Exact Match' : candidates?.length > 1 ? 'Multiple Matches' : 'Likely Match';
55
+ const matchTone = exact ? 'positive' : 'caution';
56
+
57
+ return (
58
+ <Stack space={2}>
59
+ <Label size={0}>Existing Document</Label>
60
+
61
+ {/* Toggle */}
62
+ <Flex align="center" gap={2}>
63
+ <Switch
64
+ checked={isUpdating}
65
+ onChange={handleToggle}
66
+ style={{ cursor: 'pointer' }}
67
+ />
68
+ <Stack space={1}>
69
+ <Text size={1} weight="semibold">
70
+ {isUpdating ? 'Update existing document' : 'Create new document'}
71
+ </Text>
72
+ <Text size={0} muted>
73
+ {isUpdating
74
+ ? 'Files will be uploaded to the matched document below.'
75
+ : 'A new document will be created. You may need to update the Document ID above to avoid conflicts.'
76
+ }
77
+ </Text>
78
+ </Stack>
79
+ </Flex>
80
+
81
+ {/* Match card — greyed out when not updating */}
82
+ <Card
83
+ tone={isUpdating ? matchTone : 'default'}
84
+ border
85
+ padding={2}
86
+ radius={1}
87
+ style={{ opacity: isUpdating ? 1 : 0.5, transition: 'opacity 0.15s ease' }}
88
+ >
89
+ <Stack space={2}>
90
+ <Flex align="center" gap={2}>
91
+ <Badge tone={isUpdating ? matchTone : 'default'} fontSize={0}>{matchType}</Badge>
92
+ <Text size={1} style={{ fontFamily: 'monospace' }}>{matchDoc?._id}</Text>
93
+ </Flex>
94
+ <Text size={0} muted>
95
+ {matchDoc?.title} · {matchDoc?.weightName} · {matchDoc?.style}
96
+ {matchDoc?.subfamily ? ` · ${matchDoc.subfamily}` : ''}
97
+ </Text>
98
+ </Stack>
99
+ </Card>
100
+
101
+ {/* Multiple candidates — show selector when updating */}
102
+ {isUpdating && candidates?.length > 1 && (
103
+ <Stack space={1}>
104
+ <Text size={0} muted>Select which document to update:</Text>
105
+ {candidates.map((candidate) => (
106
+ <Card
107
+ key={candidate._id}
108
+ border
109
+ radius={1}
110
+ padding={2}
111
+ tone={selectedCandidate?._id === candidate._id ? 'positive' : 'default'}
112
+ style={{ cursor: 'pointer' }}
113
+ onClick={() => handleSelectCandidate(candidate)}
114
+ >
115
+ <Flex align="center" gap={2}>
116
+ <input
117
+ type="radio"
118
+ name={`candidate-${tempId}`}
119
+ checked={selectedCandidate?._id === candidate._id}
120
+ onChange={() => handleSelectCandidate(candidate)}
121
+ style={{ cursor: 'pointer' }}
122
+ />
123
+ <Stack space={1} style={{ flex: 1 }}>
124
+ <Text size={1} style={{ fontFamily: 'monospace' }}>{candidate._id}</Text>
125
+ <Text size={0} muted>
126
+ {candidate.title} · {candidate.weightName} · {candidate.style}
127
+ {candidate.subfamily ? ` · ${candidate.subfamily}` : ''}
128
+ </Text>
129
+ </Stack>
130
+ </Flex>
131
+ </Card>
132
+ ))}
133
+ </Stack>
134
+ )}
135
+ </Stack>
136
+ );
137
+ }
138
+
139
+ // User chose create after initially having a match (fallback)
140
+ if (userChoice === 'create') {
141
+ return (
142
+ <Stack space={2}>
143
+ <Label size={0}>Existing Document</Label>
144
+ <Card border padding={2} radius={1}>
145
+ <Text size={1}>Will create new document</Text>
146
+ </Card>
147
+ </Stack>
148
+ );
149
+ }
150
+
151
+ return null;
152
+ }