Skip to content

Commit 214bfc5

Browse files
committed
feat: Improve image viewer mobile layout and remove overlay backgrounds
- Move metadata to top of bottom control area - Fix mobile layout to prevent elements from being too crowded - Prevent layout shift by reserving space for metadata - Remove overlay-bg from all image viewer elements - Improve spacing and organization of mobile controls
1 parent d49c24c commit 214bfc5

1 file changed

Lines changed: 210 additions & 68 deletions

File tree

src/components/ImageViewer.tsx

Lines changed: 210 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ import {cn, createRawImageUrl, getMcmetaPath} from '@/utils'
1616
import {saveAs} from 'file-saver'
1717
import type {GithubRepo} from '@/utils'
1818
import {useSettingStore} from '@/stores/settingStore'
19+
20+
// Format file size to human-readable format
21+
function formatFileSize(bytes: number): string {
22+
if (bytes < 1024) return `${bytes} B`
23+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
24+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
25+
}
1926
import {AnimatedSprite} from './AnimatedSprite'
2027

2128
interface ImageViewerProps {
@@ -40,6 +47,12 @@ export function ImageViewer({
4047
}: ImageViewerProps) {
4148
const [loading, setLoading] = useState(true)
4249
const [imageError, setImageError] = useState(false)
50+
const [imageMetadata, setImageMetadata] = useState<{
51+
width: number
52+
height: number
53+
fileSize: number | null
54+
format: string
55+
} | null>(null)
4356
const pixelated = useSettingStore(state => state.pixelated)
4457
const animationEnabled = useSettingStore(state => state.animationEnabled)
4558
const gridBackground = useSettingStore(state => state.gridBackground)
@@ -99,13 +112,96 @@ export function ImageViewer({
99112
}
100113
}, [currentIndex, hasNext, onIndexChange])
101114

102-
const handleImageLoad = useCallback(() => {
115+
const handleImageLoad = useCallback(async () => {
103116
setLoading(false)
104-
}, [])
117+
118+
// Get image metadata
119+
const url = createRawImageUrl(repo, currentImage)
120+
let width = 0
121+
let height = 0
122+
123+
if (imgRef.current) {
124+
width = imgRef.current.naturalWidth
125+
height = imgRef.current.naturalHeight
126+
}
127+
128+
// Get file size and format
129+
try {
130+
const response = await fetch(url, {method: 'HEAD'})
131+
const contentLength = response.headers.get('Content-Length')
132+
const contentType = response.headers.get('Content-Type') || ''
133+
134+
const fileSize = contentLength ? parseInt(contentLength, 10) : null
135+
const format =
136+
contentType.split('/')[1]?.toUpperCase() ||
137+
currentImage.split('.').pop()?.toUpperCase() ||
138+
'UNKNOWN'
139+
140+
// If width/height not available from img element, load image to get dimensions
141+
if (width === 0 || height === 0) {
142+
const img = new Image()
143+
img.src = url
144+
await new Promise<void>((resolve, reject) => {
145+
const timeout = setTimeout(() => reject(new Error('Timeout')), 5000)
146+
img.onload = () => {
147+
clearTimeout(timeout)
148+
width = img.naturalWidth
149+
height = img.naturalHeight
150+
resolve()
151+
}
152+
img.onerror = () => {
153+
clearTimeout(timeout)
154+
reject(new Error('Failed to load'))
155+
}
156+
})
157+
}
158+
159+
setImageMetadata({
160+
width,
161+
height,
162+
fileSize,
163+
format,
164+
})
165+
} catch {
166+
// Fallback: try to get format from extension
167+
const format = currentImage.split('.').pop()?.toUpperCase() || 'UNKNOWN'
168+
169+
// Try to get dimensions from image if not already available
170+
if (width === 0 || height === 0) {
171+
try {
172+
const img = new Image()
173+
img.src = url
174+
await new Promise<void>((resolve, reject) => {
175+
const timeout = setTimeout(() => reject(new Error('Timeout')), 5000)
176+
img.onload = () => {
177+
clearTimeout(timeout)
178+
width = img.naturalWidth
179+
height = img.naturalHeight
180+
resolve()
181+
}
182+
img.onerror = () => {
183+
clearTimeout(timeout)
184+
reject(new Error('Failed to load'))
185+
}
186+
})
187+
} catch {
188+
// Ignore errors, use 0 dimensions
189+
}
190+
}
191+
192+
setImageMetadata({
193+
width,
194+
height,
195+
fileSize: null,
196+
format,
197+
})
198+
}
199+
}, [currentImage, repo])
105200

106201
const handleImageError = useCallback(() => {
107202
setLoading(false)
108203
setImageError(true)
204+
setImageMetadata(null)
109205
}, [])
110206

111207
const handleImageRef = useCallback(
@@ -115,6 +211,7 @@ export function ImageViewer({
115211
currentImageRef.current = currentImage
116212
setLoading(true)
117213
setImageError(false)
214+
setImageMetadata(null)
118215
// Reset zoom when image changes
119216
setScale(1)
120217
setTranslateX(0)
@@ -139,22 +236,21 @@ export function ImageViewer({
139236
(delta: number, centerX?: number, centerY?: number) => {
140237
setScale(prevScale => {
141238
const newScale = Math.max(0.5, Math.min(5, prevScale + delta))
142-
239+
143240
// Zoom towards center point if provided
144-
if (centerX !== undefined && centerY !== undefined && imageContainerRef.current) {
145-
const container = imageContainerRef.current
146-
const rect = container.getBoundingClientRect()
147-
const containerCenterX = rect.width / 2
148-
const containerCenterY = rect.height / 2
149-
241+
if (
242+
centerX !== undefined &&
243+
centerY !== undefined &&
244+
imageContainerRef.current
245+
) {
150246
const scaleChange = newScale / prevScale
151247
const newTranslateX = centerX - (centerX - translateX) * scaleChange
152248
const newTranslateY = centerY - (centerY - translateY) * scaleChange
153-
249+
154250
setTranslateX(newTranslateX)
155251
setTranslateY(newTranslateY)
156252
}
157-
253+
158254
return newScale
159255
})
160256
},
@@ -165,15 +261,15 @@ export function ImageViewer({
165261
const handleWheelZoom = useCallback(
166262
(e: WheelEvent) => {
167263
if (!(e.ctrlKey || e.metaKey)) return
168-
264+
169265
e.preventDefault()
170266
const container = imageContainerRef.current
171267
if (!container) return
172-
268+
173269
const rect = container.getBoundingClientRect()
174270
const centerX = e.clientX - rect.left - rect.width / 2
175271
const centerY = e.clientY - rect.top - rect.height / 2
176-
272+
177273
const delta = -e.deltaY * 0.001
178274
handleZoom(delta, centerX, centerY)
179275
},
@@ -412,7 +508,7 @@ export function ImageViewer({
412508
</DialogClose>
413509

414510
<div className="absolute top-4 left-0 right-0 flex justify-center z-50 px-4">
415-
<div className="px-4 py-3 rounded-md max-w-[90%] wrap-break-word text-center overlay-bg">
511+
<div className="px-4 py-3 rounded-md max-w-[90%] wrap-break-word text-center">
416512
<div
417513
id={imageTitleId}
418514
className="text-base sm:text-lg font-semibold mb-1">
@@ -457,7 +553,7 @@ export function ImageViewer({
457553
)}
458554
{imageError ? (
459555
<div
460-
className="flex flex-col items-center justify-center overlay-bg"
556+
className="flex flex-col items-center justify-center"
461557
role="alert">
462558
<p className="text-lg mb-2">Failed to load image</p>
463559
<p className="text-sm opacity-70">{currentImage}</p>
@@ -529,9 +625,30 @@ export function ImageViewer({
529625
</Button>
530626
)}
531627

532-
<div className="absolute bottom-4 left-0 right-0 flex flex-col items-center gap-2 z-50">
628+
<div className="absolute bottom-4 left-0 right-0 flex flex-col items-center gap-2 z-50 px-4">
629+
<div className="hidden sm:flex items-center justify-center gap-2 rounded-md px-4 py-2 text-[0.65rem] min-h-[1.5rem]">
630+
{imageMetadata ? (
631+
<>
632+
<span className="opacity-80">
633+
{imageMetadata.width} × {imageMetadata.height}px
634+
</span>
635+
{imageMetadata.fileSize && (
636+
<span className="opacity-80">
637+
· {formatFileSize(imageMetadata.fileSize)}
638+
</span>
639+
)}
640+
{imageMetadata.format && (
641+
<span className="opacity-80">
642+
· {imageMetadata.format}
643+
</span>
644+
)}
645+
</>
646+
) : (
647+
<span className="invisible opacity-0">0 × 0px</span>
648+
)}
649+
</div>
533650
<div
534-
className="hidden items-center gap-3 rounded-md px-4 py-2 text-xs sm:flex overlay-bg"
651+
className="hidden items-center gap-3 rounded-md px-4 py-2 text-xs sm:flex"
535652
aria-live="polite"
536653
aria-atomic="true">
537654
<span>
@@ -584,81 +701,106 @@ export function ImageViewer({
584701
DOWNLOAD
585702
</Button>
586703
</div>
587-
<div className="flex items-center gap-4 sm:hidden">
704+
<div className="flex flex-col items-center gap-2 sm:hidden w-full">
705+
<div className="flex items-center justify-center gap-2 rounded-md px-3 py-1.5 text-[0.65rem] min-h-[1.75rem] w-full">
706+
{imageMetadata ? (
707+
<>
708+
<span className="opacity-80">
709+
{imageMetadata.width}×{imageMetadata.height}px
710+
</span>
711+
{imageMetadata.fileSize && (
712+
<span className="opacity-80">
713+
· {formatFileSize(imageMetadata.fileSize)}
714+
</span>
715+
)}
716+
{imageMetadata.format && (
717+
<span className="opacity-80">
718+
· {imageMetadata.format}
719+
</span>
720+
)}
721+
</>
722+
) : (
723+
<span className="invisible opacity-0">0×0px</span>
724+
)}
725+
</div>
726+
<div className="flex items-center gap-4 w-full">
588727
{hasPrevious && (
589728
<Button
590729
variant="ghost"
591730
size="icon"
592-
className="size-14 min-w-14 overlay-button"
731+
className="size-14 min-w-14 overlay-button flex-shrink-0"
593732
onClick={handlePrevious}
594733
aria-label="Previous image">
595734
<ChevronLeft className="size-10" />
596735
</Button>
597736
)}
598737
<div
599-
className="flex items-center gap-2 rounded-md px-4 py-2 text-xs overlay-bg"
738+
className="flex-1 flex items-center justify-center gap-2 rounded-md px-3 py-2 text-xs min-w-0"
600739
aria-live="polite"
601740
aria-atomic="true">
602-
<span>
741+
<span className="whitespace-nowrap">
603742
Image {currentIndex + 1} of {images.length}
604743
</span>
605-
<div className="flex items-center gap-1">
606-
<Button
607-
type="button"
608-
size="sm"
609-
variant="outline"
610-
className="h-8 px-2 py-1 text-[0.65rem] font-semibold overlay-button-outline"
611-
onClick={() => handleZoom(-0.2)}
612-
aria-label="Zoom out"
613-
disabled={scale <= 0.5}>
614-
<ZoomOut className="h-3 w-3" />
615-
</Button>
616-
<span className="px-1 text-[0.65rem] min-w-[2.5rem] text-center">
617-
{Math.round(scale * 100)}%
618-
</span>
619-
<Button
620-
type="button"
621-
size="sm"
622-
variant="outline"
623-
className="h-8 px-2 py-1 text-[0.65rem] font-semibold overlay-button-outline"
624-
onClick={() => handleZoom(0.2)}
625-
aria-label="Zoom in"
626-
disabled={scale >= 5}>
627-
<ZoomIn className="h-3 w-3" />
628-
</Button>
629-
{scale !== 1 && (
630-
<Button
631-
type="button"
632-
size="sm"
633-
variant="outline"
634-
className="h-8 px-2 py-1 text-[0.65rem] font-semibold overlay-button-outline"
635-
onClick={handleResetZoom}
636-
aria-label="Reset zoom">
637-
<RotateCcw className="h-3 w-3" />
638-
</Button>
639-
)}
640-
</div>
641-
<Button
642-
type="button"
643-
size="sm"
644-
variant="outline"
645-
className="h-8 px-2 py-1 text-[0.65rem] font-semibold overlay-button-outline"
646-
onClick={handleDownloadCurrent}
647-
aria-label="Download current image">
648-
<Download className="h-3 w-3" />
649-
</Button>
650744
</div>
651745
{hasNext && (
652746
<Button
653747
variant="ghost"
654748
size="icon"
655-
className="size-14 min-w-14 overlay-button"
749+
className="size-14 min-w-14 overlay-button flex-shrink-0"
656750
onClick={handleNext}
657751
aria-label="Next image">
658752
<ChevronRight className="size-10" />
659753
</Button>
660754
)}
661755
</div>
756+
<div className="flex items-center gap-2 w-full">
757+
<div className="flex-1 flex items-center justify-center gap-1 rounded-md px-3 py-2">
758+
<Button
759+
type="button"
760+
size="sm"
761+
variant="outline"
762+
className="h-8 px-2 py-1 text-[0.65rem] font-semibold overlay-button-outline"
763+
onClick={() => handleZoom(-0.2)}
764+
aria-label="Zoom out"
765+
disabled={scale <= 0.5}>
766+
<ZoomOut className="h-3 w-3" />
767+
</Button>
768+
<span className="px-1 text-[0.65rem] min-w-[2.5rem] text-center">
769+
{Math.round(scale * 100)}%
770+
</span>
771+
<Button
772+
type="button"
773+
size="sm"
774+
variant="outline"
775+
className="h-8 px-2 py-1 text-[0.65rem] font-semibold overlay-button-outline"
776+
onClick={() => handleZoom(0.2)}
777+
aria-label="Zoom in"
778+
disabled={scale >= 5}>
779+
<ZoomIn className="h-3 w-3" />
780+
</Button>
781+
{scale !== 1 && (
782+
<Button
783+
type="button"
784+
size="sm"
785+
variant="outline"
786+
className="h-8 px-2 py-1 text-[0.65rem] font-semibold overlay-button-outline"
787+
onClick={handleResetZoom}
788+
aria-label="Reset zoom">
789+
<RotateCcw className="h-3 w-3" />
790+
</Button>
791+
)}
792+
</div>
793+
<Button
794+
type="button"
795+
size="sm"
796+
variant="outline"
797+
className="h-8 px-3 py-1 text-[0.65rem] font-semibold overlay-button-outline flex-shrink-0"
798+
onClick={handleDownloadCurrent}
799+
aria-label="Download current image">
800+
<Download className="h-3 w-3" />
801+
</Button>
802+
</div>
803+
</div>
662804
</div>
663805
</div>
664806
</DialogPrimitive.Content>

0 commit comments

Comments
 (0)