@liiift-studio/sanity-font-manager 2.3.19 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +437 -437
- package/dist/UploadModal-6LIX7XOK.js +6 -0
- package/dist/UploadModal-NME2W53V.mjs +6 -0
- package/dist/chunk-646WCBRR.mjs +7276 -0
- package/dist/chunk-FH4QKHOH.js +7276 -0
- package/dist/index.js +747 -1675
- package/dist/index.mjs +400 -1237
- package/package.json +85 -85
- package/src/components/BatchUploadFonts.jsx +653 -639
- package/src/components/BulkActions.jsx +99 -0
- package/src/components/ExistingDocumentResolver.jsx +152 -0
- package/src/components/FontReviewCard.jsx +415 -0
- package/src/components/FontScriptUploaderComponent.jsx +463 -463
- package/src/components/GenerateCollectionsPairsComponent.jsx +259 -259
- package/src/components/KeyValueInput.jsx +95 -95
- package/src/components/KeyValueReferenceInput.jsx +254 -254
- package/src/components/NestedObjectArraySelector.jsx +146 -146
- package/src/components/PriceInput.jsx +26 -26
- package/src/components/PrimaryCollectionGeneratorTypeface.jsx +116 -116
- package/src/components/RegenerateSubfamiliesComponent.jsx +185 -185
- package/src/components/SetOTF.jsx +87 -87
- package/src/components/SingleUploaderTool.jsx +672 -673
- package/src/components/StatusDisplay.jsx +26 -26
- package/src/components/StyleCountInput.jsx +16 -16
- package/src/components/UpdateScriptsComponent.jsx +76 -76
- package/src/components/UploadButton.jsx +43 -43
- package/src/components/UploadModal.jsx +268 -0
- package/src/components/UploadScriptsComponent.jsx +539 -537
- package/src/components/UploadStep1Settings.jsx +272 -0
- package/src/components/UploadStep2Review.jsx +472 -0
- package/src/components/UploadStep3Execute.jsx +234 -0
- package/src/components/UploadSummary.jsx +196 -0
- package/src/components/VariableInstanceReferencesInput.jsx +190 -190
- package/src/hooks/useNestedObjects.js +92 -92
- package/src/hooks/useSanityClient.js +9 -9
- package/src/index.js +115 -70
- package/src/schema/openTypeField.js +1945 -1945
- package/src/schema/styleCountField.js +12 -12
- package/src/schema/stylesField.js +268 -268
- package/src/schema/stylisticSetField.js +301 -301
- package/src/utils/buildUploadPlan.js +325 -0
- package/src/utils/executeUploadPlan.js +437 -0
- package/src/utils/executionReducer.js +56 -0
- package/src/utils/fontHelpers.js +267 -0
- package/src/utils/generateCssFile.js +207 -205
- package/src/utils/generateFontData.js +98 -145
- package/src/utils/generateFontFile.js +38 -38
- package/src/utils/generateKeywords.js +185 -185
- package/src/utils/generateSubset.js +45 -45
- package/src/utils/getEmptyFontKit.js +101 -99
- package/src/utils/parseFont.js +55 -0
- package/src/utils/parseVariableFontInstances.js +211 -211
- package/src/utils/planReducer.js +517 -0
- package/src/utils/planTypes.js +183 -0
- package/src/utils/processFontFiles.js +529 -477
- package/src/utils/regenerateFontData.js +146 -146
- package/src/utils/resolveExistingFont.js +87 -0
- package/src/utils/sanitizeForSanityId.js +65 -65
- package/src/utils/updateFontPrices.js +94 -94
- package/src/utils/updateTypefaceDocument.js +149 -160
- package/src/utils/uploadFontFiles.js +405 -316
- package/src/utils/utils.js +24 -24
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
// Per-font review row — table-style header with expandable detail panel
|
|
2
|
+
|
|
3
|
+
import React, { useState, useCallback, useEffect, useMemo, memo } from 'react';
|
|
4
|
+
import { Card, Stack, Flex, Box, Text, TextInput, Badge, Button, Select, Tooltip, Label } from '@sanity/ui';
|
|
5
|
+
import { ChevronDownIcon, ChevronRightIcon, TrashIcon, ResetIcon, InfoOutlineIcon } from '@sanity/icons';
|
|
6
|
+
import { FONT_STATUS, RECOMMENDATION } from '../utils/planTypes';
|
|
7
|
+
import ExistingDocumentResolver from './ExistingDocumentResolver';
|
|
8
|
+
|
|
9
|
+
/** Standard file types shown in the files row */
|
|
10
|
+
const STANDARD_TYPES = ['ttf', 'otf', 'woff', 'woff2'];
|
|
11
|
+
/** Extended file types shown when expanded */
|
|
12
|
+
const EXTENDED_TYPES = ['eot', 'svg', 'css', 'woff2_subset', 'woff2_web'];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Collapsible review card for a single font in the upload plan.
|
|
16
|
+
* Table-style header row with weight/style/files/action columns.
|
|
17
|
+
*/
|
|
18
|
+
const FontReviewCard = memo(function FontReviewCard({ entry, dispatch, allExpanded }) {
|
|
19
|
+
const [expanded, setExpanded] = useState(false);
|
|
20
|
+
const [showAllFileTypes, setShowAllFileTypes] = useState(false);
|
|
21
|
+
|
|
22
|
+
// Sync with allExpanded toggle from BulkActions
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
setExpanded(allExpanded);
|
|
25
|
+
}, [allExpanded]);
|
|
26
|
+
|
|
27
|
+
// Local state for typing — dispatches on blur
|
|
28
|
+
const [localTitle, setLocalTitle] = useState(entry.title);
|
|
29
|
+
const [localWeight, setLocalWeight] = useState(String(entry.weight));
|
|
30
|
+
const [localWeightName, setLocalWeightName] = useState(entry.weightName);
|
|
31
|
+
const [localSubfamily, setLocalSubfamily] = useState(entry.subfamily);
|
|
32
|
+
|
|
33
|
+
const [localDocId, setLocalDocId] = useState(entry.documentId);
|
|
34
|
+
|
|
35
|
+
const isError = entry.status === FONT_STATUS.ERROR;
|
|
36
|
+
const hasConflict = entry._idConflict;
|
|
37
|
+
const resolution = entry.decisions?.existingDocument;
|
|
38
|
+
const isUpdate = resolution?.userChoice === 'update' ||
|
|
39
|
+
(!resolution?.userChoice && (resolution?.recommendation === RECOMMENDATION.USE_EXACT || resolution?.recommendation === RECOMMENDATION.USE_CANDIDATE));
|
|
40
|
+
|
|
41
|
+
// Document ID is editable when user chose "create new" on a font with an existing match
|
|
42
|
+
const isCreateNewOverride = resolution?.userChoice === 'create' &&
|
|
43
|
+
(resolution?.exact || resolution?.candidates?.length > 0);
|
|
44
|
+
const docIdEditable = isCreateNewOverride || hasConflict;
|
|
45
|
+
|
|
46
|
+
// Detect if user has overridden any suggestions
|
|
47
|
+
const hasUserOverrides = useMemo(() => {
|
|
48
|
+
const d = entry.decisions;
|
|
49
|
+
if (!d) return false;
|
|
50
|
+
return (
|
|
51
|
+
d.title?.userOverride != null ||
|
|
52
|
+
d.weight?.userOverride != null ||
|
|
53
|
+
d.weightName?.userOverride != null ||
|
|
54
|
+
d.style?.userOverride != null ||
|
|
55
|
+
d.subfamily?.userOverride != null ||
|
|
56
|
+
d.documentId?.userOverride != null
|
|
57
|
+
);
|
|
58
|
+
}, [entry.decisions]);
|
|
59
|
+
|
|
60
|
+
// Map of which file extensions are present
|
|
61
|
+
const fileExtMap = useMemo(() => {
|
|
62
|
+
const map = {};
|
|
63
|
+
(entry.files || []).forEach(f => {
|
|
64
|
+
const ext = f.name?.split('.').pop()?.toLowerCase();
|
|
65
|
+
if (ext) map[ext] = f.name;
|
|
66
|
+
});
|
|
67
|
+
return map;
|
|
68
|
+
}, [entry.files]);
|
|
69
|
+
|
|
70
|
+
const fileCount = entry.files?.length || 0;
|
|
71
|
+
const cardTone = isError ? 'critical' : hasConflict ? 'caution' : 'default';
|
|
72
|
+
|
|
73
|
+
const handleTitleBlur = useCallback(() => {
|
|
74
|
+
if (localTitle !== entry.title) {
|
|
75
|
+
dispatch({ type: 'SET_FONT_TITLE', tempId: entry.tempId, title: localTitle });
|
|
76
|
+
}
|
|
77
|
+
}, [localTitle, entry.title, entry.tempId, dispatch]);
|
|
78
|
+
|
|
79
|
+
const handleDocIdBlur = useCallback(() => {
|
|
80
|
+
if (localDocId !== entry.documentId) {
|
|
81
|
+
dispatch({ type: 'SET_FONT_DOCUMENT_ID', tempId: entry.tempId, documentId: localDocId });
|
|
82
|
+
}
|
|
83
|
+
}, [localDocId, entry.documentId, entry.tempId, dispatch]);
|
|
84
|
+
|
|
85
|
+
const handleWeightBlur = useCallback(() => {
|
|
86
|
+
const num = Number(localWeight);
|
|
87
|
+
if (!isNaN(num) && num !== entry.weight) {
|
|
88
|
+
dispatch({ type: 'SET_FONT_WEIGHT', tempId: entry.tempId, weight: num });
|
|
89
|
+
}
|
|
90
|
+
}, [localWeight, entry.weight, entry.tempId, dispatch]);
|
|
91
|
+
|
|
92
|
+
const handleWeightNameBlur = useCallback(() => {
|
|
93
|
+
if (localWeightName !== entry.weightName) {
|
|
94
|
+
dispatch({ type: 'SET_FONT_WEIGHT_NAME', tempId: entry.tempId, weightName: localWeightName });
|
|
95
|
+
}
|
|
96
|
+
}, [localWeightName, entry.weightName, entry.tempId, dispatch]);
|
|
97
|
+
|
|
98
|
+
const handleStyleChange = useCallback((e) => {
|
|
99
|
+
dispatch({ type: 'SET_FONT_STYLE', tempId: entry.tempId, style: e.target.value });
|
|
100
|
+
}, [entry.tempId, dispatch]);
|
|
101
|
+
|
|
102
|
+
const handleSubfamilyBlur = useCallback(() => {
|
|
103
|
+
if (localSubfamily !== entry.subfamily) {
|
|
104
|
+
dispatch({ type: 'SET_FONT_SUBFAMILY', tempId: entry.tempId, subfamily: localSubfamily });
|
|
105
|
+
}
|
|
106
|
+
}, [localSubfamily, entry.subfamily, entry.tempId, dispatch]);
|
|
107
|
+
|
|
108
|
+
const handleReset = useCallback(() => {
|
|
109
|
+
dispatch({ type: 'RESET_FONT_TO_SUGGESTIONS', tempId: entry.tempId });
|
|
110
|
+
setLocalTitle(entry.decisions.title.processed);
|
|
111
|
+
setLocalDocId(entry.decisions.documentId.generated);
|
|
112
|
+
setLocalWeight(String(entry.decisions.weight.detected));
|
|
113
|
+
setLocalWeightName(entry.decisions.weightName.detected);
|
|
114
|
+
setLocalSubfamily(entry.decisions.subfamily.detected || 'Regular');
|
|
115
|
+
}, [entry, dispatch]);
|
|
116
|
+
|
|
117
|
+
const handleRemove = useCallback(() => {
|
|
118
|
+
dispatch({ type: 'REMOVE_FONT', tempId: entry.tempId });
|
|
119
|
+
}, [entry.tempId, dispatch]);
|
|
120
|
+
|
|
121
|
+
/** Format source string — converts nameId references to readable format, appends user override if present */
|
|
122
|
+
const formatSource = (decision) => {
|
|
123
|
+
if (!decision) return null;
|
|
124
|
+
let src = decision.source || '';
|
|
125
|
+
// Format name table references: nameId1-familyName → nameId1 (familyName)
|
|
126
|
+
src = src.replace(/nameId(\d+)-(\w+)/g, 'nameId$1 ($2)');
|
|
127
|
+
src = src.replace(/-/g, ' ').replace('fontkit ', '');
|
|
128
|
+
// Special case: default-empty for subfamily
|
|
129
|
+
if (src === 'default empty' && decision.detected === '') {
|
|
130
|
+
src = 'empty — defaults to "Regular"';
|
|
131
|
+
}
|
|
132
|
+
const reason = decision.reason ? ` (${decision.reason})` : '';
|
|
133
|
+
const override = decision.userOverride != null ? ' (user override)' : '';
|
|
134
|
+
return `Source: ${src}${reason}${override}`;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<Card border radius={2} tone={cardTone} style={{ marginBottom: -1 }}>
|
|
139
|
+
{/* Header row — table-style columns */}
|
|
140
|
+
<Box
|
|
141
|
+
as="button"
|
|
142
|
+
onClick={() => !isError && setExpanded(v => !v)}
|
|
143
|
+
style={{
|
|
144
|
+
width: '100%',
|
|
145
|
+
background: expanded ? 'var(--card-muted-bg-color)' : 'none',
|
|
146
|
+
border: 'none',
|
|
147
|
+
cursor: isError ? 'default' : 'pointer',
|
|
148
|
+
textAlign: 'left',
|
|
149
|
+
padding: 0,
|
|
150
|
+
transition: 'background 0.1s ease',
|
|
151
|
+
}}
|
|
152
|
+
onMouseEnter={(e) => { if (!isError && !expanded) e.currentTarget.style.background = 'var(--card-muted-bg-color)'; }}
|
|
153
|
+
onMouseLeave={(e) => { if (!expanded) e.currentTarget.style.background = 'none'; }}
|
|
154
|
+
>
|
|
155
|
+
<Flex align="center" gap={2} paddingX={2} paddingY={2}>
|
|
156
|
+
<Box style={{ width: 20, flexShrink: 0 }}>
|
|
157
|
+
{!isError && (expanded ? <ChevronDownIcon /> : <ChevronRightIcon />)}
|
|
158
|
+
</Box>
|
|
159
|
+
<Box style={{ flex: 1, whiteSpace: 'nowrap',}}>
|
|
160
|
+
<Text size={1} weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
|
161
|
+
{entry.title || entry.sourceFileName}
|
|
162
|
+
{entry.variableFont && <Badge tone="primary" fontSize={0} style={{ marginLeft: 6 }}>VF</Badge>}
|
|
163
|
+
{hasConflict && <Badge tone="caution" fontSize={0} style={{ marginLeft: 6 }}>ID Conflict</Badge>}
|
|
164
|
+
</Text>
|
|
165
|
+
</Box>
|
|
166
|
+
<Text size={0} style={{ width: 50, textAlign: 'center', flexShrink: 0 }}>{entry.weight}</Text>
|
|
167
|
+
<Text size={0} style={{ width: 50, textAlign: 'center', flexShrink: 0 }}>{entry.style}</Text>
|
|
168
|
+
<Text size={0} style={{ width: 40, textAlign: 'center', flexShrink: 0 }}>{fileCount}</Text>
|
|
169
|
+
<Box style={{ width: 55, textAlign: 'center', flexShrink: 0 }}>
|
|
170
|
+
<Badge tone={isError ? 'critical' : isUpdate ? 'caution' : 'positive'} fontSize={0}>
|
|
171
|
+
{isError ? 'Error' : isUpdate ? 'Update' : 'Create'}
|
|
172
|
+
</Badge>
|
|
173
|
+
</Box>
|
|
174
|
+
</Flex>
|
|
175
|
+
</Box>
|
|
176
|
+
|
|
177
|
+
{/* Error message */}
|
|
178
|
+
{isError && (
|
|
179
|
+
<Box paddingX={2} paddingBottom={2}>
|
|
180
|
+
<Flex justify="space-between" align="center">
|
|
181
|
+
<Text size={0} muted>{entry.error}</Text>
|
|
182
|
+
<Button mode="bleed" tone="critical" icon={TrashIcon} padding={1} onClick={handleRemove} />
|
|
183
|
+
</Flex>
|
|
184
|
+
</Box>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{/* Expanded detail panel */}
|
|
188
|
+
{expanded && !isError && (
|
|
189
|
+
<Box padding={3} style={{ borderTop: '1px solid var(--card-border-color)', background: 'var(--card-muted-bg-color)' }}>
|
|
190
|
+
<Stack space={4}>
|
|
191
|
+
{/* Files — standard types with grey for missing, expandable for extended */}
|
|
192
|
+
<Stack space={2}>
|
|
193
|
+
<Flex align="center" gap={2}>
|
|
194
|
+
<Label size={0}>Files ({fileCount})</Label>
|
|
195
|
+
<Button
|
|
196
|
+
mode="bleed"
|
|
197
|
+
fontSize={0}
|
|
198
|
+
padding={1}
|
|
199
|
+
text={showAllFileTypes ? 'Hide extended types' : 'Show all types'}
|
|
200
|
+
onClick={() => setShowAllFileTypes(v => !v)}
|
|
201
|
+
style={{ cursor: 'pointer' }}
|
|
202
|
+
/>
|
|
203
|
+
</Flex>
|
|
204
|
+
<Flex gap={1} wrap="wrap">
|
|
205
|
+
{STANDARD_TYPES.map(ext => (
|
|
206
|
+
<Badge
|
|
207
|
+
key={ext}
|
|
208
|
+
fontSize={0}
|
|
209
|
+
tone={fileExtMap[ext] ? 'primary' : 'default'}
|
|
210
|
+
mode={fileExtMap[ext] ? 'default' : 'outline'}
|
|
211
|
+
style={{ opacity: fileExtMap[ext] ? 1 : 0.35 }}
|
|
212
|
+
>
|
|
213
|
+
{ext.toUpperCase()}{fileExtMap[ext] ? `: ${fileExtMap[ext]}` : ''}
|
|
214
|
+
</Badge>
|
|
215
|
+
))}
|
|
216
|
+
{showAllFileTypes && EXTENDED_TYPES.map(ext => (
|
|
217
|
+
<Badge
|
|
218
|
+
key={ext}
|
|
219
|
+
fontSize={0}
|
|
220
|
+
tone={fileExtMap[ext] ? 'primary' : 'default'}
|
|
221
|
+
mode={fileExtMap[ext] ? 'default' : 'outline'}
|
|
222
|
+
style={{ opacity: fileExtMap[ext] ? 1 : 0.35 }}
|
|
223
|
+
>
|
|
224
|
+
{ext.toUpperCase()}{fileExtMap[ext] ? `: ${fileExtMap[ext]}` : ''}
|
|
225
|
+
</Badge>
|
|
226
|
+
))}
|
|
227
|
+
</Flex>
|
|
228
|
+
</Stack>
|
|
229
|
+
|
|
230
|
+
{/* Title */}
|
|
231
|
+
<Stack space={2}>
|
|
232
|
+
<Label size={0}>Font Title</Label>
|
|
233
|
+
<TextInput
|
|
234
|
+
value={localTitle}
|
|
235
|
+
onChange={(e) => setLocalTitle(e.target.value)}
|
|
236
|
+
onBlur={handleTitleBlur}
|
|
237
|
+
fontSize={1}
|
|
238
|
+
/>
|
|
239
|
+
<Text size={0} muted>{formatSource(entry.decisions?.title)}</Text>
|
|
240
|
+
{entry.decisions?.title?.alternatives?.length > 0 && (
|
|
241
|
+
<Flex gap={1} wrap="wrap">
|
|
242
|
+
{entry.decisions.title.alternatives.filter(a => a.value).map((alt, i) => (
|
|
243
|
+
<Tooltip key={i} content={<Box padding={1}><Text size={0}>{alt.source.replace(/nameId(\d+)-(\w+)/g, 'nameId$1 ($2)')}</Text></Box>} portal>
|
|
244
|
+
<Badge
|
|
245
|
+
mode="outline"
|
|
246
|
+
fontSize={0}
|
|
247
|
+
style={{ cursor: 'pointer' }}
|
|
248
|
+
onClick={() => {
|
|
249
|
+
setLocalTitle(alt.value);
|
|
250
|
+
dispatch({ type: 'SET_FONT_TITLE', tempId: entry.tempId, title: alt.value, source: alt.source });
|
|
251
|
+
}}
|
|
252
|
+
>
|
|
253
|
+
{alt.value.length > 30 ? alt.value.slice(0, 30) + '...' : alt.value}
|
|
254
|
+
</Badge>
|
|
255
|
+
</Tooltip>
|
|
256
|
+
))}
|
|
257
|
+
</Flex>
|
|
258
|
+
)}
|
|
259
|
+
</Stack>
|
|
260
|
+
|
|
261
|
+
{/* Document ID */}
|
|
262
|
+
<Stack space={2}>
|
|
263
|
+
<Flex align="center" gap={1}>
|
|
264
|
+
<Label size={0}>Document ID</Label>
|
|
265
|
+
{!docIdEditable && (
|
|
266
|
+
<Tooltip
|
|
267
|
+
content={<Box padding={2} style={{ maxWidth: 260 }}><Text size={1} style={{ lineHeight: 1.6 }}>Document IDs must be unique. This field is auto-derived from the font title. It becomes editable when you choose "Create new instead" on a font with an existing match, or when there is a duplicate ID conflict.</Text></Box>}
|
|
268
|
+
placement="top"
|
|
269
|
+
portal
|
|
270
|
+
>
|
|
271
|
+
<InfoOutlineIcon style={{ opacity: 0.4, fontSize: 12 }} />
|
|
272
|
+
</Tooltip>
|
|
273
|
+
)}
|
|
274
|
+
</Flex>
|
|
275
|
+
<TextInput
|
|
276
|
+
value={docIdEditable ? localDocId : entry.documentId}
|
|
277
|
+
onChange={docIdEditable ? (e) => setLocalDocId(e.target.value) : undefined}
|
|
278
|
+
onBlur={docIdEditable ? handleDocIdBlur : undefined}
|
|
279
|
+
readOnly={!docIdEditable}
|
|
280
|
+
fontSize={1}
|
|
281
|
+
style={{ fontFamily: 'monospace', opacity: docIdEditable ? 1 : 0.7 }}
|
|
282
|
+
/>
|
|
283
|
+
{hasConflict && (
|
|
284
|
+
<Text size={0} tone="caution">This ID conflicts with another font in this batch — edit to make unique</Text>
|
|
285
|
+
)}
|
|
286
|
+
{isCreateNewOverride && !hasConflict && (
|
|
287
|
+
<Text size={0} tone="caution">Creating new document — edit the ID to avoid overwriting the existing document</Text>
|
|
288
|
+
)}
|
|
289
|
+
{!docIdEditable && (
|
|
290
|
+
<Text size={0} muted>Auto-derived from font title</Text>
|
|
291
|
+
)}
|
|
292
|
+
</Stack>
|
|
293
|
+
|
|
294
|
+
{/* Weight + Weight Name row */}
|
|
295
|
+
<Flex gap={3}>
|
|
296
|
+
<Box style={{ flex: 1 }}>
|
|
297
|
+
<Stack space={2}>
|
|
298
|
+
<Label size={0}>Weight</Label>
|
|
299
|
+
<TextInput
|
|
300
|
+
type="number"
|
|
301
|
+
value={localWeight}
|
|
302
|
+
onChange={(e) => setLocalWeight(e.target.value)}
|
|
303
|
+
onBlur={handleWeightBlur}
|
|
304
|
+
fontSize={1}
|
|
305
|
+
/>
|
|
306
|
+
{entry.decisions?.weight && (
|
|
307
|
+
<Text size={0} muted>{formatSource(entry.decisions.weight)}</Text>
|
|
308
|
+
)}
|
|
309
|
+
</Stack>
|
|
310
|
+
</Box>
|
|
311
|
+
<Box style={{ flex: 1 }}>
|
|
312
|
+
<Stack space={2}>
|
|
313
|
+
<Label size={0}>Weight Name</Label>
|
|
314
|
+
<TextInput
|
|
315
|
+
value={localWeightName}
|
|
316
|
+
onChange={(e) => setLocalWeightName(e.target.value)}
|
|
317
|
+
onBlur={handleWeightNameBlur}
|
|
318
|
+
fontSize={1}
|
|
319
|
+
/>
|
|
320
|
+
{entry.decisions?.weightName && (
|
|
321
|
+
<Text size={0} muted>{formatSource(entry.decisions.weightName)}</Text>
|
|
322
|
+
)}
|
|
323
|
+
</Stack>
|
|
324
|
+
</Box>
|
|
325
|
+
</Flex>
|
|
326
|
+
|
|
327
|
+
{/* Style + Subfamily row */}
|
|
328
|
+
<Flex gap={3}>
|
|
329
|
+
<Box style={{ flex: 1 }}>
|
|
330
|
+
<Stack space={2}>
|
|
331
|
+
<Label size={0}>Style</Label>
|
|
332
|
+
<Select value={entry.style} onChange={handleStyleChange} fontSize={1}>
|
|
333
|
+
<option value="Regular">Regular</option>
|
|
334
|
+
<option value="Italic">Italic</option>
|
|
335
|
+
</Select>
|
|
336
|
+
{entry.decisions?.style && (
|
|
337
|
+
<Text size={0} muted>{formatSource(entry.decisions.style)}</Text>
|
|
338
|
+
)}
|
|
339
|
+
</Stack>
|
|
340
|
+
</Box>
|
|
341
|
+
<Box style={{ flex: 1 }}>
|
|
342
|
+
<Stack space={2}>
|
|
343
|
+
<Label size={0}>Subfamily</Label>
|
|
344
|
+
<TextInput
|
|
345
|
+
value={localSubfamily}
|
|
346
|
+
onChange={(e) => setLocalSubfamily(e.target.value)}
|
|
347
|
+
onBlur={handleSubfamilyBlur}
|
|
348
|
+
fontSize={1}
|
|
349
|
+
/>
|
|
350
|
+
{entry.decisions?.subfamily && (
|
|
351
|
+
<Text size={0} muted>{formatSource(entry.decisions.subfamily)}</Text>
|
|
352
|
+
)}
|
|
353
|
+
</Stack>
|
|
354
|
+
</Box>
|
|
355
|
+
</Flex>
|
|
356
|
+
|
|
357
|
+
{/* VF axes info */}
|
|
358
|
+
{entry.variableFont && entry.variationAxes && (
|
|
359
|
+
<Stack space={2}>
|
|
360
|
+
<Label size={0}>Variable Font Axes</Label>
|
|
361
|
+
<Flex gap={1} wrap="wrap">
|
|
362
|
+
{Object.entries(entry.variationAxes).map(([tag, axis]) => (
|
|
363
|
+
<Badge key={tag} mode="outline" fontSize={0}>
|
|
364
|
+
{tag} {axis.min}–{axis.max}
|
|
365
|
+
</Badge>
|
|
366
|
+
))}
|
|
367
|
+
</Flex>
|
|
368
|
+
{entry.decisions?.weight?.userOverride != null && entry.variationAxes?.wght && (
|
|
369
|
+
(entry.weight < entry.variationAxes.wght.min || entry.weight > entry.variationAxes.wght.max) && (
|
|
370
|
+
<Text size={0} tone="caution">
|
|
371
|
+
Weight {entry.weight} is outside the wght axis range ({entry.variationAxes.wght.min}–{entry.variationAxes.wght.max})
|
|
372
|
+
</Text>
|
|
373
|
+
)
|
|
374
|
+
)}
|
|
375
|
+
</Stack>
|
|
376
|
+
)}
|
|
377
|
+
|
|
378
|
+
{/* Existing document resolution */}
|
|
379
|
+
<ExistingDocumentResolver
|
|
380
|
+
decision={entry.decisions?.existingDocument}
|
|
381
|
+
tempId={entry.tempId}
|
|
382
|
+
dispatch={dispatch}
|
|
383
|
+
/>
|
|
384
|
+
|
|
385
|
+
{/* Actions — only show reset if user has overridden suggestions */}
|
|
386
|
+
<Flex justify="flex-end" gap={2}>
|
|
387
|
+
{hasUserOverrides && (
|
|
388
|
+
<Button
|
|
389
|
+
mode="ghost"
|
|
390
|
+
tone="default"
|
|
391
|
+
icon={ResetIcon}
|
|
392
|
+
text="Reset to Suggestions"
|
|
393
|
+
fontSize={1}
|
|
394
|
+
padding={2}
|
|
395
|
+
onClick={handleReset}
|
|
396
|
+
/>
|
|
397
|
+
)}
|
|
398
|
+
<Button
|
|
399
|
+
mode="ghost"
|
|
400
|
+
tone="critical"
|
|
401
|
+
icon={TrashIcon}
|
|
402
|
+
text="Remove"
|
|
403
|
+
fontSize={1}
|
|
404
|
+
padding={2}
|
|
405
|
+
onClick={handleRemove}
|
|
406
|
+
/>
|
|
407
|
+
</Flex>
|
|
408
|
+
</Stack>
|
|
409
|
+
</Box>
|
|
410
|
+
)}
|
|
411
|
+
</Card>
|
|
412
|
+
);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
export default FontReviewCard;
|