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.
@@ -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'