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,284 @@
1
+ import {useState, useMemo, useEffect} from 'react'
2
+ import {Box, Grid, Text, Stack, Flex, Spinner, Card, Button, useToast} from '@sanity/ui'
3
+ import {useClient} from 'sanity'
4
+ import {useAssets} from '@/hooks/useAssets'
5
+ import {findUnusedAssets, deleteAssets} from '@/utils/assetQueries'
6
+ import type {Asset, AssetTab, AssetTypeFilter, SizeFilter, SortOrder} from '@/types'
7
+ import {AssetCard} from '@/components/AssetCard'
8
+ import {AssetDetailsDialog} from '@/components/AssetDetailsDialog'
9
+ import {TopToolbar} from '@/components/TopToolbar'
10
+ import {SizeAnalyzer} from '@/components/tabs/SizeAnalyzer'
11
+ import {UnusedAssets} from '@/components/tabs/UnusedAssets'
12
+ import styled from 'styled-components'
13
+
14
+ const AppContainer = styled(Card)`
15
+ height: 100vh;
16
+ display: flex;
17
+ flex-direction: column;
18
+ `
19
+
20
+ const ScrollableContent = styled(Box)`
21
+ flex: 1;
22
+ overflow-y: auto;
23
+ `
24
+
25
+ const TabNav = styled(Card)`
26
+ z-index: 10;
27
+ `
28
+
29
+ export function SmartAssetManagerTool() {
30
+ const sanityClient = useClient({apiVersion: '2025-02-07'})
31
+ const [activeTab, setActiveTab] = useState<AssetTab>('all')
32
+ const [searchQuery, setSearchQuery] = useState('')
33
+ const [sortBy, setSortBy] = useState<SortOrder>('_createdAt')
34
+ const [assetType, setAssetType] = useState<AssetTypeFilter>('all')
35
+ const [sizeFilter, setSizeFilter] = useState<SizeFilter>('all')
36
+
37
+ const limit = 20
38
+ const [currentPage, setCurrentPage] = useState(1)
39
+ const {assets, loading, total, refreshAssets} = useAssets(
40
+ sanityClient,
41
+ searchQuery,
42
+ sortBy,
43
+ assetType,
44
+ (currentPage - 1) * limit,
45
+ limit,
46
+ )
47
+ const [scanning, setScanning] = useState(false)
48
+ const [isUploading, setIsUploading] = useState(false)
49
+ const toast = useToast()
50
+
51
+ const [unusedAssets, setUnusedAssets] = useState<Asset[]>([])
52
+ const [selectedAsset, setSelectedAsset] = useState<Asset | null>(null)
53
+
54
+ const filteredAssets = useMemo(() => {
55
+ return assets.filter((asset) => {
56
+ if (sizeFilter === 'small') return asset.size < 102400
57
+ if (sizeFilter === 'medium') return asset.size >= 102400 && asset.size < 1048576
58
+ if (sizeFilter === 'large') return asset.size >= 1048576
59
+ return true
60
+ })
61
+ }, [assets, sizeFilter])
62
+
63
+ useEffect(() => {
64
+ setCurrentPage(1)
65
+ }, [searchQuery, assetType, sizeFilter, activeTab])
66
+
67
+ const handleFindUnused = async () => {
68
+ setScanning(true)
69
+ const unused = await findUnusedAssets(sanityClient)
70
+ setUnusedAssets(unused)
71
+ setScanning(false)
72
+ setActiveTab('unused')
73
+ }
74
+
75
+ const handleDeleteAsset = async (id: string | string[]) => {
76
+ const ids = Array.isArray(id) ? id : [id]
77
+ await deleteAssets(sanityClient, ids)
78
+
79
+ refreshAssets()
80
+
81
+ setUnusedAssets((prev) => prev.filter((a) => !ids.includes(a._id)))
82
+
83
+ if (activeTab === 'unused') handleFindUnused()
84
+ }
85
+
86
+ const handleResetFilters = () => {
87
+ setSearchQuery('')
88
+ setAssetType('all')
89
+ setSizeFilter('all')
90
+ setCurrentPage(1)
91
+ }
92
+
93
+ const handleUpload = async (files: FileList) => {
94
+ if (files.length > 5) {
95
+ toast.push({
96
+ status: 'error',
97
+ title: 'Upload limit exceeded',
98
+ description: 'You can only upload up to 5 files at a time.',
99
+ })
100
+ return
101
+ }
102
+
103
+ setIsUploading(true)
104
+ const fileArray = Array.from(files)
105
+ const filenames = fileArray.map((f) => f.name)
106
+
107
+ try {
108
+ // 1. Batch check which files already exist
109
+ const existingAssetNames = await sanityClient.fetch<string[]>(
110
+ `*[_type in ["sanity.imageAsset", "sanity.fileAsset"] && originalFilename in $filenames].originalFilename`,
111
+ {filenames},
112
+ )
113
+
114
+ const filesToUpload = fileArray.filter((f) => !existingAssetNames.includes(f.name))
115
+ const skippedCount = fileArray.length - filesToUpload.length
116
+
117
+ if (filesToUpload.length === 0) {
118
+ setIsUploading(false)
119
+ if (skippedCount > 0) {
120
+ toast.push({
121
+ status: 'info',
122
+ title: 'No new files were added',
123
+ description: `All ${skippedCount} file(s) already exist in your library.`,
124
+ })
125
+ }
126
+ return
127
+ }
128
+
129
+ // 2. Upload only the new files
130
+ const uploadPromises = filesToUpload.map(async (file) => {
131
+ const type = file.type.startsWith('image/') ? 'image' : 'file'
132
+ return sanityClient.assets.upload(type, file, {
133
+ filename: file.name,
134
+ })
135
+ })
136
+
137
+ await Promise.all(uploadPromises)
138
+ await new Promise((resolve) => setTimeout(resolve, 1000))
139
+
140
+ // 3. Refresh and Notify
141
+ refreshAssets()
142
+ setIsUploading(false)
143
+
144
+ toast.push({
145
+ status: 'success',
146
+ title: 'Upload successful',
147
+ description: `Uploaded ${filesToUpload.length} new file(s). ${skippedCount > 0 ? `Skipped ${skippedCount} existing files.` : ''}`,
148
+ })
149
+ } catch (err) {
150
+ console.error('Upload failed:', err)
151
+ setIsUploading(false)
152
+ toast.push({
153
+ status: 'error',
154
+ title: 'Upload failed',
155
+ description: 'An error occurred while uploading your files.',
156
+ })
157
+ }
158
+ }
159
+
160
+ return (
161
+ <AppContainer height="fill">
162
+ {/* Tab Navigation */}
163
+ <TabNav borderBottom padding={2}>
164
+ <Flex gap={1}>
165
+ {[
166
+ {id: 'all', label: 'All Assets'},
167
+ {id: 'analysis', label: 'Size Analyzer'},
168
+ {id: 'unused', label: 'Unused Assets', onClick: handleFindUnused},
169
+ ].map((tab) => (
170
+ <Button
171
+ key={tab.id}
172
+ mode={activeTab === tab.id ? 'default' : 'bleed'}
173
+ padding={3}
174
+ text={tab.label}
175
+ onClick={() => {
176
+ setActiveTab(tab.id as AssetTab)
177
+ if (tab.onClick) tab.onClick()
178
+ }}
179
+ selected={activeTab === tab.id}
180
+ />
181
+ ))}
182
+ </Flex>
183
+ </TabNav>
184
+
185
+ <ScrollableContent>
186
+ {activeTab === 'all' && (
187
+ <TopToolbar
188
+ searchQuery={searchQuery}
189
+ setSearchQuery={setSearchQuery}
190
+ sortBy={sortBy}
191
+ setSortBy={setSortBy}
192
+ assetType={assetType}
193
+ setAssetType={setAssetType}
194
+ sizeFilter={sizeFilter}
195
+ setSizeFilter={setSizeFilter}
196
+ onReset={handleResetFilters}
197
+ onUpload={handleUpload}
198
+ isUploading={isUploading}
199
+ />
200
+ )}
201
+
202
+ <Box padding={4}>
203
+ {loading || scanning ? (
204
+ <Flex align="center" justify="center" style={{minHeight: '400px'}}>
205
+ <Stack space={4}>
206
+ <Flex align="center" justify="center">
207
+ <Spinner muted />
208
+ </Flex>
209
+ <Text size={1} muted>
210
+ Syncing with media library...
211
+ </Text>
212
+ </Stack>
213
+ </Flex>
214
+ ) : (
215
+ <Box>
216
+ {activeTab === 'all' && (
217
+ <Box>
218
+ {filteredAssets.length === 0 ? (
219
+ <Card padding={5} border radius={3} style={{textAlign: 'center'}}>
220
+ <Text muted>No assets found matching your filters.</Text>
221
+ </Card>
222
+ ) : (
223
+ <Grid columns={[2, 3, 4, 5, 6]} gap={3}>
224
+ {filteredAssets.map((asset) => (
225
+ <AssetCard key={asset._id} asset={asset} onClick={setSelectedAsset} />
226
+ ))}
227
+ </Grid>
228
+ )}
229
+
230
+ {total > limit && (
231
+ <Flex justify="center" align="center" gap={3} marginTop={5} marginBottom={2}>
232
+ <Button
233
+ padding={3}
234
+ mode="ghost"
235
+ text="Previous"
236
+ disabled={currentPage === 1 || loading}
237
+ onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
238
+ />
239
+ <Card padding={3} radius={2} border>
240
+ <Text size={1} weight="semibold">
241
+ Page {currentPage} of {Math.ceil(total / limit)}
242
+ </Text>
243
+ </Card>
244
+ <Button
245
+ padding={3}
246
+ mode="ghost"
247
+ text="Next"
248
+ disabled={currentPage >= Math.ceil(total / limit) || loading}
249
+ onClick={() => setCurrentPage((p) => p + 1)}
250
+ />
251
+ </Flex>
252
+ )}
253
+ </Box>
254
+ )}
255
+
256
+ {activeTab === 'analysis' && <SizeAnalyzer assets={assets} />}
257
+
258
+ {activeTab === 'unused' && (
259
+ <UnusedAssets
260
+ unusedAssets={unusedAssets}
261
+ onBulkDelete={handleDeleteAsset}
262
+ onAssetClick={setSelectedAsset}
263
+ />
264
+ )}
265
+ </Box>
266
+ )}
267
+ </Box>
268
+ </ScrollableContent>
269
+
270
+ {selectedAsset && (
271
+ <AssetDetailsDialog
272
+ asset={selectedAsset}
273
+ sanityClient={sanityClient}
274
+ onClose={() => setSelectedAsset(null)}
275
+ onUpdate={(_id, newName) => {
276
+ refreshAssets()
277
+ setSelectedAsset((prev) => (prev ? {...prev, originalFilename: newName} : null))
278
+ }}
279
+ onDelete={handleDeleteAsset}
280
+ />
281
+ )}
282
+ </AppContainer>
283
+ )
284
+ }
@@ -0,0 +1,129 @@
1
+ import React from 'react'
2
+ import {Flex, Box, TextInput, Select, Button, Card} from '@sanity/ui'
3
+ import {SearchIcon, ResetIcon, UploadIcon} from '@/components/common/Icons'
4
+ import type {AssetTypeFilter, SizeFilter, SortOrder} from '@/types'
5
+ import styled from 'styled-components'
6
+
7
+ const StickyCard = styled(Card)`
8
+ position: sticky;
9
+ top: 0;
10
+ z-index: 10;
11
+ `
12
+
13
+ const SearchBox = styled(Box)`
14
+ flex: 1;
15
+ `
16
+
17
+ const FilterBox = styled(Box)`
18
+ width: 140px;
19
+ `
20
+
21
+ interface TopToolbarProps {
22
+ searchQuery: string
23
+ setSearchQuery: (val: string) => void
24
+ sortBy: SortOrder
25
+ setSortBy: (val: SortOrder) => void
26
+ assetType: AssetTypeFilter
27
+ setAssetType: (val: AssetTypeFilter) => void
28
+ sizeFilter: SizeFilter
29
+ setSizeFilter: (val: SizeFilter) => void
30
+ onReset: () => void
31
+ onUpload: (files: FileList) => void
32
+ isUploading: boolean
33
+ }
34
+
35
+ export const TopToolbar: React.FC<TopToolbarProps> = ({
36
+ searchQuery,
37
+ setSearchQuery,
38
+ sortBy,
39
+ setSortBy,
40
+ assetType,
41
+ setAssetType,
42
+ sizeFilter,
43
+ setSizeFilter,
44
+ onReset,
45
+ onUpload,
46
+ isUploading,
47
+ }) => {
48
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
49
+
50
+ const handleUploadClick = () => {
51
+ fileInputRef.current?.click()
52
+ }
53
+
54
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
55
+ if (e.target.files && e.target.files.length > 0) {
56
+ onUpload(e.target.files)
57
+ // Reset input so the same file can be uploaded again
58
+ e.target.value = ''
59
+ }
60
+ }
61
+
62
+ return (
63
+ <StickyCard padding={3} borderBottom>
64
+ <Flex align="center" gap={3}>
65
+ <input
66
+ type="file"
67
+ ref={fileInputRef}
68
+ style={{display: 'none'}}
69
+ onChange={handleFileChange}
70
+ multiple
71
+ accept="image/*,video/*,audio/*,application/*,text/*"
72
+ />
73
+ <SearchBox>
74
+ <TextInput
75
+ icon={SearchIcon}
76
+ placeholder="Search name, extension, ID..."
77
+ value={searchQuery}
78
+ onChange={(e) => setSearchQuery(e.currentTarget.value)}
79
+ fontSize={1}
80
+ />
81
+ </SearchBox>
82
+ <Box>
83
+ <Select
84
+ fontSize={1}
85
+ value={sortBy}
86
+ onChange={(e) => setSortBy(e.currentTarget.value as SortOrder)}
87
+ >
88
+ <option value="_createdAt">Upload Date</option>
89
+ <option value="size">File Size</option>
90
+ <option value="originalFilename">Name A-Z</option>
91
+ </Select>
92
+ </Box>
93
+ <FilterBox>
94
+ <Select
95
+ fontSize={1}
96
+ value={assetType}
97
+ onChange={(e) => setAssetType(e.currentTarget.value as AssetTypeFilter)}
98
+ >
99
+ <option value="all">All Types</option>
100
+ <option value="image">Images</option>
101
+ <option value="video">Videos</option>
102
+ <option value="audio">Audio</option>
103
+ <option value="file">Other Files</option>
104
+ </Select>
105
+ </FilterBox>
106
+ <FilterBox>
107
+ <Select
108
+ fontSize={1}
109
+ value={sizeFilter}
110
+ onChange={(e) => setSizeFilter(e.currentTarget.value as SizeFilter)}
111
+ >
112
+ <option value="all">All Sizes</option>
113
+ <option value="small">&lt; 100 KB</option>
114
+ <option value="medium">100KB - 1MB</option>
115
+ <option value="large">&gt; 1MB</option>
116
+ </Select>
117
+ </FilterBox>
118
+ <Button icon={ResetIcon} mode="ghost" onClick={onReset} title="Reset filters" />
119
+ <Button
120
+ icon={UploadIcon}
121
+ text={isUploading ? 'Uploading...' : 'Upload'}
122
+ tone="primary"
123
+ onClick={handleUploadClick}
124
+ loading={isUploading}
125
+ />
126
+ </Flex>
127
+ </StickyCard>
128
+ )
129
+ }
@@ -0,0 +1,64 @@
1
+ import {render, screen, fireEvent} from '@testing-library/react'
2
+ import {describe, it, expect, vi} from 'vitest'
3
+ import {AssetCard} from '../AssetCard'
4
+ import {ThemeProvider} from '@sanity/ui'
5
+ import {buildTheme} from '@sanity/ui/theme'
6
+ import React from 'react'
7
+
8
+ vi.mock('@/components/common/Icons', () => ({
9
+ DocumentsIcon: () => <div data-testid="documents-icon" />,
10
+ DownloadIcon: () => <div data-testid="download-icon" />,
11
+ PdfIcon: () => <div data-testid="pdf-icon" />,
12
+ AudioIcon: () => <div data-testid="audio-icon" />,
13
+ }))
14
+
15
+ const mockAsset = {
16
+ _id: 'asset-1',
17
+ _type: 'sanity.imageAsset',
18
+ url: 'https://example.com/image.jpg',
19
+ originalFilename: 'test-image.jpg',
20
+ size: 1024,
21
+ extension: 'jpg',
22
+ mimeType: 'image/jpeg',
23
+ metadata: {
24
+ dimensions: {width: 100, height: 100},
25
+ },
26
+ }
27
+
28
+ describe('AssetCard', () => {
29
+ const renderWithTheme = (ui: React.ReactElement) => {
30
+ return render(<ThemeProvider theme={buildTheme()}>{ui}</ThemeProvider>)
31
+ }
32
+
33
+ it('renders correctly with image asset', () => {
34
+ renderWithTheme(<AssetCard asset={mockAsset} onClick={vi.fn()} />)
35
+
36
+ expect(screen.getByText('test-image.jpg')).toBeDefined()
37
+ expect(screen.getByText('1 KB')).toBeDefined()
38
+ const img = screen.getByAltText('test-image.jpg')
39
+ expect(img).toBeDefined()
40
+
41
+ expect(img.getAttribute('src')).toContain('w=400')
42
+ })
43
+
44
+ it('calls onClick when clicked', () => {
45
+ const handleClick = vi.fn()
46
+ renderWithTheme(<AssetCard asset={mockAsset} onClick={handleClick} />)
47
+
48
+ fireEvent.click(screen.getByText('test-image.jpg'))
49
+ expect(handleClick).toHaveBeenCalledWith(mockAsset)
50
+ })
51
+
52
+ it('shows checkbox when onSelect is provided', () => {
53
+ const handleSelect = vi.fn()
54
+ renderWithTheme(
55
+ <AssetCard asset={mockAsset} onClick={vi.fn()} isSelected={false} onSelect={handleSelect} />,
56
+ )
57
+
58
+ const checkbox = screen.getByRole('checkbox')
59
+ expect(checkbox).toBeDefined()
60
+ expect(checkbox).not.toBeChecked()
61
+
62
+ fireEvent.click(checkbox)
63
+ })
64
+ })