@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,472 @@
|
|
|
1
|
+
// Step 2 — Processing & Review with settings, FontReviewCard, BulkActions, and subfamily grouping
|
|
2
|
+
|
|
3
|
+
import React, { useMemo, useState, useCallback, useEffect } from 'react';
|
|
4
|
+
import { Box, Grid, Stack, Flex, Text, Label, Switch, Button, Card, Spinner, Badge, Tooltip } from '@sanity/ui';
|
|
5
|
+
import { InfoOutlineIcon } from '@sanity/icons';
|
|
6
|
+
import { PLAN_PHASE, FONT_STATUS, RECOMMENDATION } from '../utils/planTypes';
|
|
7
|
+
import FontReviewCard from './FontReviewCard';
|
|
8
|
+
import BulkActions from './BulkActions';
|
|
9
|
+
import PriceInput from './PriceInput';
|
|
10
|
+
|
|
11
|
+
/** Determines whether a font entry will create or update a document */
|
|
12
|
+
function isUpdateEntry(entry) {
|
|
13
|
+
const d = entry.decisions?.existingDocument;
|
|
14
|
+
const choice = d?.userChoice;
|
|
15
|
+
const rec = d?.recommendation;
|
|
16
|
+
return choice === 'update' || (!choice && (rec === RECOMMENDATION.USE_EXACT || rec === RECOMMENDATION.USE_CANDIDATE));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Step 2 — displays processing progress and font review cards with full editing.
|
|
21
|
+
*/
|
|
22
|
+
export default function UploadStep2Review({
|
|
23
|
+
plan,
|
|
24
|
+
dispatch,
|
|
25
|
+
onCancelProcessing,
|
|
26
|
+
onReadyToUpload,
|
|
27
|
+
onStartExecution,
|
|
28
|
+
processingCancelled,
|
|
29
|
+
}) {
|
|
30
|
+
const isProcessing = plan.phase === PLAN_PHASE.PROCESSING;
|
|
31
|
+
const isReviewing = plan.phase === PLAN_PHASE.REVIEWING || plan.phase === PLAN_PHASE.READY;
|
|
32
|
+
|
|
33
|
+
// Settings state (editable in review)
|
|
34
|
+
const [localPrice, setLocalPrice] = useState(String(plan.settings?.price || 0));
|
|
35
|
+
const [localPreserveShortenedNames, setLocalPreserveShortenedNames] = useState(plan.settings?.preserveShortenedNames ?? true);
|
|
36
|
+
const [localPreserveFileNames, setLocalPreserveFileNames] = useState(plan.settings?.preserveFileNames ?? false);
|
|
37
|
+
|
|
38
|
+
// Search, filter, and sort state
|
|
39
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
40
|
+
const [filterBy, setFilterBy] = useState('all');
|
|
41
|
+
const [allExpanded, setAllExpanded] = useState(false);
|
|
42
|
+
const [sortBy, setSortBy] = useState('weight');
|
|
43
|
+
const [sortDir, setSortDir] = useState('asc');
|
|
44
|
+
|
|
45
|
+
const fontEntries = useMemo(() => Object.values(plan.fonts), [plan.fonts]);
|
|
46
|
+
const processedCount = fontEntries.filter(f => f.status === FONT_STATUS.PROCESSED).length;
|
|
47
|
+
const errorCount = fontEntries.filter(f => f.status === FONT_STATUS.ERROR).length;
|
|
48
|
+
const totalCount = plan.processingProgress.total;
|
|
49
|
+
|
|
50
|
+
// Debug: log all fonts with their subfamily assignment for investigation
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!isReviewing) return;
|
|
53
|
+
const processed = fontEntries.filter(f => f.status !== FONT_STATUS.ERROR);
|
|
54
|
+
if (processed.length === 0) return;
|
|
55
|
+
|
|
56
|
+
// Group by subfamily for clear logging
|
|
57
|
+
const bySubfamily = {};
|
|
58
|
+
processed.forEach(f => {
|
|
59
|
+
const sf = f.subfamily || '(none)';
|
|
60
|
+
if (!bySubfamily[sf]) bySubfamily[sf] = [];
|
|
61
|
+
bySubfamily[sf].push(f);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
console.group('[UploadStep2Review] Subfamily assignments');
|
|
65
|
+
Object.entries(bySubfamily).forEach(([sf, fonts]) => {
|
|
66
|
+
console.group(`Subfamily: "${sf}" (${fonts.length} font${fonts.length === 1 ? '' : 's'})`);
|
|
67
|
+
fonts.forEach(f => {
|
|
68
|
+
console.log(` "${f.title}" (id: ${f.documentId})`, {
|
|
69
|
+
sourceFile: f.sourceFileName,
|
|
70
|
+
weightName: f.weightName,
|
|
71
|
+
style: f.style,
|
|
72
|
+
subfamily: f.subfamily,
|
|
73
|
+
subfamilyDecision: f.decisions?.subfamily,
|
|
74
|
+
parsedMetadata: {
|
|
75
|
+
familyName: f.parsedMetadata?.familyName,
|
|
76
|
+
fullName: f.parsedMetadata?.fullName,
|
|
77
|
+
preferredFamily: f.parsedMetadata?.preferredFamily,
|
|
78
|
+
preferredSubfamily: f.parsedMetadata?.preferredSubfamily,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
console.groupEnd();
|
|
83
|
+
});
|
|
84
|
+
console.groupEnd();
|
|
85
|
+
}, [isReviewing, fontEntries]);
|
|
86
|
+
|
|
87
|
+
// Count creates vs updates
|
|
88
|
+
const createCount = useMemo(() =>
|
|
89
|
+
fontEntries.filter(f => f.status !== FONT_STATUS.ERROR && !isUpdateEntry(f)).length,
|
|
90
|
+
[fontEntries]
|
|
91
|
+
);
|
|
92
|
+
const updateCount = useMemo(() =>
|
|
93
|
+
fontEntries.filter(f => f.status !== FONT_STATUS.ERROR && isUpdateEntry(f)).length,
|
|
94
|
+
[fontEntries]
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// Filter and search
|
|
98
|
+
const visibleEntries = useMemo(() => {
|
|
99
|
+
let entries = fontEntries;
|
|
100
|
+
|
|
101
|
+
// Apply filter
|
|
102
|
+
if (filterBy === 'create') {
|
|
103
|
+
entries = entries.filter(f => !isUpdateEntry(f) && f.status !== FONT_STATUS.ERROR);
|
|
104
|
+
} else if (filterBy === 'update') {
|
|
105
|
+
entries = entries.filter(f => isUpdateEntry(f));
|
|
106
|
+
} else if (filterBy === 'error') {
|
|
107
|
+
entries = entries.filter(f => f.status === FONT_STATUS.ERROR);
|
|
108
|
+
} else if (filterBy === 'conflict') {
|
|
109
|
+
entries = entries.filter(f => f._idConflict);
|
|
110
|
+
} else if (filterBy === 'style:italic') {
|
|
111
|
+
entries = entries.filter(f => f.style === 'Italic' && f.status !== FONT_STATUS.ERROR);
|
|
112
|
+
} else if (filterBy === 'style:regular') {
|
|
113
|
+
entries = entries.filter(f => f.style === 'Regular' && f.status !== FONT_STATUS.ERROR);
|
|
114
|
+
} else if (filterBy.startsWith('sf:')) {
|
|
115
|
+
const sf = filterBy.slice(3);
|
|
116
|
+
entries = entries.filter(f => f.subfamily === sf);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Apply search
|
|
120
|
+
if (searchQuery.trim()) {
|
|
121
|
+
const q = searchQuery.toLowerCase().trim();
|
|
122
|
+
entries = entries.filter(f =>
|
|
123
|
+
f.title?.toLowerCase().includes(q) ||
|
|
124
|
+
f.documentId?.toLowerCase().includes(q) ||
|
|
125
|
+
f.sourceFileName?.toLowerCase().includes(q) ||
|
|
126
|
+
f.weightName?.toLowerCase().includes(q)
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return entries;
|
|
131
|
+
}, [fontEntries, filterBy, searchQuery]);
|
|
132
|
+
|
|
133
|
+
const visibleTempIds = useMemo(() => visibleEntries.map(e => e.tempId), [visibleEntries]);
|
|
134
|
+
const hasConflicts = fontEntries.some(f => f._idConflict);
|
|
135
|
+
|
|
136
|
+
/** Sort comparator based on current sortBy/sortDir */
|
|
137
|
+
const sortEntries = useCallback((a, b) => {
|
|
138
|
+
const dir = sortDir === 'asc' ? 1 : -1;
|
|
139
|
+
let cmp = 0;
|
|
140
|
+
switch (sortBy) {
|
|
141
|
+
case 'title':
|
|
142
|
+
cmp = (a.title || '').localeCompare(b.title || '');
|
|
143
|
+
break;
|
|
144
|
+
case 'weight':
|
|
145
|
+
cmp = a.weight - b.weight;
|
|
146
|
+
if (cmp === 0) {
|
|
147
|
+
// Secondary sort: italic after regular at same weight
|
|
148
|
+
if (a.style === 'Italic' && b.style !== 'Italic') cmp = 1;
|
|
149
|
+
else if (a.style !== 'Italic' && b.style === 'Italic') cmp = -1;
|
|
150
|
+
}
|
|
151
|
+
break;
|
|
152
|
+
case 'style':
|
|
153
|
+
cmp = (a.style || '').localeCompare(b.style || '');
|
|
154
|
+
break;
|
|
155
|
+
case 'files':
|
|
156
|
+
cmp = (a.files?.length || 0) - (b.files?.length || 0);
|
|
157
|
+
break;
|
|
158
|
+
case 'action': {
|
|
159
|
+
const aUpdate = isUpdateEntry(a) ? 1 : 0;
|
|
160
|
+
const bUpdate = isUpdateEntry(b) ? 1 : 0;
|
|
161
|
+
cmp = aUpdate - bUpdate;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
default:
|
|
165
|
+
cmp = a.weight - b.weight;
|
|
166
|
+
}
|
|
167
|
+
return cmp * dir;
|
|
168
|
+
}, [sortBy, sortDir]);
|
|
169
|
+
|
|
170
|
+
/** Toggle sort — click same column to flip direction, different column to sort asc */
|
|
171
|
+
const handleSort = useCallback((column) => {
|
|
172
|
+
if (sortBy === column) {
|
|
173
|
+
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
|
174
|
+
} else {
|
|
175
|
+
setSortBy(column);
|
|
176
|
+
setSortDir('asc');
|
|
177
|
+
}
|
|
178
|
+
}, [sortBy]);
|
|
179
|
+
|
|
180
|
+
// Group by subfamily for display — always show headers
|
|
181
|
+
const groupedEntries = useMemo(() => {
|
|
182
|
+
const groups = {};
|
|
183
|
+
for (const entry of visibleEntries) {
|
|
184
|
+
const sf = entry.subfamily || 'Regular';
|
|
185
|
+
if (!groups[sf]) groups[sf] = [];
|
|
186
|
+
groups[sf].push(entry);
|
|
187
|
+
}
|
|
188
|
+
// Sort entries within each group
|
|
189
|
+
Object.values(groups).forEach(g => g.sort(sortEntries));
|
|
190
|
+
// Sort groups: "Regular" first, then alphabetically
|
|
191
|
+
const sorted = {};
|
|
192
|
+
const keys = Object.keys(groups).sort((a, b) => {
|
|
193
|
+
if (a === 'Regular') return -1;
|
|
194
|
+
if (b === 'Regular') return 1;
|
|
195
|
+
return a.localeCompare(b);
|
|
196
|
+
});
|
|
197
|
+
keys.forEach(k => { sorted[k] = groups[k]; });
|
|
198
|
+
return sorted;
|
|
199
|
+
}, [visibleEntries]);
|
|
200
|
+
|
|
201
|
+
// Validation
|
|
202
|
+
const validationErrors = useMemo(() => {
|
|
203
|
+
const errors = [];
|
|
204
|
+
const uploadable = fontEntries.filter(f => f.status !== FONT_STATUS.ERROR);
|
|
205
|
+
const missingTitles = uploadable.filter(f => !f.title || f.title.trim() === '');
|
|
206
|
+
if (missingTitles.length > 0) {
|
|
207
|
+
errors.push(`${missingTitles.length} font${missingTitles.length === 1 ? '' : 's'} missing a title`);
|
|
208
|
+
}
|
|
209
|
+
const missingIds = uploadable.filter(f => !f.documentId || f.documentId.trim() === '');
|
|
210
|
+
if (missingIds.length > 0) {
|
|
211
|
+
errors.push(`${missingIds.length} font${missingIds.length === 1 ? '' : 's'} missing a document ID`);
|
|
212
|
+
}
|
|
213
|
+
if (hasConflicts) {
|
|
214
|
+
const conflictCount = uploadable.filter(f => f._idConflict).length;
|
|
215
|
+
errors.push(`${conflictCount} font${conflictCount === 1 ? '' : 's'} with duplicate document IDs`);
|
|
216
|
+
}
|
|
217
|
+
return errors;
|
|
218
|
+
}, [fontEntries, hasConflicts]);
|
|
219
|
+
|
|
220
|
+
const canUploadValidation = isReviewing && processedCount > 0 && validationErrors.length === 0;
|
|
221
|
+
|
|
222
|
+
const handleUpload = useCallback(() => {
|
|
223
|
+
if (validationErrors.length > 0) {
|
|
224
|
+
window.alert('Please fix the following before uploading:\n\n• ' + validationErrors.join('\n• '));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
onStartExecution();
|
|
228
|
+
}, [validationErrors, onStartExecution]);
|
|
229
|
+
|
|
230
|
+
const handleToggleExpandAll = useCallback(() => {
|
|
231
|
+
setAllExpanded(v => !v);
|
|
232
|
+
}, []);
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<Stack space={3}>
|
|
236
|
+
{/* Processing progress */}
|
|
237
|
+
{isProcessing && (
|
|
238
|
+
<Card border padding={3} radius={2}>
|
|
239
|
+
<Stack space={3}>
|
|
240
|
+
<Flex align="center" gap={3}>
|
|
241
|
+
<Spinner />
|
|
242
|
+
<Text size={1}>
|
|
243
|
+
Processing {plan.processingProgress.completed} of {totalCount} files...
|
|
244
|
+
</Text>
|
|
245
|
+
</Flex>
|
|
246
|
+
<Box style={{ height: 4, background: 'var(--card-border-color)', borderRadius: 2, overflow: 'hidden' }}>
|
|
247
|
+
<Box
|
|
248
|
+
style={{
|
|
249
|
+
height: '100%',
|
|
250
|
+
width: '100%',
|
|
251
|
+
transformOrigin: 'left',
|
|
252
|
+
transform: `scaleX(${totalCount > 0 ? plan.processingProgress.completed / totalCount : 0})`,
|
|
253
|
+
background: '#43b649',
|
|
254
|
+
transition: 'transform 0.3s ease-out',
|
|
255
|
+
borderRadius: 2,
|
|
256
|
+
}}
|
|
257
|
+
/>
|
|
258
|
+
</Box>
|
|
259
|
+
{plan.processingProgress.currentFile && (
|
|
260
|
+
<Text size={0} muted style={{ fontFamily: 'monospace' }}>
|
|
261
|
+
{plan.processingProgress.currentFile}
|
|
262
|
+
</Text>
|
|
263
|
+
)}
|
|
264
|
+
<Flex justify="flex-end">
|
|
265
|
+
<Button
|
|
266
|
+
mode="ghost"
|
|
267
|
+
tone="caution"
|
|
268
|
+
text="Cancel Processing"
|
|
269
|
+
fontSize={1}
|
|
270
|
+
padding={2}
|
|
271
|
+
onClick={onCancelProcessing}
|
|
272
|
+
/>
|
|
273
|
+
</Flex>
|
|
274
|
+
</Stack>
|
|
275
|
+
</Card>
|
|
276
|
+
)}
|
|
277
|
+
|
|
278
|
+
{/* Processing complete summary */}
|
|
279
|
+
{isReviewing && (
|
|
280
|
+
<Card tone={errorCount > 0 ? 'caution' : 'positive'} border padding={3} radius={2}>
|
|
281
|
+
<Flex align="center" gap={3}>
|
|
282
|
+
<Text size={1} weight="semibold">
|
|
283
|
+
{processedCount} document{processedCount === 1 ? '' : 's'}
|
|
284
|
+
</Text>
|
|
285
|
+
<Flex gap={1}>
|
|
286
|
+
{createCount > 0 && <Badge tone="positive" fontSize={0}>{createCount} create</Badge>}
|
|
287
|
+
{updateCount > 0 && <Badge tone="caution" fontSize={0}>{updateCount} update</Badge>}
|
|
288
|
+
{errorCount > 0 && <Badge tone="critical" fontSize={0}>{errorCount} error{errorCount === 1 ? '' : 's'}</Badge>}
|
|
289
|
+
</Flex>
|
|
290
|
+
</Flex>
|
|
291
|
+
</Card>
|
|
292
|
+
)}
|
|
293
|
+
|
|
294
|
+
{/* Settings — price and naming options */}
|
|
295
|
+
{isReviewing && (
|
|
296
|
+
<>
|
|
297
|
+
<Text size={1} weight="semibold">Settings</Text>
|
|
298
|
+
<Card border padding={3} radius={2}>
|
|
299
|
+
<Stack space={3}>
|
|
300
|
+
<Grid columns={[2]} gap={4}>
|
|
301
|
+
<Box>
|
|
302
|
+
<PriceInput
|
|
303
|
+
inputPrice={localPrice}
|
|
304
|
+
handleInputChange={(e) => {
|
|
305
|
+
setLocalPrice(e.target.value);
|
|
306
|
+
dispatch({ type: 'SET_SETTINGS', settings: { price: Number(e.target.value) || 0 } });
|
|
307
|
+
}}
|
|
308
|
+
/>
|
|
309
|
+
</Box>
|
|
310
|
+
<Stack space={3}>
|
|
311
|
+
<Flex align="center" gap={2}>
|
|
312
|
+
<Switch
|
|
313
|
+
checked={localPreserveShortenedNames}
|
|
314
|
+
onChange={(e) => {
|
|
315
|
+
setLocalPreserveShortenedNames(e.target.checked);
|
|
316
|
+
dispatch({ type: 'SET_SETTINGS', settings: { preserveShortenedNames: e.target.checked } });
|
|
317
|
+
}}
|
|
318
|
+
/>
|
|
319
|
+
<Tooltip
|
|
320
|
+
content={<Box padding={2} style={{ maxWidth: 260 }}><Text size={1} style={{ lineHeight: 1.6 }}>Abbreviations in font names are kept as-is (e.g. "XNarrow" stays "XNarrow").</Text></Box>}
|
|
321
|
+
placement="top"
|
|
322
|
+
portal
|
|
323
|
+
>
|
|
324
|
+
<Flex align="center" gap={1} style={{ cursor: 'default' }}>
|
|
325
|
+
<Label>Preserve shortened names</Label>
|
|
326
|
+
<InfoOutlineIcon style={{ opacity: 0.5, display: 'block' }} />
|
|
327
|
+
</Flex>
|
|
328
|
+
</Tooltip>
|
|
329
|
+
</Flex>
|
|
330
|
+
<Flex align="center" gap={2}>
|
|
331
|
+
<Switch
|
|
332
|
+
checked={localPreserveFileNames}
|
|
333
|
+
onChange={(e) => {
|
|
334
|
+
setLocalPreserveFileNames(e.target.checked);
|
|
335
|
+
dispatch({ type: 'SET_SETTINGS', settings: { preserveFileNames: e.target.checked } });
|
|
336
|
+
}}
|
|
337
|
+
/>
|
|
338
|
+
<Tooltip
|
|
339
|
+
content={<Box padding={2} style={{ maxWidth: 260 }}><Text size={1} style={{ lineHeight: 1.6 }}>Original filename is used for the font title and document ID instead of embedded font metadata.</Text></Box>}
|
|
340
|
+
placement="top"
|
|
341
|
+
portal
|
|
342
|
+
>
|
|
343
|
+
<Flex align="center" gap={1} style={{ cursor: 'default' }}>
|
|
344
|
+
<Label>Preserve file names</Label>
|
|
345
|
+
<InfoOutlineIcon style={{ opacity: 0.5, display: 'block' }} />
|
|
346
|
+
</Flex>
|
|
347
|
+
</Tooltip>
|
|
348
|
+
</Flex>
|
|
349
|
+
</Stack>
|
|
350
|
+
</Grid>
|
|
351
|
+
</Stack>
|
|
352
|
+
</Card>
|
|
353
|
+
</>
|
|
354
|
+
)}
|
|
355
|
+
|
|
356
|
+
{/* Bulk actions bar */}
|
|
357
|
+
{fontEntries.length > 0 && (
|
|
358
|
+
<BulkActions
|
|
359
|
+
fonts={plan.fonts}
|
|
360
|
+
dispatch={dispatch}
|
|
361
|
+
searchQuery={searchQuery}
|
|
362
|
+
onSearchChange={setSearchQuery}
|
|
363
|
+
filterBy={filterBy}
|
|
364
|
+
onFilterChange={setFilterBy}
|
|
365
|
+
allExpanded={allExpanded}
|
|
366
|
+
onToggleExpandAll={handleToggleExpandAll}
|
|
367
|
+
visibleTempIds={visibleTempIds}
|
|
368
|
+
/>
|
|
369
|
+
)}
|
|
370
|
+
|
|
371
|
+
{/* Expand All — between filters and column headers */}
|
|
372
|
+
{fontEntries.length > 0 && isReviewing && (
|
|
373
|
+
<Flex justify="flex-start" paddingY={1}>
|
|
374
|
+
<Button
|
|
375
|
+
mode="ghost"
|
|
376
|
+
fontSize={0}
|
|
377
|
+
padding={2}
|
|
378
|
+
text={allExpanded ? 'Collapse All' : 'Expand All'}
|
|
379
|
+
onClick={handleToggleExpandAll}
|
|
380
|
+
style={{ cursor: 'pointer' }}
|
|
381
|
+
/>
|
|
382
|
+
</Flex>
|
|
383
|
+
)}
|
|
384
|
+
|
|
385
|
+
{/* Sortable column header row */}
|
|
386
|
+
{fontEntries.length > 0 && isReviewing && (
|
|
387
|
+
<Flex
|
|
388
|
+
align="center"
|
|
389
|
+
gap={2}
|
|
390
|
+
paddingX={2}
|
|
391
|
+
paddingY={1}
|
|
392
|
+
style={{ borderBottom: '1px solid var(--card-border-color)', userSelect: 'none' }}
|
|
393
|
+
>
|
|
394
|
+
<Box style={{ width: 20 }} />
|
|
395
|
+
{[
|
|
396
|
+
{ key: 'title', label: 'Font Title', style: { flex: 1, cursor: 'pointer' } },
|
|
397
|
+
{ key: 'weight', label: 'Weight', style: { width: 50, textAlign: 'center', cursor: 'pointer' } },
|
|
398
|
+
{ key: 'style', label: 'Style', style: { width: 50, textAlign: 'center', cursor: 'pointer' } },
|
|
399
|
+
{ key: 'files', label: 'Files', style: { width: 40, textAlign: 'center', cursor: 'pointer' } },
|
|
400
|
+
{ key: 'action', label: 'Action', style: { width: 55, textAlign: 'center', cursor: 'pointer' } },
|
|
401
|
+
].map(col => (
|
|
402
|
+
<Text
|
|
403
|
+
key={col.key}
|
|
404
|
+
size={0}
|
|
405
|
+
weight="semibold"
|
|
406
|
+
muted={sortBy !== col.key}
|
|
407
|
+
style={col.style}
|
|
408
|
+
onClick={() => handleSort(col.key)}
|
|
409
|
+
>
|
|
410
|
+
{col.label} {sortBy === col.key ? (sortDir === 'asc' ? '↑' : '↓') : ''}
|
|
411
|
+
</Text>
|
|
412
|
+
))}
|
|
413
|
+
</Flex>
|
|
414
|
+
)}
|
|
415
|
+
|
|
416
|
+
{/* Font cards grouped by subfamily */}
|
|
417
|
+
{Object.entries(groupedEntries).map(([subfamily, entries]) => (
|
|
418
|
+
<Stack key={subfamily} space={1}>
|
|
419
|
+
<Card padding={2} radius={1} style={{ background: 'var(--card-muted-bg-color)' }}>
|
|
420
|
+
<Flex align="center" gap={2}>
|
|
421
|
+
<Text size={1} weight="semibold">
|
|
422
|
+
{subfamily}
|
|
423
|
+
</Text>
|
|
424
|
+
<Badge mode="outline" fontSize={0}>{entries.length}</Badge>
|
|
425
|
+
</Flex>
|
|
426
|
+
</Card>
|
|
427
|
+
<Stack space={0}>
|
|
428
|
+
{entries.map(entry => (
|
|
429
|
+
<FontReviewCard
|
|
430
|
+
key={entry.tempId}
|
|
431
|
+
entry={entry}
|
|
432
|
+
dispatch={dispatch}
|
|
433
|
+
allExpanded={allExpanded}
|
|
434
|
+
/>
|
|
435
|
+
))}
|
|
436
|
+
</Stack>
|
|
437
|
+
</Stack>
|
|
438
|
+
))}
|
|
439
|
+
|
|
440
|
+
{/* Empty state */}
|
|
441
|
+
{visibleEntries.length === 0 && fontEntries.length > 0 && (
|
|
442
|
+
<Card border padding={4} radius={2}>
|
|
443
|
+
<Text size={1} muted align="center">No fonts match the current filter</Text>
|
|
444
|
+
</Card>
|
|
445
|
+
)}
|
|
446
|
+
|
|
447
|
+
{/* Validation errors */}
|
|
448
|
+
{isReviewing && validationErrors.length > 0 && (
|
|
449
|
+
<Card tone="caution" border padding={2} radius={2}>
|
|
450
|
+
<Stack space={1}>
|
|
451
|
+
{validationErrors.map((err, i) => (
|
|
452
|
+
<Text key={i} size={0} tone="caution">• {err}</Text>
|
|
453
|
+
))}
|
|
454
|
+
</Stack>
|
|
455
|
+
</Card>
|
|
456
|
+
)}
|
|
457
|
+
|
|
458
|
+
{/* Upload button */}
|
|
459
|
+
{isReviewing && processedCount > 0 && (
|
|
460
|
+
<Flex justify="flex-end" gap={2} style={{ position: 'sticky', bottom: 0, background: 'var(--card-bg-color)', paddingTop: 8, paddingBottom: 4 }}>
|
|
461
|
+
<Button
|
|
462
|
+
mode="default"
|
|
463
|
+
tone="primary"
|
|
464
|
+
text={`Upload ${processedCount} Font${processedCount === 1 ? '' : 's'} to Sanity`}
|
|
465
|
+
disabled={!canUploadValidation}
|
|
466
|
+
onClick={handleUpload}
|
|
467
|
+
/>
|
|
468
|
+
</Flex>
|
|
469
|
+
)}
|
|
470
|
+
</Stack>
|
|
471
|
+
);
|
|
472
|
+
}
|