sanity-plugin-smart-asset-manager 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/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "sanity-plugin-smart-asset-manager",
3
+ "version": "1.0.0",
4
+ "description": "An advanced asset management plugin for Sanity Studio with smart filtering, size analysis, unused asset detection, broken asset checks, and usage tracking.",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "source": "./src/index.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js",
13
+ "default": "./dist/index.mjs"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "src"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsup src/index.ts --format esm,cjs --dts --external react,react-dom,sanity,@sanity/ui,styled-components",
23
+ "prepublishOnly": "npm run build",
24
+ "format": "prettier --write .",
25
+ "format:check": "prettier --check .",
26
+ "test": "vitest run",
27
+ "test:watch": "vitest",
28
+ "dev": "tsup src/index.ts --format esm,cjs --watch --dts --external react,react-dom,sanity,@sanity/ui,styled-components"
29
+ },
30
+ "keywords": [
31
+ "sanity",
32
+ "sanity-plugin",
33
+ "asset-management",
34
+ "media-library",
35
+ "asset-manager",
36
+ "media-manager",
37
+ "sanity-plugin-smart-asset-manager"
38
+ ],
39
+ "author": "Code-Journey <codejourney.info@gmail.com>",
40
+ "license": "MIT",
41
+ "peerDependencies": {
42
+ "react": "^18.0.0 || ^19.0.0",
43
+ "react-dom": "^18.0.0 || ^19.0.0",
44
+ "sanity": "^3.0.0 || ^4.0.0 || ^5.0.0",
45
+ "styled-components": "^6.0.0"
46
+ },
47
+ "devDependencies": {
48
+ "@sanity/ui": "^3.1.13",
49
+ "@testing-library/jest-dom": "^6.9.1",
50
+ "@testing-library/react": "^16.3.2",
51
+ "@testing-library/user-event": "^14.6.1",
52
+ "@types/react": "^19.2.14",
53
+ "@vitejs/plugin-react": "^5.1.4",
54
+ "jsdom": "^27.0.1",
55
+ "prettier": "^3.5.1",
56
+ "react": "^19.2.4",
57
+ "react-dom": "^19.2.4",
58
+ "sanity": "^5.12.0",
59
+ "styled-components": "^6.1.8",
60
+ "tsup": "^8.5.1",
61
+ "typescript": "^5.9.3",
62
+ "vitest": "^3.2.4"
63
+ },
64
+ "repository": {
65
+ "type": "git",
66
+ "url": "https://github.com/Code-Journey-77/sanity-plugin-smart-asset-manager"
67
+ },
68
+ "bugs": {
69
+ "url": "https://github.com/Code-Journey-77/sanity-plugin-smart-asset-manager/issues"
70
+ },
71
+ "homepage": "https://github.com/Code-Journey-77/sanity-plugin-smart-asset-manager#readme"
72
+ }
Binary file
@@ -0,0 +1,177 @@
1
+ import React from 'react'
2
+ import {Box, Flex, Stack, Text, Badge, Button, Card, Checkbox} from '@sanity/ui'
3
+ import type {Asset} from '@/types'
4
+ import {formatBytes} from '@/utils/formatBytes'
5
+ import {DocumentsIcon, DownloadIcon, PdfIcon, AudioIcon} from '@/components/common/Icons'
6
+ import styled from 'styled-components'
7
+
8
+ const StyledCard = styled(Card)`
9
+ overflow: hidden;
10
+ transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
11
+ cursor: pointer;
12
+ position: relative;
13
+ border: 1px solid
14
+ ${(props) => (props.selected ? 'var(--card-focus-ring-color)' : 'var(--card-border-color)')};
15
+ background-color: ${(props) => (props.selected ? 'var(--card-accent-bg-color)' : 'inherit')};
16
+
17
+ &:hover {
18
+ transform: translateY(-4px);
19
+ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.08);
20
+ border-color: var(--card-focus-ring-color);
21
+ }
22
+ `
23
+
24
+ const ImageContainer = styled(Box)`
25
+ width: 100%;
26
+ aspect-ratio: 1.25;
27
+ background-color: transparent;
28
+ position: relative;
29
+ display: flex;
30
+ align-items: center;
31
+ justify-content: center;
32
+ border-bottom: 1px solid var(--card-border-color);
33
+ overflow: hidden;
34
+ `
35
+
36
+ const SelectOverlay = styled(Box)`
37
+ position: absolute;
38
+ top: 8px;
39
+ left: 8px;
40
+ z-index: 5;
41
+ `
42
+
43
+ const AssetPreview = styled.img`
44
+ width: 100%;
45
+ height: 100%;
46
+ object-fit: cover;
47
+ transition: transform 0.3s ease;
48
+
49
+ ${StyledCard}:hover & {
50
+ transform: scale(1.05);
51
+ }
52
+ `
53
+
54
+ const VideoPreview = styled.video`
55
+ width: 100%;
56
+ height: 100%;
57
+ object-fit: cover;
58
+ background: #000;
59
+ `
60
+
61
+ const ActionBar = styled(Flex)`
62
+ position: absolute;
63
+ bottom: 8px;
64
+ right: 8px;
65
+ background: var(--card-bg-color);
66
+ opacity: 0.95;
67
+ backdrop-filter: blur(4px);
68
+ border-radius: 8px;
69
+ padding: 2px;
70
+ opacity: 0;
71
+ border: 1px solid var(--card-border-color);
72
+ transition: opacity 0.2s ease;
73
+
74
+ ${StyledCard}:hover & {
75
+ opacity: 1;
76
+ }
77
+ `
78
+
79
+ interface AssetCardProps {
80
+ asset: Asset
81
+ onClick: (asset: Asset) => void
82
+ isSelected?: boolean
83
+ onSelect?: (id: string, selected: boolean) => void
84
+ }
85
+
86
+ export const AssetCard: React.FC<AssetCardProps> = ({asset, onClick, isSelected, onSelect}) => {
87
+ const isImage = asset._type === 'sanity.imageAsset'
88
+ const isVideo = asset?.mimeType?.startsWith('video/')
89
+ const isAudio = asset?.mimeType?.startsWith('audio/')
90
+ const isPdf = asset?.extension === 'pdf' || asset?.mimeType === 'application/pdf'
91
+
92
+ const handleCardClick = (e: React.MouseEvent) => {
93
+ if ((e.target as HTMLElement).closest('button')) return
94
+ onClick(asset)
95
+ }
96
+
97
+ return (
98
+ <StyledCard radius={3} onClick={handleCardClick} selected={isSelected}>
99
+ <ImageContainer>
100
+ {onSelect && (
101
+ <SelectOverlay onClick={(e) => e.stopPropagation()}>
102
+ <Checkbox
103
+ checked={isSelected}
104
+ onChange={(e) => onSelect(asset._id, e.target.checked)}
105
+ />
106
+ </SelectOverlay>
107
+ )}
108
+ {isImage ? (
109
+ <AssetPreview
110
+ src={`${asset.url}?w=400&h=320&fit=crop`}
111
+ alt={asset.originalFilename || ''}
112
+ />
113
+ ) : isVideo ? (
114
+ <VideoPreview src={asset.url} muted playsInline />
115
+ ) : (
116
+ <Flex
117
+ direction="column"
118
+ align="center"
119
+ justify="center"
120
+ gap={3}
121
+ style={{height: '100%', width: '100%'}}
122
+ >
123
+ {isPdf ? (
124
+ <PdfIcon
125
+ style={{
126
+ fontSize: '48px',
127
+ color: '#ef4444',
128
+ }}
129
+ />
130
+ ) : isAudio ? (
131
+ <AudioIcon
132
+ style={{
133
+ fontSize: '48px',
134
+ color: '#ef4444',
135
+ }}
136
+ />
137
+ ) : (
138
+ <DocumentsIcon
139
+ style={{
140
+ fontSize: '48px',
141
+ color: '#9ca3af',
142
+ }}
143
+ />
144
+ )}
145
+ <Text size={1} weight="bold" muted>
146
+ {(asset.extension || asset.mimeType?.split('/')[1])?.toUpperCase() || 'OTHER'}
147
+ </Text>
148
+ </Flex>
149
+ )}
150
+ <ActionBar gap={1}>
151
+ <Button
152
+ icon={DownloadIcon}
153
+ mode="bleed"
154
+ fontSize={1}
155
+ padding={2}
156
+ onClick={(e) => {
157
+ e.stopPropagation()
158
+ window.open(asset.url)
159
+ }}
160
+ />
161
+ </ActionBar>
162
+ </ImageContainer>
163
+ <Box padding={3}>
164
+ <Stack space={3}>
165
+ <Flex align="center" direction="column" gap={2}>
166
+ <Text size={1} weight="semibold" textOverflow="ellipsis" style={{flex: 1}}>
167
+ {asset.originalFilename || asset._id.substring(0, 10)}
168
+ </Text>
169
+ <Badge tone={asset.size > 1048576 ? 'caution' : 'default'} fontSize={0}>
170
+ {formatBytes(asset.size)}
171
+ </Badge>
172
+ </Flex>
173
+ </Stack>
174
+ </Box>
175
+ </StyledCard>
176
+ )
177
+ }
@@ -0,0 +1,423 @@
1
+ import React, {useState, useEffect} from 'react'
2
+ import {
3
+ Box,
4
+ Flex,
5
+ Stack,
6
+ Text,
7
+ Badge,
8
+ Button,
9
+ Dialog,
10
+ TextInput,
11
+ Label,
12
+ Card,
13
+ Grid,
14
+ Spinner,
15
+ useToast,
16
+ Tooltip,
17
+ } from '@sanity/ui'
18
+ import type {Asset, ReferencedDocument} from '@/types'
19
+ import {formatBytes} from '@/utils/formatBytes'
20
+ import {DocumentsIcon, DownloadIcon, TrashIcon, PdfIcon, AudioIcon} from '@/components/common/Icons'
21
+ import {type SanityClient} from 'sanity'
22
+ import {useRouter} from 'sanity/router'
23
+ import {getAssetUsage, updateAssetFilename} from '@/utils/assetQueries'
24
+ import styled from 'styled-components'
25
+
26
+ const PreviewContainer = styled(Box)`
27
+ width: 100%;
28
+ border-radius: 8px;
29
+ overflow: hidden;
30
+ display: flex;
31
+ align-items: center;
32
+ justify-content: center;
33
+ min-height: 200px;
34
+ margin-bottom: 16px;
35
+ border: 1px solid var(--card-border-color);
36
+ `
37
+
38
+ const PreviewImage = styled.img`
39
+ max-width: 100%;
40
+ max-height: 400px;
41
+ object-fit: contain;
42
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
43
+ `
44
+
45
+ const VideoPreview = styled.video`
46
+ max-width: 100%;
47
+ max-height: 400px;
48
+ object-fit: contain;
49
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
50
+ background: #000;
51
+ `
52
+
53
+ const PdfPreview = styled.iframe`
54
+ width: 100%;
55
+ height: 400px;
56
+ border: none;
57
+ border-radius: 4px;
58
+ `
59
+
60
+ const AudioPreview = styled.audio`
61
+ width: 100%;
62
+ margin-top: 16px;
63
+ `
64
+
65
+ const UsageCard = styled(Card)`
66
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
67
+ cursor: pointer;
68
+ border: 1px solid var(--card-border-color);
69
+ &:hover {
70
+ background: var(--card-bg-color);
71
+ border-color: var(--card-focus-ring-color);
72
+ box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12);
73
+ }
74
+ `
75
+
76
+ const ViewText = styled(Text)`
77
+ transition: all 0.2s ease;
78
+ color: var(--card-focus-ring-color);
79
+ `
80
+
81
+ interface AssetDetailsDialogProps {
82
+ asset: Asset
83
+ onClose: () => void
84
+ sanityClient: SanityClient
85
+ onUpdate: (id: string, newName: string) => void
86
+ onDelete: (id: string) => void
87
+ }
88
+
89
+ export const AssetDetailsDialog: React.FC<AssetDetailsDialogProps> = ({
90
+ asset,
91
+ onClose,
92
+ sanityClient,
93
+ onUpdate,
94
+ onDelete,
95
+ }) => {
96
+ const [newName, setNewName] = useState(asset.originalFilename || '')
97
+ const [isUpdating, setIsUpdating] = useState(false)
98
+ const [isDeleting, setIsDeleting] = useState(false)
99
+ const [confirmDelete, setConfirmDelete] = useState(false)
100
+ const [usage, setUsage] = useState<ReferencedDocument[]>([])
101
+ const [loadingUsage, setLoadingUsage] = useState(true)
102
+ const toast = useToast()
103
+ const router = useRouter()
104
+
105
+ useEffect(() => {
106
+ async function fetchUsage() {
107
+ setLoadingUsage(true)
108
+ try {
109
+ const results = await getAssetUsage(sanityClient, asset._id)
110
+ setUsage(results)
111
+ } catch (err) {
112
+ console.error(err)
113
+ } finally {
114
+ setLoadingUsage(false)
115
+ }
116
+ }
117
+ fetchUsage()
118
+ }, [asset._id, sanityClient])
119
+
120
+ const handleUpdate = async () => {
121
+ if (!newName.trim()) return
122
+ setIsUpdating(true)
123
+ try {
124
+ await updateAssetFilename(sanityClient, asset._id, newName)
125
+ onUpdate(asset._id, newName)
126
+ toast.push({
127
+ status: 'success',
128
+ title: 'Filename updated',
129
+ })
130
+ } catch (err) {
131
+ toast.push({
132
+ status: 'error',
133
+ title: 'Failed to update filename',
134
+ })
135
+ } finally {
136
+ setIsUpdating(false)
137
+ }
138
+ }
139
+
140
+ const handleDelete = async () => {
141
+ setIsDeleting(true)
142
+ try {
143
+ onDelete(asset._id)
144
+ onClose()
145
+ toast.push({
146
+ status: 'success',
147
+ title: 'Asset deleted',
148
+ })
149
+ } catch (err) {
150
+ toast.push({
151
+ status: 'error',
152
+ title: 'Failed to delete asset',
153
+ })
154
+ } finally {
155
+ setIsDeleting(false)
156
+ setConfirmDelete(false)
157
+ }
158
+ }
159
+
160
+ const handleDocClick = (docId: string, docType: string) => {
161
+ if (router && typeof router.navigateIntent === 'function') {
162
+ router.navigateIntent('edit', {id: docId, type: docType})
163
+ onClose()
164
+ } else {
165
+ // Fallback for cases where router might not be fully available
166
+ window.location.hash = `intent/edit/id=${docId};type=${docType}`
167
+ onClose()
168
+ }
169
+ }
170
+
171
+ const isImage = asset._type === 'sanity.imageAsset'
172
+ const isVideo = asset?.mimeType?.startsWith('video/')
173
+ const isAudio = asset?.mimeType?.startsWith('audio/')
174
+ const isPdf = asset?.extension === 'pdf' || asset?.mimeType === 'application/pdf'
175
+
176
+ return (
177
+ <Dialog
178
+ id="asset-details-dialog"
179
+ header="Asset Details"
180
+ onClose={onClose}
181
+ width={2}
182
+ footer={
183
+ <Box padding={3}>
184
+ <Flex justify="space-between" align="center">
185
+ <Tooltip
186
+ content={
187
+ <Box padding={2} style={{maxWidth: '350px'}}>
188
+ <Text size={1}>
189
+ {usage.length > 0
190
+ ? 'This asset is being used by documents and cannot be deleted. Remove all references first.'
191
+ : 'Delete this asset permanently'}
192
+ </Text>
193
+ </Box>
194
+ }
195
+ placement="top"
196
+ portal
197
+ >
198
+ <Box>
199
+ <Button
200
+ icon={TrashIcon}
201
+ text="Delete Asset"
202
+ tone="critical"
203
+ mode="ghost"
204
+ onClick={() => setConfirmDelete(true)}
205
+ disabled={usage.length > 0 || loadingUsage}
206
+ />
207
+ </Box>
208
+ </Tooltip>
209
+ <Flex gap={2}>
210
+ <Button text="Close" mode="ghost" onClick={onClose} />
211
+ </Flex>
212
+ </Flex>
213
+ </Box>
214
+ }
215
+ >
216
+ <Box padding={4}>
217
+ {confirmDelete && (
218
+ <Dialog
219
+ id="confirm-asset-delete"
220
+ header="Delete Asset?"
221
+ onClose={() => setConfirmDelete(false)}
222
+ footer={
223
+ <Box padding={3}>
224
+ <Flex gap={2} justify="flex-end">
225
+ <Button text="Cancel" mode="ghost" onClick={() => setConfirmDelete(false)} />
226
+ <Button
227
+ text="Delete Permanently"
228
+ tone="critical"
229
+ onClick={handleDelete}
230
+ loading={isDeleting}
231
+ />
232
+ </Flex>
233
+ </Box>
234
+ }
235
+ >
236
+ <Box padding={4}>
237
+ <Text>
238
+ Are you sure you want to delete{' '}
239
+ <strong>{asset.originalFilename || 'this asset'}</strong>? This action cannot be
240
+ undone.
241
+ </Text>
242
+ </Box>
243
+ </Dialog>
244
+ )}
245
+ <Grid columns={[1, 1, 2]} gap={4}>
246
+ <Stack space={4}>
247
+ <PreviewContainer>
248
+ {isImage ? (
249
+ <PreviewImage src={`${asset.url}?w=800`} alt="" />
250
+ ) : isVideo ? (
251
+ <VideoPreview src={asset.url} controls muted playsInline />
252
+ ) : isPdf ? (
253
+ <PdfPreview src={asset.url} title="PDF Preview" />
254
+ ) : isAudio ? (
255
+ <Flex direction="column" align="center" gap={4} style={{width: '100%'}}>
256
+ <AudioIcon style={{fontSize: '64px', color: '#ef4444'}} />
257
+ <AudioPreview src={asset.url} controls />
258
+ </Flex>
259
+ ) : (
260
+ <Flex
261
+ direction="column"
262
+ align="center"
263
+ justify="center"
264
+ gap={3}
265
+ style={{height: '300px', width: '100%'}}
266
+ >
267
+ {isPdf ? (
268
+ <PdfIcon style={{fontSize: '80px', color: '#ef4444'}} />
269
+ ) : isAudio ? (
270
+ <AudioIcon style={{fontSize: '80px', color: '#ef4444'}} />
271
+ ) : (
272
+ <DocumentsIcon
273
+ style={{
274
+ fontSize: '80px',
275
+ color: '#9ca3af',
276
+ }}
277
+ />
278
+ )}
279
+ </Flex>
280
+ )}
281
+ </PreviewContainer>
282
+
283
+ <Stack space={3}>
284
+ <Box>
285
+ <Label size={1}>Original Filename</Label>
286
+ <Flex gap={2} marginTop={2}>
287
+ <Box style={{flex: 1}}>
288
+ <TextInput
289
+ value={newName}
290
+ onChange={(e) => setNewName(e.currentTarget.value)}
291
+ placeholder="Enter filename..."
292
+ />
293
+ </Box>
294
+ <Button
295
+ text="Save"
296
+ tone="primary"
297
+ onClick={handleUpdate}
298
+ loading={isUpdating}
299
+ disabled={newName === asset.originalFilename}
300
+ />
301
+ </Flex>
302
+ </Box>
303
+
304
+ <Card padding={3} border radius={2}>
305
+ <Stack space={3}>
306
+ <Text weight="bold" size={1}>
307
+ File Information
308
+ </Text>
309
+ <Stack space={3}>
310
+ <Flex align="flex-start" gap={3}>
311
+ <Box style={{minWidth: '85px'}}>
312
+ <Text size={1} muted>
313
+ ID:
314
+ </Text>
315
+ </Box>
316
+ <Box flex={1}>
317
+ <Text size={1} style={{wordBreak: 'break-all'}}>
318
+ {asset._id}
319
+ </Text>
320
+ </Box>
321
+ </Flex>
322
+
323
+ <Flex align="center" gap={3}>
324
+ <Box style={{minWidth: '85px'}}>
325
+ <Text size={1} muted>
326
+ Type:
327
+ </Text>
328
+ </Box>
329
+ <Box flex={1}>
330
+ <Badge tone="primary" mode="outline" fontSize={0}>
331
+ {(asset.extension || asset.mimeType?.split('/')[1])?.toUpperCase() ||
332
+ 'OTHER'}
333
+ </Badge>
334
+ </Box>
335
+ </Flex>
336
+
337
+ <Flex align="center" gap={3}>
338
+ <Box style={{minWidth: '85px'}}>
339
+ <Text size={1} muted>
340
+ Size:
341
+ </Text>
342
+ </Box>
343
+ <Box flex={1}>
344
+ <Text size={1}>{formatBytes(asset.size)}</Text>
345
+ </Box>
346
+ </Flex>
347
+
348
+ {asset.metadata?.dimensions && (
349
+ <Flex align="center" gap={3}>
350
+ <Box style={{minWidth: '85px'}}>
351
+ <Text size={1} muted>
352
+ Dimensions:
353
+ </Text>
354
+ </Box>
355
+ <Box flex={1}>
356
+ <Text size={1}>
357
+ {asset.metadata.dimensions.width} × {asset.metadata.dimensions.height}{' '}
358
+ px
359
+ </Text>
360
+ </Box>
361
+ </Flex>
362
+ )}
363
+ </Stack>
364
+ <Button
365
+ icon={DownloadIcon}
366
+ text="Download File"
367
+ mode="ghost"
368
+ onClick={() => window.open(asset.url)}
369
+ />
370
+ </Stack>
371
+ </Card>
372
+ </Stack>
373
+ </Stack>
374
+
375
+ <Stack space={4}>
376
+ <Text weight="bold" size={1}>
377
+ Usage in Documents
378
+ </Text>
379
+ {loadingUsage ? (
380
+ <Flex align="center" justify="center" style={{padding: '40px'}}>
381
+ <Spinner />
382
+ </Flex>
383
+ ) : usage?.length > 0 ? (
384
+ <Stack space={2} style={{maxHeight: '500px', overflowY: 'auto'}}>
385
+ {usage?.map((doc) => (
386
+ <UsageCard
387
+ key={doc._id}
388
+ padding={3}
389
+ border
390
+ radius={2}
391
+ onClick={() => handleDocClick(doc._id, doc._type)}
392
+ >
393
+ <Flex align="center" justify="space-between" gap={3}>
394
+ <Flex direction="column" align="flex-start" gap={2}>
395
+ <Text size={1} weight="semibold">
396
+ {doc.title}
397
+ </Text>
398
+ <Badge tone="caution" fontSize={0}>
399
+ {doc._type}
400
+ </Badge>
401
+ </Flex>
402
+ <Flex align="center" gap={3}>
403
+ <ViewText size={0} weight="bold" className="view-text">
404
+ VIEW →
405
+ </ViewText>
406
+ </Flex>
407
+ </Flex>
408
+ </UsageCard>
409
+ ))}
410
+ </Stack>
411
+ ) : (
412
+ <Card padding={4} border radius={2} tone="transparent" style={{textAlign: 'center'}}>
413
+ <Text size={1} muted>
414
+ No references found for this asset.
415
+ </Text>
416
+ </Card>
417
+ )}
418
+ </Stack>
419
+ </Grid>
420
+ </Box>
421
+ </Dialog>
422
+ )
423
+ }