@@ -16,6 +16,13 @@ import {cn, createRawImageUrl, getMcmetaPath} from '@/utils'
1616import { saveAs } from 'file-saver'
1717import type { GithubRepo } from '@/utils'
1818import { 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+ }
1926import { AnimatedSprite } from './AnimatedSprite'
2027
2128interface 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