sanity-plugin-image-resizer 1.0.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/LICENSE +21 -0
- package/README.md +91 -0
- package/dist/_chunks-cjs/resources.js +4 -0
- package/dist/_chunks-cjs/resources.js.map +1 -0
- package/dist/_chunks-es/resources.mjs +5 -0
- package/dist/_chunks-es/resources.mjs.map +1 -0
- package/dist/index.d.mts +31 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +487 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +490 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +85 -0
- package/sanity.json +8 -0
- package/src/config.ts +61 -0
- package/src/constants.ts +1 -0
- package/src/helpers.ts +232 -0
- package/src/i18n/index.ts +9 -0
- package/src/i18n/resources.ts +1 -0
- package/src/index.ts +9 -0
- package/src/plugin.tsx +52 -0
- package/src/tool/ImageResizer.tsx +389 -0
- package/src/tool/components/AssetCard.tsx +185 -0
- package/v2-incompatible.js +11 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { useClient } from 'sanity'
|
|
3
|
+
import {
|
|
4
|
+
Badge,
|
|
5
|
+
Box,
|
|
6
|
+
Button,
|
|
7
|
+
Card,
|
|
8
|
+
Container,
|
|
9
|
+
Dialog,
|
|
10
|
+
Flex,
|
|
11
|
+
Heading,
|
|
12
|
+
Label,
|
|
13
|
+
Spinner,
|
|
14
|
+
Stack,
|
|
15
|
+
Switch,
|
|
16
|
+
Text,
|
|
17
|
+
} from '@sanity/ui'
|
|
18
|
+
import { CogIcon } from '@sanity/icons'
|
|
19
|
+
import {
|
|
20
|
+
type ConversionSettings,
|
|
21
|
+
type ImageAsset,
|
|
22
|
+
CONCURRENCY,
|
|
23
|
+
DEFAULT_SETTINGS,
|
|
24
|
+
IMAGE_MAX_SIZE,
|
|
25
|
+
IMAGE_MAX_WIDTH,
|
|
26
|
+
buildReplacementPatch,
|
|
27
|
+
getViolations,
|
|
28
|
+
processImage,
|
|
29
|
+
} from '../helpers'
|
|
30
|
+
import { AssetCard } from './components/AssetCard'
|
|
31
|
+
|
|
32
|
+
/** Human-readable size limit for display purposes */
|
|
33
|
+
const MAX_SIZE_MB = IMAGE_MAX_SIZE / 1024 / 1024
|
|
34
|
+
|
|
35
|
+
const KV_SETTINGS_KEY = 'image-resizer-settings'
|
|
36
|
+
|
|
37
|
+
function loadSettings(): ConversionSettings {
|
|
38
|
+
try {
|
|
39
|
+
const raw = localStorage.getItem(KV_SETTINGS_KEY)
|
|
40
|
+
if (raw) {
|
|
41
|
+
const parsed = JSON.parse(raw)
|
|
42
|
+
return {
|
|
43
|
+
pngToWebp:
|
|
44
|
+
typeof parsed.pngToWebp === 'boolean'
|
|
45
|
+
? parsed.pngToWebp
|
|
46
|
+
: DEFAULT_SETTINGS.pngToWebp,
|
|
47
|
+
tiffToJpg:
|
|
48
|
+
typeof parsed.tiffToJpg === 'boolean'
|
|
49
|
+
? parsed.tiffToJpg
|
|
50
|
+
: DEFAULT_SETTINGS.tiffToJpg,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// ignore corrupt data
|
|
55
|
+
}
|
|
56
|
+
return DEFAULT_SETTINGS
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Studio tool that scans all image assets for constraint violations
|
|
61
|
+
* (TIFF format, oversized width/filesize) and lets editors batch-optimise
|
|
62
|
+
* them in-place — re-encoding, resizing and re-linking references.
|
|
63
|
+
*/
|
|
64
|
+
export function ImageResizerView() {
|
|
65
|
+
const client = useClient({ apiVersion: '2025-02-19' })
|
|
66
|
+
const [assets, setAssets] = useState<ImageAsset[]>([])
|
|
67
|
+
const [loading, setLoading] = useState(true)
|
|
68
|
+
const [processingAll, setProcessingAll] = useState(false)
|
|
69
|
+
const [settings, setSettings] = useState<ConversionSettings>(loadSettings)
|
|
70
|
+
const [showSettings, setShowSettings] = useState(false)
|
|
71
|
+
|
|
72
|
+
/** Wraps setSettings to also persist to localStorage. */
|
|
73
|
+
const updateSettings = useCallback(
|
|
74
|
+
(updater: (prev: ConversionSettings) => ConversionSettings) => {
|
|
75
|
+
setSettings((prev) => {
|
|
76
|
+
const next = updater(prev)
|
|
77
|
+
localStorage.setItem(KV_SETTINGS_KEY, JSON.stringify(next))
|
|
78
|
+
return next
|
|
79
|
+
})
|
|
80
|
+
},
|
|
81
|
+
[]
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
// Ref keeps processAll's sequential loop in sync with latest state
|
|
85
|
+
const assetsRef = useRef(assets)
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
assetsRef.current = assets
|
|
88
|
+
}, [assets])
|
|
89
|
+
|
|
90
|
+
// ── data fetching ───────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/** Fetches all image assets that violate at least one constraint. */
|
|
93
|
+
const fetchAssets = useCallback(async () => {
|
|
94
|
+
setLoading(true)
|
|
95
|
+
try {
|
|
96
|
+
const raw = await client.fetch<
|
|
97
|
+
Omit<ImageAsset, 'violations' | 'status'>[]
|
|
98
|
+
>(
|
|
99
|
+
`*[_type == "sanity.imageAsset" && (
|
|
100
|
+
mimeType == "image/tiff" ||
|
|
101
|
+
metadata.dimensions.width > ${IMAGE_MAX_WIDTH} ||
|
|
102
|
+
size > ${IMAGE_MAX_SIZE}
|
|
103
|
+
)][] {
|
|
104
|
+
_id, url, originalFilename, mimeType, size,
|
|
105
|
+
"width": metadata.dimensions.width
|
|
106
|
+
}`
|
|
107
|
+
)
|
|
108
|
+
setAssets(
|
|
109
|
+
raw.map((a) => ({
|
|
110
|
+
...a,
|
|
111
|
+
violations: getViolations(a, settings),
|
|
112
|
+
status: 'idle',
|
|
113
|
+
}))
|
|
114
|
+
)
|
|
115
|
+
} finally {
|
|
116
|
+
setLoading(false)
|
|
117
|
+
}
|
|
118
|
+
}, [client, settings])
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
fetchAssets()
|
|
122
|
+
}, [fetchAssets])
|
|
123
|
+
|
|
124
|
+
// ── single-asset processing ─────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/** Helper to update a single asset's state by ID. */
|
|
127
|
+
const updateAsset = useCallback(
|
|
128
|
+
(id: string, patch: Partial<ImageAsset>) =>
|
|
129
|
+
setAssets((prev) =>
|
|
130
|
+
prev.map((a) => (a._id === id ? { ...a, ...patch } : a))
|
|
131
|
+
),
|
|
132
|
+
[]
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Processes one asset end-to-end:
|
|
137
|
+
* 1. Re-encode / resize via Sanity Image API transformations
|
|
138
|
+
* 2. Upload the transformed image as a new asset
|
|
139
|
+
* 3. Find & patch all documents that reference the old asset
|
|
140
|
+
* 4. Delete the old asset
|
|
141
|
+
*/
|
|
142
|
+
const processAsset = useCallback(
|
|
143
|
+
async (asset: ImageAsset) => {
|
|
144
|
+
updateAsset(asset._id, { status: 'processing', error: undefined })
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
// 1 — Resize / convert via Sanity Image API
|
|
148
|
+
const { blob, outFormat } = await processImage(
|
|
149
|
+
asset.url,
|
|
150
|
+
asset.mimeType,
|
|
151
|
+
asset.width,
|
|
152
|
+
settings
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
// Skip replacement if the new file is bigger than the original
|
|
156
|
+
if (blob.size >= asset.size) {
|
|
157
|
+
updateAsset(asset._id, {
|
|
158
|
+
status: 'error',
|
|
159
|
+
error: `Skipped: optimised file (${(blob.size / 1024 / 1024).toFixed(1)} MB) is not smaller than original (${(asset.size / 1024 / 1024).toFixed(1)} MB)`,
|
|
160
|
+
})
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 2 — Upload replacement asset
|
|
165
|
+
const baseName =
|
|
166
|
+
asset.originalFilename?.replace(/\.[^.]+$/, '') || 'image'
|
|
167
|
+
const newAsset = await client.assets.upload('image', blob, {
|
|
168
|
+
filename: `${baseName}.${outFormat}`,
|
|
169
|
+
contentType: `image/${outFormat}`,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// 3 — Re-link all referencing documents
|
|
173
|
+
const refs = await client.fetch<{ _id: string }[]>(
|
|
174
|
+
`*[references($id)]{ _id }`,
|
|
175
|
+
{ id: asset._id }
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
for (const { _id } of refs) {
|
|
179
|
+
const doc = await client.getDocument(_id)
|
|
180
|
+
if (!doc) continue
|
|
181
|
+
const patch = buildReplacementPatch(doc, asset._id, newAsset._id)
|
|
182
|
+
if (Object.keys(patch).length > 0) {
|
|
183
|
+
await client.patch(_id).set(patch).commit()
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 4 — Delete the old asset now that all references point to the new one
|
|
188
|
+
await client.delete(asset._id)
|
|
189
|
+
|
|
190
|
+
// Fetch the new asset's metadata for display
|
|
191
|
+
const newMeta = await client.fetch<{
|
|
192
|
+
url: string
|
|
193
|
+
size: number
|
|
194
|
+
width: number
|
|
195
|
+
originalFilename: string
|
|
196
|
+
}>(
|
|
197
|
+
`*[_id == $id][0]{ url, size, originalFilename, "width": metadata.dimensions.width }`,
|
|
198
|
+
{ id: newAsset._id }
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
updateAsset(asset._id, {
|
|
202
|
+
status: 'done',
|
|
203
|
+
newUrl: newMeta?.url ?? newAsset.url,
|
|
204
|
+
newSize: newMeta?.size ?? blob.size,
|
|
205
|
+
newWidth: newMeta?.width ?? Math.min(asset.width, IMAGE_MAX_WIDTH),
|
|
206
|
+
newFilename: newMeta?.originalFilename ?? `${baseName}.${outFormat}`,
|
|
207
|
+
})
|
|
208
|
+
} catch (err: unknown) {
|
|
209
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
210
|
+
updateAsset(asset._id, { status: 'error', error: message })
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
[client, updateAsset, settings]
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
// ── batch processing ────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Processes all pending / failed assets with up to `CONCURRENCY`
|
|
220
|
+
* tasks running in parallel using a simple worker-pool pattern.
|
|
221
|
+
*/
|
|
222
|
+
const processAll = useCallback(async () => {
|
|
223
|
+
setProcessingAll(true)
|
|
224
|
+
const pending = assetsRef.current.filter(
|
|
225
|
+
(a) => a.status === 'idle' || a.status === 'error'
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
let idx = 0
|
|
229
|
+
const next = async (): Promise<void> => {
|
|
230
|
+
while (idx < pending.length) {
|
|
231
|
+
const asset = pending[idx++]
|
|
232
|
+
await processAsset(asset)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Spawn `CONCURRENCY` workers that all pull from the same queue
|
|
237
|
+
await Promise.all(Array.from({ length: CONCURRENCY }, () => next()))
|
|
238
|
+
|
|
239
|
+
setProcessingAll(false)
|
|
240
|
+
}, [processAsset])
|
|
241
|
+
|
|
242
|
+
// ── derived state (memoised) ────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
/** Aggregated status counts used for badges and conditional rendering. */
|
|
245
|
+
const counts = useMemo(
|
|
246
|
+
() => ({
|
|
247
|
+
pending: assets.filter((a) => a.status === 'idle').length,
|
|
248
|
+
processing: assets.filter((a) => a.status === 'processing').length,
|
|
249
|
+
done: assets.filter((a) => a.status === 'done').length,
|
|
250
|
+
error: assets.filter((a) => a.status === 'error').length,
|
|
251
|
+
}),
|
|
252
|
+
[assets]
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
// ── render ──────────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<Container
|
|
259
|
+
width={4}
|
|
260
|
+
padding={4}
|
|
261
|
+
style={{ width: 'calc(100vw - 1.25rem * 2)' }}
|
|
262
|
+
>
|
|
263
|
+
<Stack space={5}>
|
|
264
|
+
{/* ── Header ─────────────────────────────────────────────────── */}
|
|
265
|
+
<Flex align="flex-start" justify="space-between" gap={4} wrap="wrap">
|
|
266
|
+
<Stack space={2} style={{ flex: 1, minWidth: 0 }}>
|
|
267
|
+
<Heading size={2}>Image Optimiser</Heading>
|
|
268
|
+
<Text size={1} muted style={{ wordBreak: 'break-word' }}>
|
|
269
|
+
Converts TIFF images to WebP. Resizes/compresses all images to fit
|
|
270
|
+
within {IMAGE_MAX_WIDTH}px / {MAX_SIZE_MB} MB.
|
|
271
|
+
</Text>
|
|
272
|
+
</Stack>
|
|
273
|
+
<Flex gap={2} align="center" wrap="wrap" style={{ flexShrink: 0 }}>
|
|
274
|
+
<Button
|
|
275
|
+
icon={CogIcon}
|
|
276
|
+
mode="ghost"
|
|
277
|
+
onClick={() => setShowSettings(true)}
|
|
278
|
+
disabled={processingAll}
|
|
279
|
+
/>
|
|
280
|
+
<Button
|
|
281
|
+
text="Refresh"
|
|
282
|
+
mode="ghost"
|
|
283
|
+
onClick={fetchAssets}
|
|
284
|
+
disabled={loading || processingAll}
|
|
285
|
+
/>
|
|
286
|
+
{counts.pending > 0 && (
|
|
287
|
+
<Button
|
|
288
|
+
text={
|
|
289
|
+
processingAll
|
|
290
|
+
? 'Processing…'
|
|
291
|
+
: `Process All (${counts.pending})`
|
|
292
|
+
}
|
|
293
|
+
tone="primary"
|
|
294
|
+
onClick={processAll}
|
|
295
|
+
disabled={processingAll || loading}
|
|
296
|
+
icon={processingAll ? Spinner : undefined}
|
|
297
|
+
/>
|
|
298
|
+
)}
|
|
299
|
+
</Flex>
|
|
300
|
+
</Flex>
|
|
301
|
+
|
|
302
|
+
{/* ── Status badges ──────────────────────────────────────────── */}
|
|
303
|
+
{!loading && assets.length > 0 && (
|
|
304
|
+
<Flex gap={3} wrap="wrap">
|
|
305
|
+
{counts.pending > 0 && (
|
|
306
|
+
<Badge tone="caution">{counts.pending} pending</Badge>
|
|
307
|
+
)}
|
|
308
|
+
{counts.processing > 0 && (
|
|
309
|
+
<Badge tone="primary">{counts.processing} processing</Badge>
|
|
310
|
+
)}
|
|
311
|
+
{counts.done > 0 && (
|
|
312
|
+
<Badge tone="positive">{counts.done} done</Badge>
|
|
313
|
+
)}
|
|
314
|
+
{counts.error > 0 && (
|
|
315
|
+
<Badge tone="critical">{counts.error} failed</Badge>
|
|
316
|
+
)}
|
|
317
|
+
</Flex>
|
|
318
|
+
)}
|
|
319
|
+
|
|
320
|
+
{/* ── Asset list ─────────────────────────────────────────────── */}
|
|
321
|
+
{loading ? (
|
|
322
|
+
<Flex padding={6} justify="center" align="center" gap={3}>
|
|
323
|
+
<Spinner />
|
|
324
|
+
<Text muted>Scanning assets…</Text>
|
|
325
|
+
</Flex>
|
|
326
|
+
) : assets.length === 0 ? (
|
|
327
|
+
<Card padding={5} radius={2} tone="positive" border>
|
|
328
|
+
<Text align="center">All images meet the requirements.</Text>
|
|
329
|
+
</Card>
|
|
330
|
+
) : (
|
|
331
|
+
<Stack space={2}>
|
|
332
|
+
{assets.map((asset) => (
|
|
333
|
+
<AssetCard
|
|
334
|
+
key={asset._id}
|
|
335
|
+
asset={asset}
|
|
336
|
+
onProcess={processAsset}
|
|
337
|
+
settings={settings}
|
|
338
|
+
/>
|
|
339
|
+
))}
|
|
340
|
+
</Stack>
|
|
341
|
+
)}
|
|
342
|
+
</Stack>
|
|
343
|
+
|
|
344
|
+
{/* ── Settings dialog ────────────────────────────────────────── */}
|
|
345
|
+
{showSettings && (
|
|
346
|
+
<Dialog
|
|
347
|
+
id="image-optimiser-settings"
|
|
348
|
+
header="Conversion Settings"
|
|
349
|
+
onClose={() => setShowSettings(false)}
|
|
350
|
+
width={1}
|
|
351
|
+
>
|
|
352
|
+
<Box padding={4}>
|
|
353
|
+
<Stack space={4}>
|
|
354
|
+
<Flex align="center" gap={3}>
|
|
355
|
+
<Switch
|
|
356
|
+
id="png-to-webp"
|
|
357
|
+
checked={settings.pngToWebp}
|
|
358
|
+
onChange={(e) => {
|
|
359
|
+
const checked = e.currentTarget.checked
|
|
360
|
+
updateSettings((s) => ({ ...s, pngToWebp: checked }))
|
|
361
|
+
}}
|
|
362
|
+
/>
|
|
363
|
+
<Label htmlFor="png-to-webp" style={{ cursor: 'pointer' }}>
|
|
364
|
+
Convert PNG → WebP
|
|
365
|
+
</Label>
|
|
366
|
+
</Flex>
|
|
367
|
+
<Flex align="center" gap={3}>
|
|
368
|
+
<Switch
|
|
369
|
+
id="tiff-to-jpg"
|
|
370
|
+
checked={settings.tiffToJpg}
|
|
371
|
+
onChange={(e) => {
|
|
372
|
+
const checked = e.currentTarget.checked
|
|
373
|
+
updateSettings((s) => ({ ...s, tiffToJpg: checked }))
|
|
374
|
+
}}
|
|
375
|
+
/>
|
|
376
|
+
<Label htmlFor="tiff-to-jpg" style={{ cursor: 'pointer' }}>
|
|
377
|
+
Convert TIFF → JPG (instead of WebP)
|
|
378
|
+
</Label>
|
|
379
|
+
</Flex>
|
|
380
|
+
<Text size={1} muted>
|
|
381
|
+
Changes apply on next Refresh.
|
|
382
|
+
</Text>
|
|
383
|
+
</Stack>
|
|
384
|
+
</Box>
|
|
385
|
+
</Dialog>
|
|
386
|
+
)}
|
|
387
|
+
</Container>
|
|
388
|
+
)
|
|
389
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Badge,
|
|
3
|
+
Box,
|
|
4
|
+
Button,
|
|
5
|
+
Card,
|
|
6
|
+
Flex,
|
|
7
|
+
Spinner,
|
|
8
|
+
Stack,
|
|
9
|
+
Text,
|
|
10
|
+
} from '@sanity/ui'
|
|
11
|
+
import {
|
|
12
|
+
type ConversionSettings,
|
|
13
|
+
type ImageAsset,
|
|
14
|
+
type ProcessStatus,
|
|
15
|
+
type Violation,
|
|
16
|
+
IMAGE_MAX_WIDTH,
|
|
17
|
+
IMAGE_MAX_SIZE,
|
|
18
|
+
} from '../../helpers'
|
|
19
|
+
|
|
20
|
+
/** Human-readable size limit for display purposes */
|
|
21
|
+
const MAX_SIZE_MB = IMAGE_MAX_SIZE / 1024 / 1024
|
|
22
|
+
|
|
23
|
+
/** Labels shown on violation badges */
|
|
24
|
+
const VIOLATION_LABELS: Record<Violation, string> = {
|
|
25
|
+
format: 'TIFF → WebP',
|
|
26
|
+
width: `> ${IMAGE_MAX_WIDTH}px`,
|
|
27
|
+
size: `> ${MAX_SIZE_MB} MB`,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Builds a human-readable label for the format violation badge. */
|
|
31
|
+
function formatViolationLabel(settings: ConversionSettings): string {
|
|
32
|
+
const parts: string[] = []
|
|
33
|
+
parts.push(settings.tiffToJpg ? 'TIFF → JPG' : 'TIFF → WebP')
|
|
34
|
+
if (settings.pngToWebp) parts.push('PNG → WebP')
|
|
35
|
+
return parts.join(', ')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Displays a caution badge indicating which constraint was violated. */
|
|
39
|
+
function ViolationBadge({
|
|
40
|
+
type,
|
|
41
|
+
settings,
|
|
42
|
+
}: {
|
|
43
|
+
type: Violation
|
|
44
|
+
settings: ConversionSettings
|
|
45
|
+
}) {
|
|
46
|
+
const label =
|
|
47
|
+
type === 'format' ? formatViolationLabel(settings) : VIOLATION_LABELS[type]
|
|
48
|
+
return (
|
|
49
|
+
<Badge tone="caution" size={1}>
|
|
50
|
+
{label}
|
|
51
|
+
</Badge>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Resolves the visual tone for an asset card based on its processing status. */
|
|
56
|
+
function statusTone(status: ProcessStatus) {
|
|
57
|
+
const map: Record<
|
|
58
|
+
ProcessStatus,
|
|
59
|
+
'positive' | 'critical' | 'primary' | 'default'
|
|
60
|
+
> = {
|
|
61
|
+
done: 'positive',
|
|
62
|
+
error: 'critical',
|
|
63
|
+
processing: 'primary',
|
|
64
|
+
idle: 'default',
|
|
65
|
+
}
|
|
66
|
+
return map[status]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Renders a single asset row with thumbnail, info, badges and action button. */
|
|
70
|
+
export function AssetCard({
|
|
71
|
+
asset,
|
|
72
|
+
onProcess,
|
|
73
|
+
settings,
|
|
74
|
+
}: {
|
|
75
|
+
asset: ImageAsset
|
|
76
|
+
onProcess: (asset: ImageAsset) => void
|
|
77
|
+
settings: ConversionSettings
|
|
78
|
+
}) {
|
|
79
|
+
const isDone = asset.status === 'done' && asset.newUrl
|
|
80
|
+
const thumbUrl = isDone ? asset.newUrl! : asset.url
|
|
81
|
+
const sizeReduction =
|
|
82
|
+
isDone && asset.newSize != null
|
|
83
|
+
? Math.round((1 - asset.newSize / asset.size) * 100)
|
|
84
|
+
: null
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<Card
|
|
88
|
+
key={asset._id}
|
|
89
|
+
tone={statusTone(asset.status)}
|
|
90
|
+
style={{ width: 'calc(100vw - 1.25rem * 2)' }}
|
|
91
|
+
>
|
|
92
|
+
<Flex gap={3} align="center">
|
|
93
|
+
{/* Thumbnail — 64×64 with 2× source for retina */}
|
|
94
|
+
<Box style={{ width: 64, height: 64, flexShrink: 0 }}>
|
|
95
|
+
<img
|
|
96
|
+
src={`${thumbUrl}?w=128&h=128&fit=crop&auto=format`}
|
|
97
|
+
alt=""
|
|
98
|
+
loading="lazy"
|
|
99
|
+
style={{
|
|
100
|
+
width: 64,
|
|
101
|
+
height: 64,
|
|
102
|
+
objectFit: 'cover',
|
|
103
|
+
borderRadius: 4,
|
|
104
|
+
}}
|
|
105
|
+
/>
|
|
106
|
+
</Box>
|
|
107
|
+
|
|
108
|
+
{/* File info + violation badges */}
|
|
109
|
+
<Stack space={2} style={{ flex: 1, minWidth: 0 }}>
|
|
110
|
+
<Box style={{ width: '100%', minWidth: 0 }}>
|
|
111
|
+
<Text size={1} weight="semibold">
|
|
112
|
+
{isDone
|
|
113
|
+
? asset.newFilename || asset.originalFilename || asset._id
|
|
114
|
+
: asset.originalFilename || asset._id}
|
|
115
|
+
</Text>
|
|
116
|
+
</Box>
|
|
117
|
+
{isDone ? (
|
|
118
|
+
<>
|
|
119
|
+
<Text size={1} muted style={{ wordBreak: 'break-word' }}>
|
|
120
|
+
{(asset.size / 1024 / 1024).toFixed(1)} MB →{' '}
|
|
121
|
+
{(asset.newSize! / 1024 / 1024).toFixed(1)} MB
|
|
122
|
+
{sizeReduction !== null && sizeReduction > 0
|
|
123
|
+
? ` (−${sizeReduction}%)`
|
|
124
|
+
: ''}{' '}
|
|
125
|
+
— {asset.newWidth}px wide
|
|
126
|
+
</Text>
|
|
127
|
+
<Flex gap={2} wrap="wrap">
|
|
128
|
+
{asset.newWidth != null && asset.newWidth < asset.width && (
|
|
129
|
+
<Badge tone="positive" size={1}>
|
|
130
|
+
{asset.width}px → {asset.newWidth}px
|
|
131
|
+
</Badge>
|
|
132
|
+
)}
|
|
133
|
+
</Flex>
|
|
134
|
+
</>
|
|
135
|
+
) : (
|
|
136
|
+
<>
|
|
137
|
+
<Flex gap={2} wrap="wrap">
|
|
138
|
+
{asset.violations.map((v) => (
|
|
139
|
+
<ViolationBadge key={v} type={v} settings={settings} />
|
|
140
|
+
))}
|
|
141
|
+
</Flex>
|
|
142
|
+
<Text size={1} muted style={{ wordBreak: 'break-word' }}>
|
|
143
|
+
{(asset.size / 1024 / 1024).toFixed(1)} MB — {asset.width}px
|
|
144
|
+
wide
|
|
145
|
+
</Text>
|
|
146
|
+
</>
|
|
147
|
+
)}
|
|
148
|
+
{asset.status === 'error' && (
|
|
149
|
+
<Text
|
|
150
|
+
size={1}
|
|
151
|
+
style={{
|
|
152
|
+
color: 'var(--card-badge-critical-dot-color)',
|
|
153
|
+
wordBreak: 'break-word',
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
{asset.error}
|
|
157
|
+
</Text>
|
|
158
|
+
)}
|
|
159
|
+
</Stack>
|
|
160
|
+
|
|
161
|
+
{/* Action button — contextual per status */}
|
|
162
|
+
<Box style={{ flexShrink: 0 }}>
|
|
163
|
+
{asset.status === 'idle' && (
|
|
164
|
+
<Button
|
|
165
|
+
text="Process"
|
|
166
|
+
mode="ghost"
|
|
167
|
+
tone="primary"
|
|
168
|
+
onClick={() => onProcess(asset)}
|
|
169
|
+
/>
|
|
170
|
+
)}
|
|
171
|
+
{asset.status === 'processing' && <Spinner />}
|
|
172
|
+
{asset.status === 'done' && <Badge tone="positive">Done</Badge>}
|
|
173
|
+
{asset.status === 'error' && (
|
|
174
|
+
<Button
|
|
175
|
+
text="Retry"
|
|
176
|
+
mode="ghost"
|
|
177
|
+
tone="critical"
|
|
178
|
+
onClick={() => onProcess(asset)}
|
|
179
|
+
/>
|
|
180
|
+
)}
|
|
181
|
+
</Box>
|
|
182
|
+
</Flex>
|
|
183
|
+
</Card>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const { showIncompatiblePluginDialog } = require('@sanity/incompatible-plugin')
|
|
2
|
+
const { name, version, sanityExchangeUrl } = require('./package.json')
|
|
3
|
+
|
|
4
|
+
export default showIncompatiblePluginDialog({
|
|
5
|
+
name: name,
|
|
6
|
+
versions: {
|
|
7
|
+
v3: version,
|
|
8
|
+
v2: undefined,
|
|
9
|
+
},
|
|
10
|
+
sanityExchangeUrl,
|
|
11
|
+
})
|