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/LICENSE +21 -0
- package/README.md +109 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +1356 -0
- package/dist/index.mjs +1336 -0
- package/package.json +72 -0
- package/src/assets/images/all-assets.png +0 -0
- package/src/assets/images/details-popup.png +0 -0
- package/src/assets/images/size-analyzer.png +0 -0
- package/src/assets/images/unused-assets.png +0 -0
- package/src/components/AssetCard.tsx +177 -0
- package/src/components/AssetDetailsDialog.tsx +423 -0
- package/src/components/SmartAssetManagerTool.tsx +284 -0
- package/src/components/TopToolbar.tsx +129 -0
- package/src/components/__tests__/AssetCard.test.tsx +64 -0
- package/src/components/common/Icons.tsx +239 -0
- package/src/components/tabs/SizeAnalyzer.tsx +142 -0
- package/src/components/tabs/UnusedAssets.tsx +129 -0
- package/src/components/tabs/__tests__/SizeAnalyzer.test.tsx +73 -0
- package/src/hooks/useAssets.ts +75 -0
- package/src/index.ts +17 -0
- package/src/setupTests.ts +1 -0
- package/src/types/index.ts +29 -0
- package/src/utils/__tests__/formatBytes.test.ts +25 -0
- package/src/utils/assetQueries.ts +59 -0
- package/src/utils/formatBytes.ts +10 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
export type IconProps = React.SVGProps<SVGSVGElement>
|
|
4
|
+
|
|
5
|
+
export const SearchIcon = (props: IconProps) => (
|
|
6
|
+
<svg
|
|
7
|
+
width="1em"
|
|
8
|
+
height="1em"
|
|
9
|
+
viewBox="0 0 24 24"
|
|
10
|
+
fill="none"
|
|
11
|
+
stroke="currentColor"
|
|
12
|
+
strokeWidth="2"
|
|
13
|
+
strokeLinecap="round"
|
|
14
|
+
strokeLinejoin="round"
|
|
15
|
+
{...props}
|
|
16
|
+
>
|
|
17
|
+
<circle cx="11" cy="11" r="8"></circle>
|
|
18
|
+
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
|
19
|
+
</svg>
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
export const TrashIcon = (props: IconProps) => (
|
|
23
|
+
<svg
|
|
24
|
+
width="1em"
|
|
25
|
+
height="1em"
|
|
26
|
+
viewBox="0 0 24 24"
|
|
27
|
+
fill="none"
|
|
28
|
+
stroke="currentColor"
|
|
29
|
+
strokeWidth="2"
|
|
30
|
+
strokeLinecap="round"
|
|
31
|
+
strokeLinejoin="round"
|
|
32
|
+
{...props}
|
|
33
|
+
>
|
|
34
|
+
<polyline points="3 6 5 6 21 6"></polyline>
|
|
35
|
+
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
36
|
+
<line x1="10" y1="11" x2="10" y2="17"></line>
|
|
37
|
+
<line x1="14" y1="11" x2="14" y2="17"></line>
|
|
38
|
+
</svg>
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
export const InfoOutlineIcon = (props: IconProps) => (
|
|
42
|
+
<svg
|
|
43
|
+
width="1em"
|
|
44
|
+
height="1em"
|
|
45
|
+
viewBox="0 0 24 24"
|
|
46
|
+
fill="none"
|
|
47
|
+
stroke="currentColor"
|
|
48
|
+
strokeWidth="2"
|
|
49
|
+
strokeLinecap="round"
|
|
50
|
+
strokeLinejoin="round"
|
|
51
|
+
{...props}
|
|
52
|
+
>
|
|
53
|
+
<circle cx="12" cy="12" r="10"></circle>
|
|
54
|
+
<line x1="12" y1="16" x2="12" y2="12"></line>
|
|
55
|
+
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
|
56
|
+
</svg>
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
export const WarningIcon = (props: IconProps) => (
|
|
60
|
+
<svg
|
|
61
|
+
width="1em"
|
|
62
|
+
height="1em"
|
|
63
|
+
viewBox="0 0 24 24"
|
|
64
|
+
fill="none"
|
|
65
|
+
stroke="currentColor"
|
|
66
|
+
strokeWidth="2"
|
|
67
|
+
strokeLinecap="round"
|
|
68
|
+
strokeLinejoin="round"
|
|
69
|
+
{...props}
|
|
70
|
+
>
|
|
71
|
+
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
|
|
72
|
+
<line x1="12" y1="9" x2="12" y2="13"></line>
|
|
73
|
+
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
|
74
|
+
</svg>
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
export const LinkIcon = (props: IconProps) => (
|
|
78
|
+
<svg
|
|
79
|
+
width="1em"
|
|
80
|
+
height="1em"
|
|
81
|
+
viewBox="0 0 24 24"
|
|
82
|
+
fill="none"
|
|
83
|
+
stroke="currentColor"
|
|
84
|
+
strokeWidth="2"
|
|
85
|
+
strokeLinecap="round"
|
|
86
|
+
strokeLinejoin="round"
|
|
87
|
+
{...props}
|
|
88
|
+
>
|
|
89
|
+
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
|
90
|
+
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
|
91
|
+
</svg>
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
export const ResetIcon = (props: IconProps) => (
|
|
95
|
+
<svg
|
|
96
|
+
width="1em"
|
|
97
|
+
height="1em"
|
|
98
|
+
viewBox="0 0 24 24"
|
|
99
|
+
fill="none"
|
|
100
|
+
stroke="currentColor"
|
|
101
|
+
strokeWidth="2"
|
|
102
|
+
strokeLinecap="round"
|
|
103
|
+
strokeLinejoin="round"
|
|
104
|
+
{...props}
|
|
105
|
+
>
|
|
106
|
+
<path d="M1 4v6h6"></path>
|
|
107
|
+
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
|
|
108
|
+
</svg>
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
export const DocumentsIcon = (props: IconProps) => (
|
|
112
|
+
<svg
|
|
113
|
+
width="1em"
|
|
114
|
+
height="1em"
|
|
115
|
+
viewBox="0 0 24 24"
|
|
116
|
+
fill="none"
|
|
117
|
+
stroke="currentColor"
|
|
118
|
+
strokeWidth="2"
|
|
119
|
+
strokeLinecap="round"
|
|
120
|
+
strokeLinejoin="round"
|
|
121
|
+
{...props}
|
|
122
|
+
>
|
|
123
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
|
124
|
+
<polyline points="14 2 14 8 20 8"></polyline>
|
|
125
|
+
<line x1="16" y1="13" x2="8" y2="13"></line>
|
|
126
|
+
<line x1="16" y1="17" x2="8" y2="17"></line>
|
|
127
|
+
<polyline points="10 9 9 9 8 9"></polyline>
|
|
128
|
+
</svg>
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
export const DownloadIcon = (props: IconProps) => (
|
|
132
|
+
<svg
|
|
133
|
+
width="1em"
|
|
134
|
+
height="1em"
|
|
135
|
+
viewBox="0 0 24 24"
|
|
136
|
+
fill="none"
|
|
137
|
+
stroke="currentColor"
|
|
138
|
+
strokeWidth="2"
|
|
139
|
+
strokeLinecap="round"
|
|
140
|
+
strokeLinejoin="round"
|
|
141
|
+
{...props}
|
|
142
|
+
>
|
|
143
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
|
144
|
+
<polyline points="7 10 12 15 17 10"></polyline>
|
|
145
|
+
<line x1="12" y1="15" x2="12" y2="3"></line>
|
|
146
|
+
</svg>
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
export const FolderIcon = (props: IconProps) => (
|
|
150
|
+
<svg
|
|
151
|
+
width="1em"
|
|
152
|
+
height="1em"
|
|
153
|
+
viewBox="0 0 24 24"
|
|
154
|
+
fill="none"
|
|
155
|
+
stroke="currentColor"
|
|
156
|
+
strokeWidth="2"
|
|
157
|
+
strokeLinecap="round"
|
|
158
|
+
strokeLinejoin="round"
|
|
159
|
+
{...props}
|
|
160
|
+
>
|
|
161
|
+
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
|
162
|
+
</svg>
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
export const UploadIcon = (props: IconProps) => (
|
|
166
|
+
<svg
|
|
167
|
+
width="1em"
|
|
168
|
+
height="1em"
|
|
169
|
+
viewBox="0 0 24 24"
|
|
170
|
+
fill="none"
|
|
171
|
+
stroke="currentColor"
|
|
172
|
+
strokeWidth="2"
|
|
173
|
+
strokeLinecap="round"
|
|
174
|
+
strokeLinejoin="round"
|
|
175
|
+
{...props}
|
|
176
|
+
>
|
|
177
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
|
178
|
+
<polyline points="17 8 12 3 7 8"></polyline>
|
|
179
|
+
<line x1="12" y1="3" x2="12" y2="15"></line>
|
|
180
|
+
</svg>
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
export const PdfIcon = (props: IconProps) => (
|
|
184
|
+
<svg
|
|
185
|
+
width="1em"
|
|
186
|
+
height="1em"
|
|
187
|
+
viewBox="0 0 24 24"
|
|
188
|
+
fill="none"
|
|
189
|
+
stroke="currentColor"
|
|
190
|
+
strokeWidth="2"
|
|
191
|
+
strokeLinecap="round"
|
|
192
|
+
strokeLinejoin="round"
|
|
193
|
+
{...props}
|
|
194
|
+
>
|
|
195
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
|
196
|
+
<polyline points="14 2 14 8 20 8"></polyline>
|
|
197
|
+
<path d="M9 15h3a2 2 0 0 0 2-2V13a2 2 0 0 0-2-2H9v4z"></path>
|
|
198
|
+
<line x1="9" y1="11" x2="9" y2="17"></line>
|
|
199
|
+
</svg>
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
export const AudioIcon = (props: IconProps) => (
|
|
203
|
+
<svg
|
|
204
|
+
width="1em"
|
|
205
|
+
height="1em"
|
|
206
|
+
viewBox="0 0 24 24"
|
|
207
|
+
fill="none"
|
|
208
|
+
stroke="currentColor"
|
|
209
|
+
strokeWidth="2"
|
|
210
|
+
strokeLinecap="round"
|
|
211
|
+
strokeLinejoin="round"
|
|
212
|
+
{...props}
|
|
213
|
+
>
|
|
214
|
+
<path d="M9 18V5l12-2v13"></path>
|
|
215
|
+
<circle cx="6" cy="18" r="3"></circle>
|
|
216
|
+
<circle cx="18" cy="16" r="3"></circle>
|
|
217
|
+
</svg>
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
export const SortIcon = (props: IconProps) => (
|
|
221
|
+
<svg
|
|
222
|
+
width="1em"
|
|
223
|
+
height="1em"
|
|
224
|
+
viewBox="0 0 24 24"
|
|
225
|
+
fill="none"
|
|
226
|
+
stroke="currentColor"
|
|
227
|
+
strokeWidth="2"
|
|
228
|
+
strokeLinecap="round"
|
|
229
|
+
strokeLinejoin="round"
|
|
230
|
+
{...props}
|
|
231
|
+
>
|
|
232
|
+
<line x1="11" y1="5" x2="21" y2="5"></line>
|
|
233
|
+
<line x1="11" y1="9" x2="18" y2="9"></line>
|
|
234
|
+
<line x1="11" y1="13" x2="15" y2="13"></line>
|
|
235
|
+
<line x1="3" y1="17" x2="21" y2="17"></line>
|
|
236
|
+
<polyline points="7 5 3 9 7 13"></polyline>
|
|
237
|
+
<line x1="3" y1="9" x2="3" y2="1"></line>
|
|
238
|
+
</svg>
|
|
239
|
+
)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import {Stack, Heading, Card, Box, Flex, Text, Badge, Button} from '@sanity/ui'
|
|
3
|
+
import type {Asset} from '@/types'
|
|
4
|
+
import {formatBytes} from '@/utils/formatBytes'
|
|
5
|
+
import {DocumentsIcon, PdfIcon, AudioIcon, SortIcon} from '@/components/common/Icons'
|
|
6
|
+
import styled from 'styled-components'
|
|
7
|
+
|
|
8
|
+
const TableCard = styled(Card)`
|
|
9
|
+
border: 1px solid var(--card-border-color);
|
|
10
|
+
`
|
|
11
|
+
|
|
12
|
+
const TableHeader = styled(Box)`
|
|
13
|
+
border-bottom: 1px solid var(--card-border-color);
|
|
14
|
+
`
|
|
15
|
+
|
|
16
|
+
const TableRow = styled(Flex)<{$isLast?: boolean}>`
|
|
17
|
+
border-bottom: ${(props) => (props.$isLast ? 'none' : '1px solid var(--card-border-color)')};
|
|
18
|
+
transition: background 0.2s ease;
|
|
19
|
+
&:hover {
|
|
20
|
+
background: var(--card-bg-color);
|
|
21
|
+
filter: brightness(0.95);
|
|
22
|
+
}
|
|
23
|
+
`
|
|
24
|
+
|
|
25
|
+
const Thumbnail = styled.img`
|
|
26
|
+
width: 40px;
|
|
27
|
+
height: 40px;
|
|
28
|
+
object-fit: cover;
|
|
29
|
+
border-radius: 4px;
|
|
30
|
+
background-color: var(--card-border-color);
|
|
31
|
+
`
|
|
32
|
+
|
|
33
|
+
interface SizeAnalyzerProps {
|
|
34
|
+
assets: Asset[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const SizeAnalyzer: React.FC<SizeAnalyzerProps> = ({assets}) => {
|
|
38
|
+
const [sortOrder, setSortOrder] = React.useState<'desc' | 'asc'>('desc')
|
|
39
|
+
|
|
40
|
+
const sortedAssets = React.useMemo(() => {
|
|
41
|
+
return [...assets].sort((a, b) => {
|
|
42
|
+
return sortOrder === 'desc' ? b.size - a.size : a.size - b.size
|
|
43
|
+
})
|
|
44
|
+
}, [assets, sortOrder])
|
|
45
|
+
|
|
46
|
+
const toggleSort = () => {
|
|
47
|
+
setSortOrder((prev) => (prev === 'desc' ? 'asc' : 'desc'))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<Stack space={4}>
|
|
52
|
+
<Heading size={1}>Image Size Analysis</Heading>
|
|
53
|
+
<TableCard border radius={2}>
|
|
54
|
+
<TableHeader padding={2}>
|
|
55
|
+
<Flex align="center" gap={3}>
|
|
56
|
+
<Box style={{flex: 1}} paddingLeft={3}>
|
|
57
|
+
<Text size={1} weight="bold">
|
|
58
|
+
File
|
|
59
|
+
</Text>
|
|
60
|
+
</Box>
|
|
61
|
+
<Box style={{width: '120px'}}>
|
|
62
|
+
<Text size={1} weight="bold">
|
|
63
|
+
Dimensions
|
|
64
|
+
</Text>
|
|
65
|
+
</Box>
|
|
66
|
+
<Box style={{width: '140px'}}>
|
|
67
|
+
<Flex align="center" gap={1}>
|
|
68
|
+
<Text size={1} weight="bold">
|
|
69
|
+
Weight
|
|
70
|
+
</Text>
|
|
71
|
+
<Button
|
|
72
|
+
icon={SortIcon}
|
|
73
|
+
mode="bleed"
|
|
74
|
+
padding={2}
|
|
75
|
+
onClick={toggleSort}
|
|
76
|
+
fontSize={1}
|
|
77
|
+
title={`Sort by weight ${sortOrder === 'desc' ? 'Ascending' : 'Descending'}`}
|
|
78
|
+
style={{transform: sortOrder === 'asc' ? 'rotate(180deg)' : 'none'}}
|
|
79
|
+
/>
|
|
80
|
+
</Flex>
|
|
81
|
+
</Box>
|
|
82
|
+
</Flex>
|
|
83
|
+
</TableHeader>
|
|
84
|
+
{sortedAssets.slice(0, 50).map((asset, i) => (
|
|
85
|
+
<TableRow
|
|
86
|
+
key={asset._id}
|
|
87
|
+
padding={3}
|
|
88
|
+
align="center"
|
|
89
|
+
gap={3}
|
|
90
|
+
$isLast={i === (sortedAssets.length < 50 ? sortedAssets.length - 1 : 49)}
|
|
91
|
+
>
|
|
92
|
+
{asset._type === 'sanity.imageAsset' ? (
|
|
93
|
+
<Thumbnail src={`${asset.url}?w=40&h=40&fit=crop`} alt="" />
|
|
94
|
+
) : (
|
|
95
|
+
<Box
|
|
96
|
+
style={{
|
|
97
|
+
width: '40px',
|
|
98
|
+
height: '40px',
|
|
99
|
+
display: 'flex',
|
|
100
|
+
alignItems: 'center',
|
|
101
|
+
justifyContent: 'center',
|
|
102
|
+
background: 'var(--card-border-color)',
|
|
103
|
+
borderRadius: '4px',
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
{asset.mimeType?.startsWith('video/') ? (
|
|
107
|
+
<DocumentsIcon style={{fontSize: '20px', color: '#3b82f6'}} />
|
|
108
|
+
) : asset.mimeType?.startsWith('audio/') ? (
|
|
109
|
+
<AudioIcon style={{fontSize: '20px', color: '#ef4444'}} />
|
|
110
|
+
) : asset.extension === 'pdf' || asset.mimeType === 'application/pdf' ? (
|
|
111
|
+
<PdfIcon style={{fontSize: '20px', color: '#ef4444'}} />
|
|
112
|
+
) : (
|
|
113
|
+
<DocumentsIcon style={{fontSize: '20px', color: '#9ca3af'}} />
|
|
114
|
+
)}
|
|
115
|
+
</Box>
|
|
116
|
+
)}
|
|
117
|
+
<Box style={{flex: 1}}>
|
|
118
|
+
<Text size={1} weight="semibold">
|
|
119
|
+
{asset.originalFilename || asset._id}
|
|
120
|
+
</Text>
|
|
121
|
+
</Box>
|
|
122
|
+
<Box style={{width: '120px'}}>
|
|
123
|
+
<Text size={1}>
|
|
124
|
+
{asset.metadata?.dimensions?.width || '—'} x{' '}
|
|
125
|
+
{asset.metadata?.dimensions?.height || '—'}
|
|
126
|
+
</Text>
|
|
127
|
+
</Box>
|
|
128
|
+
<Box style={{width: '140px'}}>
|
|
129
|
+
<Badge
|
|
130
|
+
tone={
|
|
131
|
+
asset.size > 1048576 ? 'critical' : asset.size > 500000 ? 'caution' : 'positive'
|
|
132
|
+
}
|
|
133
|
+
>
|
|
134
|
+
{formatBytes(asset.size)}
|
|
135
|
+
</Badge>
|
|
136
|
+
</Box>
|
|
137
|
+
</TableRow>
|
|
138
|
+
))}
|
|
139
|
+
</TableCard>
|
|
140
|
+
</Stack>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import React, {useState} from 'react'
|
|
2
|
+
import {Stack, Flex, Heading, Button, Card, Text, Grid, Dialog, Box, Checkbox} from '@sanity/ui'
|
|
3
|
+
import type {Asset} from '@/types'
|
|
4
|
+
import {TrashIcon} from '@/components/common/Icons'
|
|
5
|
+
import {AssetCard} from '@/components/AssetCard'
|
|
6
|
+
|
|
7
|
+
interface UnusedAssetsProps {
|
|
8
|
+
unusedAssets: Asset[]
|
|
9
|
+
onBulkDelete: (ids: string[]) => void
|
|
10
|
+
onAssetClick: (asset: Asset) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const UnusedAssets: React.FC<UnusedAssetsProps> = ({
|
|
14
|
+
unusedAssets,
|
|
15
|
+
onBulkDelete,
|
|
16
|
+
onAssetClick,
|
|
17
|
+
}) => {
|
|
18
|
+
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
|
19
|
+
const [confirmDelete, setConfirmDelete] = useState<{show: boolean; ids: string[]}>({
|
|
20
|
+
show: false,
|
|
21
|
+
ids: [],
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const handleSelect = (id: string, selected: boolean) => {
|
|
25
|
+
setSelectedIds((prev) => (selected ? [...prev, id] : prev.filter((i) => i !== id)))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const handleSelectAll = (checked: boolean) => {
|
|
29
|
+
setSelectedIds(checked ? unusedAssets.map((a) => a._id) : [])
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const triggerBulkDelete = (ids: string[]) => {
|
|
33
|
+
setConfirmDelete({show: true, ids})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const handleConfirmDelete = () => {
|
|
37
|
+
onBulkDelete(confirmDelete.ids)
|
|
38
|
+
setSelectedIds((prev) => prev.filter((id) => !confirmDelete.ids.includes(id)))
|
|
39
|
+
setConfirmDelete({show: false, ids: []})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Stack space={4}>
|
|
44
|
+
<Flex align="center" justify="space-between">
|
|
45
|
+
<Stack space={3}>
|
|
46
|
+
<Heading size={1}>Bulk Deletion of Unused Assets</Heading>
|
|
47
|
+
{unusedAssets.length > 0 && (
|
|
48
|
+
<Flex align="center" gap={3}>
|
|
49
|
+
<Checkbox
|
|
50
|
+
id="select-all-unused"
|
|
51
|
+
checked={selectedIds.length === unusedAssets.length && unusedAssets.length > 0}
|
|
52
|
+
indeterminate={selectedIds.length > 0 && selectedIds.length < unusedAssets.length}
|
|
53
|
+
onChange={(e) => handleSelectAll(e.currentTarget.checked)}
|
|
54
|
+
/>
|
|
55
|
+
<Text size={1} weight="semibold" muted>
|
|
56
|
+
{selectedIds.length > 0 ? `${selectedIds.length} Selected` : 'Select All'}
|
|
57
|
+
</Text>
|
|
58
|
+
</Flex>
|
|
59
|
+
)}
|
|
60
|
+
</Stack>
|
|
61
|
+
|
|
62
|
+
<Flex gap={2}>
|
|
63
|
+
{selectedIds.length > 0 && (
|
|
64
|
+
<Button
|
|
65
|
+
icon={TrashIcon}
|
|
66
|
+
tone="critical"
|
|
67
|
+
text={`Delete Selected (${selectedIds.length})`}
|
|
68
|
+
onClick={() => triggerBulkDelete(selectedIds)}
|
|
69
|
+
/>
|
|
70
|
+
)}
|
|
71
|
+
{unusedAssets.length > 0 && selectedIds.length !== unusedAssets.length && (
|
|
72
|
+
<Button
|
|
73
|
+
mode="ghost"
|
|
74
|
+
icon={TrashIcon}
|
|
75
|
+
tone="critical"
|
|
76
|
+
text={`Delete All Unused (${unusedAssets.length})`}
|
|
77
|
+
onClick={() => triggerBulkDelete(unusedAssets.map((a) => a._id))}
|
|
78
|
+
/>
|
|
79
|
+
)}
|
|
80
|
+
</Flex>
|
|
81
|
+
</Flex>
|
|
82
|
+
|
|
83
|
+
{unusedAssets.length === 0 ? (
|
|
84
|
+
<Card padding={5} border radius={3}>
|
|
85
|
+
<Text align="center">No unused assets found.</Text>
|
|
86
|
+
</Card>
|
|
87
|
+
) : (
|
|
88
|
+
<Grid columns={[2, 3, 4, 5, 6]} gap={3}>
|
|
89
|
+
{unusedAssets.map((asset) => (
|
|
90
|
+
<AssetCard
|
|
91
|
+
key={asset._id}
|
|
92
|
+
asset={asset}
|
|
93
|
+
onClick={onAssetClick}
|
|
94
|
+
isSelected={selectedIds.includes(asset._id)}
|
|
95
|
+
onSelect={handleSelect}
|
|
96
|
+
/>
|
|
97
|
+
))}
|
|
98
|
+
</Grid>
|
|
99
|
+
)}
|
|
100
|
+
|
|
101
|
+
{confirmDelete.show && (
|
|
102
|
+
<Dialog
|
|
103
|
+
id="confirm-bulk-delete"
|
|
104
|
+
header={`Delete ${confirmDelete.ids.length} Asset(s)?`}
|
|
105
|
+
onClose={() => setConfirmDelete({show: false, ids: []})}
|
|
106
|
+
footer={
|
|
107
|
+
<Box padding={3}>
|
|
108
|
+
<Flex gap={2} justify="flex-end">
|
|
109
|
+
<Button
|
|
110
|
+
text="Cancel"
|
|
111
|
+
mode="ghost"
|
|
112
|
+
onClick={() => setConfirmDelete({show: false, ids: []})}
|
|
113
|
+
/>
|
|
114
|
+
<Button text="Delete Permanently" tone="critical" onClick={handleConfirmDelete} />
|
|
115
|
+
</Flex>
|
|
116
|
+
</Box>
|
|
117
|
+
}
|
|
118
|
+
>
|
|
119
|
+
<Box padding={4}>
|
|
120
|
+
<Text>
|
|
121
|
+
Are you sure you want to delete <strong>{confirmDelete.ids.length}</strong> asset(s)?
|
|
122
|
+
This action cannot be undone and will permanently remove them from your project.
|
|
123
|
+
</Text>
|
|
124
|
+
</Box>
|
|
125
|
+
</Dialog>
|
|
126
|
+
)}
|
|
127
|
+
</Stack>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {render, screen, fireEvent} from '@testing-library/react'
|
|
2
|
+
import {describe, it, expect} from 'vitest'
|
|
3
|
+
import {SizeAnalyzer} from '../SizeAnalyzer'
|
|
4
|
+
import {ThemeProvider} from '@sanity/ui'
|
|
5
|
+
import {buildTheme} from '@sanity/ui/theme'
|
|
6
|
+
import React from 'react'
|
|
7
|
+
|
|
8
|
+
const mockAssets = [
|
|
9
|
+
{
|
|
10
|
+
_id: 'asset-1',
|
|
11
|
+
_type: 'sanity.imageAsset',
|
|
12
|
+
url: 'https://example.com/small.jpg',
|
|
13
|
+
originalFilename: 'small.jpg',
|
|
14
|
+
size: 1024, // 1 KB
|
|
15
|
+
extension: 'jpg',
|
|
16
|
+
mimeType: 'image/jpeg',
|
|
17
|
+
metadata: {dimensions: {width: 10, height: 10}},
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
_id: 'asset-2',
|
|
21
|
+
_type: 'sanity.imageAsset',
|
|
22
|
+
url: 'https://example.com/medium.jpg',
|
|
23
|
+
originalFilename: 'medium.jpg',
|
|
24
|
+
size: 512000, // ~500 KB
|
|
25
|
+
extension: 'jpg',
|
|
26
|
+
mimeType: 'image/jpeg',
|
|
27
|
+
metadata: {dimensions: {width: 100, height: 100}},
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
_id: 'asset-3',
|
|
31
|
+
_type: 'sanity.imageAsset',
|
|
32
|
+
url: 'https://example.com/large.jpg',
|
|
33
|
+
originalFilename: 'large.jpg',
|
|
34
|
+
size: 2048576, // 2 MB
|
|
35
|
+
extension: 'jpg',
|
|
36
|
+
mimeType: 'image/jpeg',
|
|
37
|
+
metadata: {dimensions: {width: 1000, height: 1000}},
|
|
38
|
+
},
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
describe('SizeAnalyzer', () => {
|
|
42
|
+
const renderWithTheme = (ui: React.ReactElement) => {
|
|
43
|
+
return render(<ThemeProvider theme={buildTheme()}>{ui}</ThemeProvider>)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
it('renders correctly with multiple assets', () => {
|
|
47
|
+
renderWithTheme(<SizeAnalyzer assets={mockAssets} />)
|
|
48
|
+
|
|
49
|
+
expect(screen.getByText('small.jpg')).toBeDefined()
|
|
50
|
+
expect(screen.getByText('medium.jpg')).toBeDefined()
|
|
51
|
+
expect(screen.getByText('large.jpg')).toBeDefined()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('sorts assets by size correctly', () => {
|
|
55
|
+
renderWithTheme(<SizeAnalyzer assets={mockAssets} />)
|
|
56
|
+
|
|
57
|
+
// Initially descending by default
|
|
58
|
+
// We expect large first, then medium, then small
|
|
59
|
+
const renderedNames = screen.getAllByText(/jpg$/).map((el) => el.textContent)
|
|
60
|
+
expect(renderedNames).toEqual(['large.jpg', 'medium.jpg', 'small.jpg'])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('toggles sort order', () => {
|
|
64
|
+
renderWithTheme(<SizeAnalyzer assets={mockAssets} />)
|
|
65
|
+
|
|
66
|
+
const sortButton = screen.getByTitle(/Sort by weight/)
|
|
67
|
+
fireEvent.click(sortButton)
|
|
68
|
+
|
|
69
|
+
// Now should be ascending
|
|
70
|
+
const renderedNames = screen.getAllByText(/jpg$/).map((el) => el.textContent)
|
|
71
|
+
expect(renderedNames).toEqual(['small.jpg', 'medium.jpg', 'large.jpg'])
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {useState, useEffect} from 'react'
|
|
2
|
+
import type {SanityClient} from 'sanity'
|
|
3
|
+
import type {Asset, AssetTypeFilter, SortOrder} from '@/types'
|
|
4
|
+
|
|
5
|
+
export function useAssets(
|
|
6
|
+
sanityClient: SanityClient,
|
|
7
|
+
searchQuery: string,
|
|
8
|
+
sortBy: SortOrder,
|
|
9
|
+
type: AssetTypeFilter,
|
|
10
|
+
offset: number,
|
|
11
|
+
limit: number,
|
|
12
|
+
) {
|
|
13
|
+
const [assets, setAssets] = useState<Asset[]>([])
|
|
14
|
+
const [loading, setLoading] = useState(true)
|
|
15
|
+
const [total, setTotal] = useState(0)
|
|
16
|
+
|
|
17
|
+
const [refreshSeed, setRefreshSeed] = useState(0)
|
|
18
|
+
const refreshAssets = () => setRefreshSeed((s) => s + 1)
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
async function fetchAssets() {
|
|
22
|
+
setLoading(true)
|
|
23
|
+
try {
|
|
24
|
+
let filterParts = ['_type in ["sanity.imageAsset", "sanity.fileAsset"]']
|
|
25
|
+
|
|
26
|
+
if (type === 'image') {
|
|
27
|
+
filterParts.push('_type == "sanity.imageAsset"')
|
|
28
|
+
} else if (type === 'file') {
|
|
29
|
+
filterParts.push(
|
|
30
|
+
'_type == "sanity.fileAsset" && !(mimeType match "video/*") && !(mimeType match "audio/*")',
|
|
31
|
+
)
|
|
32
|
+
} else if (type === 'video') {
|
|
33
|
+
filterParts.push('_type == "sanity.fileAsset" && mimeType match "video/*"')
|
|
34
|
+
} else if (type === 'audio') {
|
|
35
|
+
filterParts.push('_type == "sanity.fileAsset" && mimeType match "audio/*"')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (searchQuery) {
|
|
39
|
+
filterParts.push('(originalFilename match $searchQuery || _id match $searchQuery)')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const filter = filterParts.join(' && ')
|
|
43
|
+
const countQuery = `count(*[${filter}])`
|
|
44
|
+
const query = `*[${filter}] | order(${sortBy} desc) [${offset}...${offset + limit}] {
|
|
45
|
+
_id,
|
|
46
|
+
_type,
|
|
47
|
+
url,
|
|
48
|
+
extension,
|
|
49
|
+
size,
|
|
50
|
+
mimeType,
|
|
51
|
+
originalFilename,
|
|
52
|
+
metadata {
|
|
53
|
+
dimensions
|
|
54
|
+
}
|
|
55
|
+
}`
|
|
56
|
+
|
|
57
|
+
const [totalCount, results] = await Promise.all([
|
|
58
|
+
sanityClient.fetch(countQuery, {searchQuery: `*${searchQuery}*`}),
|
|
59
|
+
sanityClient.fetch(query, {searchQuery: `*${searchQuery}*`}),
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
setTotal(totalCount)
|
|
63
|
+
setAssets(results)
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error('Error fetching assets:', error)
|
|
66
|
+
} finally {
|
|
67
|
+
setLoading(false)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fetchAssets()
|
|
72
|
+
}, [sanityClient, searchQuery, sortBy, type, refreshSeed, offset, limit])
|
|
73
|
+
|
|
74
|
+
return {assets, loading, total, refreshAssets}
|
|
75
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {definePlugin} from 'sanity'
|
|
2
|
+
import {SmartAssetManagerTool} from '@/components/SmartAssetManagerTool'
|
|
3
|
+
import {FolderIcon} from '@/components/common/Icons'
|
|
4
|
+
|
|
5
|
+
export const smartAssetManager = definePlugin(() => {
|
|
6
|
+
return {
|
|
7
|
+
name: 'sanity-plugin-smart-asset-manager',
|
|
8
|
+
tools: [
|
|
9
|
+
{
|
|
10
|
+
name: 'smart-asset-manager',
|
|
11
|
+
title: 'Smart Asset Manager',
|
|
12
|
+
component: SmartAssetManagerTool,
|
|
13
|
+
icon: FolderIcon,
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
}
|
|
17
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import '@testing-library/jest-dom'
|