Skip to content

Commit 06ce3ac

Browse files
committed
feat(download): Add path flatten options for ZIP download
1 parent bf94723 commit 06ce3ac

File tree

5 files changed

+203
-58
lines changed

5 files changed

+203
-58
lines changed

src/components/FilterToolbar.tsx

Lines changed: 94 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
import {memo} from 'react'
1+
import {memo, useState} from 'react'
22
import {Button} from '@/components/ui/button'
3-
import {Download as DownloadIcon, Loader as LoaderIcon} from 'lucide-react'
3+
import {Popover, PopoverContent, PopoverTrigger} from '@/components/ui/popover'
4+
import {Download as DownloadIcon, Loader as LoaderIcon, ChevronDown} from 'lucide-react'
45
import {useRepoStore} from '@/stores/repoStore'
56
import {useImageCount} from '@/hooks/features/filter/useImageCount'
67
import {useImageDownload} from '@/hooks/features/download/useImageDownload'
8+
import type {FlattenMode} from '@/utils'
9+
import {cn} from '@/utils'
710

811
interface ImageCountBadgeProps {
912
filteredCount: number
@@ -29,52 +32,109 @@ const ImageCountBadge = memo(
2932
},
3033
)
3134

35+
const flattenModeLabels: Record<FlattenMode, string> = {
36+
original: 'Original paths',
37+
'last-level': 'Last level only',
38+
flat: 'Flat (filename only)',
39+
}
40+
3241
const DownloadButton = memo(function DownloadButton() {
3342
const repo = useRepoStore(state => state.repo)
3443
const filteredImageFiles = useRepoStore(state => state.filteredImageFiles)
3544
const {filteredCount} = useImageCount()
45+
const [flattenMode, setFlattenMode] = useState<FlattenMode>('original')
46+
const [popoverOpen, setPopoverOpen] = useState(false)
3647

3748
const {isDownloading, downloadProgress, handleDownload} = useImageDownload({
3849
repo,
3950
imagePaths: filteredImageFiles || [],
51+
flattenMode,
4052
})
4153

54+
const handleDownloadClick = () => {
55+
setPopoverOpen(false)
56+
handleDownload()
57+
}
58+
4259
return (
43-
<Button
44-
aria-label="Download all filtered images as ZIP"
45-
disabled={isDownloading || !filteredCount}
46-
onClick={handleDownload}
47-
size="sm"
48-
variant="outline"
49-
className="text-xs font-semibold flex flex-col items-center gap-0.5 min-w-[190px]">
50-
{isDownloading ? (
51-
<>
52-
<div className="flex items-center gap-1">
53-
<LoaderIcon className="size-4 animate-spin" />
54-
<span>
55-
{downloadProgress !== null
56-
? `DOWNLOADING ${downloadProgress}%`
57-
: 'DOWNLOADING...'}
58-
</span>
59-
</div>
60-
{downloadProgress !== null && (
61-
<div
62-
className="mt-0.5 h-1 w-full rounded-full bg-muted overflow-hidden"
63-
aria-hidden="true">
64-
<div
65-
className="h-full bg-accent transition-[width] duration-150 ease-out"
66-
style={{width: `${downloadProgress}%`}}
67-
/>
60+
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
61+
<div className="flex gap-1">
62+
<Button
63+
aria-label="Download all filtered images as ZIP"
64+
disabled={isDownloading || !filteredCount}
65+
onClick={handleDownloadClick}
66+
size="sm"
67+
variant="outline"
68+
className="text-xs font-semibold flex flex-col items-center gap-0.5 min-w-[160px]">
69+
{isDownloading ? (
70+
<>
71+
<div className="flex items-center gap-1">
72+
<LoaderIcon className="size-4 animate-spin" />
73+
<span>
74+
{downloadProgress !== null
75+
? `DOWNLOADING ${downloadProgress}%`
76+
: 'DOWNLOADING...'}
77+
</span>
78+
</div>
79+
{downloadProgress !== null && (
80+
<div
81+
className="mt-0.5 h-1 w-full rounded-full bg-muted overflow-hidden"
82+
aria-hidden="true">
83+
<div
84+
className="h-full bg-accent transition-[width] duration-150 ease-out"
85+
style={{width: `${downloadProgress}%`}}
86+
/>
87+
</div>
88+
)}
89+
</>
90+
) : (
91+
<div className="flex items-center gap-1">
92+
<DownloadIcon className="size-4" />
93+
<span>DOWNLOAD FILTERED</span>
6894
</div>
6995
)}
70-
</>
71-
) : (
72-
<div className="flex items-center gap-1">
73-
<DownloadIcon className="size-4" />
74-
<span>DOWNLOAD FILTERED</span>
96+
</Button>
97+
{!isDownloading && (
98+
<PopoverTrigger asChild>
99+
<Button
100+
aria-label="Download options"
101+
size="sm"
102+
variant="outline"
103+
disabled={!filteredCount}
104+
className="px-2">
105+
<ChevronDown className="size-4" />
106+
</Button>
107+
</PopoverTrigger>
108+
)}
109+
</div>
110+
<PopoverContent side="bottom" align="end" className="w-64">
111+
<div className="space-y-2">
112+
<p className="text-xs font-semibold text-foreground">Path structure</p>
113+
<div className="space-y-1">
114+
{(['original', 'last-level', 'flat'] as FlattenMode[]).map(mode => (
115+
<button
116+
key={mode}
117+
type="button"
118+
onClick={() => {
119+
setFlattenMode(mode)
120+
setPopoverOpen(false)
121+
}}
122+
className={cn(
123+
'w-full text-left px-2 py-1.5 rounded text-xs transition-colors',
124+
flattenMode === mode
125+
? 'bg-accent text-accent-foreground'
126+
: 'hover:bg-muted text-muted-foreground',
127+
)}>
128+
{flattenModeLabels[mode]}
129+
</button>
130+
))}
131+
</div>
132+
<p className="text-xs text-muted-foreground pt-1 border-t">
133+
Duplicate names will be renamed with -1, -2, etc.
134+
</p>
75135
</div>
76-
)}
77-
</Button>
136+
</PopoverContent>
137+
</Popover>
78138
)
79139
})
80140

src/hooks/features/download/useImageDownload.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
import {useCallback, useState} from 'react'
22
import {downloadImagesAsZip} from '@/utils'
3-
import type {GithubRepo} from '@/utils'
3+
import type {GithubRepo, FlattenMode} from '@/utils'
44

55
interface UseImageDownloadProps {
66
repo: GithubRepo
77
imagePaths: string[]
8+
flattenMode?: FlattenMode
89
}
910

1011
/**
1112
* Hook for downloading images as ZIP
1213
* Handles download progress and state management
1314
*/
14-
export function useImageDownload({repo, imagePaths}: UseImageDownloadProps) {
15+
export function useImageDownload({
16+
repo,
17+
imagePaths,
18+
flattenMode = 'original',
19+
}: UseImageDownloadProps) {
1520
const [downloadProgress, setDownloadProgress] = useState<number | null>(null)
1621
const [isDownloading, setIsDownloading] = useState(false)
1722

@@ -21,10 +26,15 @@ export function useImageDownload({repo, imagePaths}: UseImageDownloadProps) {
2126
setIsDownloading(true)
2227
setDownloadProgress(0)
2328
try {
24-
await downloadImagesAsZip(repo, imagePaths, (completed, total) => {
25-
const percent = Math.round((completed / total) * 100)
26-
setDownloadProgress(percent)
27-
})
29+
await downloadImagesAsZip(
30+
repo,
31+
imagePaths,
32+
(completed, total) => {
33+
const percent = Math.round((completed / total) * 100)
34+
setDownloadProgress(percent)
35+
},
36+
flattenMode,
37+
)
2838
setDownloadProgress(100)
2939
// Briefly show 100%, then reset
3040
setTimeout(() => {
@@ -36,7 +46,7 @@ export function useImageDownload({repo, imagePaths}: UseImageDownloadProps) {
3646
setDownloadProgress(null)
3747
setIsDownloading(false)
3848
}
39-
}, [repo, imagePaths])
49+
}, [repo, imagePaths, flattenMode])
4050

4151
return {
4252
isDownloading,

src/utils/downloadImagesAsZip.ts

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,36 @@
11
import {GithubRepo, createRawImageUrl} from './github'
22
import type JSZip from 'jszip'
3+
import {type FlattenMode, resolveDuplicatePaths} from './pathFlatten'
34

45
// Constants
56
const LARGE_DOWNLOAD_THRESHOLD = 2000
67
const BATCH_SIZE = 50
78
const MAX_CONCURRENT_DOWNLOADS = 4 // Limit concurrent downloads to reduce memory usage
89

910
/**
10-
* Download a single image and add it to the zip
11+
* Download a single image and add it to the zip with a specific path
1112
*/
1213
async function downloadImageToZip(
1314
zip: JSZip,
1415
repo: GithubRepo,
15-
imagePath: string,
16+
originalPath: string,
17+
zipPath: string,
1618
): Promise<void> {
1719
try {
18-
const url = createRawImageUrl(repo, imagePath)
20+
const url = createRawImageUrl(repo, originalPath)
1921
const response = await fetch(url)
2022

2123
if (!response.ok) {
2224
throw new Error(
23-
`Failed to fetch ${imagePath}: ${response.status} ${response.statusText}`,
25+
`Failed to fetch ${originalPath}: ${response.status} ${response.statusText}`,
2426
)
2527
}
2628

2729
const blob = await response.blob()
28-
zip.file(imagePath, blob)
30+
zip.file(zipPath, blob)
2931
} catch (error) {
3032
const errorMessage = error instanceof Error ? error.message : String(error)
31-
console.error(`Failed to download ${imagePath}:`, errorMessage)
33+
console.error(`Failed to download ${originalPath}:`, errorMessage)
3234
throw error
3335
}
3436
}
@@ -41,17 +43,19 @@ async function processWithConcurrencyLimit(
4143
zip: JSZip,
4244
repo: GithubRepo,
4345
imagePaths: string[],
46+
pathMap: Map<string, string>,
4447
onProgress?: (completed: number, total: number) => void,
4548
): Promise<void> {
4649
let completed = 0
4750
const total = imagePaths.length
4851
const queue: Array<() => Promise<void>> = []
4952

5053
// Create a queue of download tasks
51-
for (const imagePath of imagePaths) {
54+
for (const originalPath of imagePaths) {
55+
const zipPath = pathMap.get(originalPath) || originalPath
5256
queue.push(async () => {
5357
try {
54-
await downloadImageToZip(zip, repo, imagePath)
58+
await downloadImageToZip(zip, repo, originalPath, zipPath)
5559
} catch {
5660
// Error already logged in downloadImageToZip
5761
// Continue with other images
@@ -93,19 +97,15 @@ async function processBatch(
9397
zip: JSZip,
9498
repo: GithubRepo,
9599
batch: string[],
100+
pathMap: Map<string, string>,
96101
currentCompleted: number,
97102
total: number,
98103
onProgress?: (completed: number, total: number) => void,
99104
): Promise<number> {
100-
await processWithConcurrencyLimit(
101-
zip,
102-
repo,
103-
batch,
104-
(completed) => {
105-
// Adjust progress to account for current batch offset
106-
onProgress?.(currentCompleted + completed, total)
107-
},
108-
)
105+
await processWithConcurrencyLimit(zip, repo, batch, pathMap, completed => {
106+
// Adjust progress to account for current batch offset
107+
onProgress?.(currentCompleted + completed, total)
108+
})
109109

110110
return currentCompleted + batch.length
111111
}
@@ -118,6 +118,7 @@ export const downloadImagesAsZip = async (
118118
repo: GithubRepo,
119119
imagePaths: string[],
120120
onProgress?: (completed: number, total: number) => void,
121+
flattenMode: FlattenMode = 'original',
121122
): Promise<void> => {
122123
if (!imagePaths.length) {
123124
throw new Error('No images to download')
@@ -137,6 +138,9 @@ export const downloadImagesAsZip = async (
137138
}
138139
}
139140

141+
// Resolve path transformations and duplicates
142+
const pathMap = resolveDuplicatePaths(imagePaths, flattenMode)
143+
140144
// Dynamic import - only load when download is triggered
141145
let JSZipClass: typeof JSZip
142146
let saveAs: typeof import('file-saver').saveAs
@@ -168,6 +172,7 @@ export const downloadImagesAsZip = async (
168172
zip,
169173
repo,
170174
batch,
175+
pathMap,
171176
completed,
172177
total,
173178
onProgress,

src/utils/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,5 @@ export {
2121
preloadImage,
2222
} from './imageCache'
2323
export {parseImagePath as parseImagePathForCell} from './features/imageCell'
24+
export {resolveDuplicatePaths, transformPath} from './pathFlatten'
25+
export type {FlattenMode} from './pathFlatten'

0 commit comments

Comments
 (0)