11import {
2+ Button ,
23 Center ,
34 Divider ,
45 HStack ,
6+ Icon ,
57 IconButton ,
68 Image ,
79 Popover ,
810 PopoverBody ,
911 PopoverContent ,
1012 PopoverTrigger ,
13+ Portal ,
1114 StackProps ,
15+ Text ,
16+ VStack ,
1217} from "@chakra-ui/react" ;
13- import { LuPenLine } from "react-icons/lu" ;
18+ import { open } from "@tauri-apps/plugin-dialog" ;
19+ import { useEffect , useState } from "react" ;
20+ import { useTranslation } from "react-i18next" ;
21+ import { LuCirclePlus , LuPenLine } from "react-icons/lu" ;
1422import SelectableButton from "@/components/common/selectable-button" ;
23+ import { useLauncherConfig } from "@/contexts/config" ;
24+ import { useToast } from "@/contexts/toast" ;
25+ import { InstanceService } from "@/services/instance" ;
26+ import { getInstanceIconSrc } from "@/utils/instance" ;
1527
1628interface InstanceIconSelectorProps extends StackProps {
1729 value ?: string ;
1830 onIconSelect : ( value : string ) => void ;
31+ // if provided instanceId and versionPath, support uploading for customization
32+ instanceId ?: string ;
33+ versionPath ?: string ;
1934}
2035
36+ /**
37+ * If image fails to load, hide the WHOLE selectable item so it doesn't occupy space.
38+ * refreshKey changes => reset ok=true (so custom icon can reappear after upload)
39+ */
40+ const IconSelectableButton : React . FC < {
41+ src : string ;
42+ selectedValue ?: string ;
43+ onSelect : ( v : string ) => void ;
44+ versionPath : string ;
45+ refreshKey ?: number ;
46+ } > = ( { src, selectedValue, onSelect, versionPath, refreshKey } ) => {
47+ const [ ok , setOk ] = useState ( true ) ;
48+
49+ useEffect ( ( ) => {
50+ setOk ( true ) ;
51+ } , [ refreshKey ] ) ;
52+
53+ if ( ! ok ) return null ;
54+
55+ return (
56+ < SelectableButton
57+ value = { src }
58+ isSelected = { src === selectedValue }
59+ onClick = { ( ) => onSelect ( src ) }
60+ paddingX = { 0.5 }
61+ >
62+ < Center w = "100%" >
63+ < Image
64+ src = { getInstanceIconSrc ( src , versionPath ) }
65+ alt = { src }
66+ boxSize = "24px"
67+ onError = { ( ) => setOk ( false ) }
68+ />
69+ </ Center >
70+ </ SelectableButton >
71+ ) ;
72+ } ;
73+
2174export const InstanceIconSelector : React . FC < InstanceIconSelectorProps > = ( {
2275 value,
2376 onIconSelect,
77+ instanceId,
78+ versionPath = "" ,
2479 ...stackProps
2580} ) => {
26- const iconList = [
27- "/images/icons/JEIcon_Release.png" ,
28- "/images/icons/JEIcon_Snapshot.png" ,
29- "divider" ,
30- "/images/icons/CommandBlock.png" ,
31- "/images/icons/CraftingTable.png" ,
32- "/images/icons/GrassBlock.png" ,
33- "/images/icons/StoneOldBeta.png" ,
34- "/images/icons/YellowGlazedTerracotta.png" ,
35- "divider" ,
36- "/images/icons/Fabric.png" ,
37- "/images/icons/Anvil.png" ,
38- "/images/icons/NeoForge.png" ,
81+ const { t } = useTranslation ( ) ;
82+ const toast = useToast ( ) ;
83+ const { config } = useLauncherConfig ( ) ;
84+ const primaryColor = config . appearance . theme . primaryColor ;
85+
86+ const [ customIconRefreshKey , setCustomIconRefreshKey ] = useState ( 0 ) ;
87+
88+ const handleAddCustomIcon = ( ) => {
89+ if ( ! instanceId ) return ;
90+
91+ open ( {
92+ multiple : false ,
93+ filters : [
94+ {
95+ name : t ( "General.dialog.filterName.image" ) ,
96+ extensions : [ "jpg" , "jpeg" , "png" , "gif" , "webp" ] ,
97+ } ,
98+ ] ,
99+ } )
100+ . then ( ( selectedPath ) => {
101+ if ( ! selectedPath ) return ;
102+ if ( Array . isArray ( selectedPath ) ) return ;
103+
104+ InstanceService . addCustomInstanceIcon ( instanceId , selectedPath ) . then (
105+ ( response ) => {
106+ if ( response . status === "success" ) {
107+ // select custom icon immediately
108+ onIconSelect ( "custom" ) ;
109+ // refresh the "custom" icon button in case it was hidden by onError previously
110+ setCustomIconRefreshKey ( ( v ) => v + 1 ) ;
111+ toast ( {
112+ title : response . message ,
113+ status : "success" ,
114+ } ) ;
115+ } else {
116+ toast ( {
117+ title : response . message ,
118+ description : response . details ,
119+ status : "error" ,
120+ } ) ;
121+ }
122+ }
123+ ) ;
124+ } )
125+ . catch ( ( ) => { } ) ;
126+ } ;
127+
128+ const itemRows : Array < Array < string | React . ReactNode > > = [
129+ [
130+ "/images/icons/JEIcon_Release.png" ,
131+ "/images/icons/JEIcon_Snapshot.png" ,
132+ < Divider orientation = "vertical" key = "d1" /> ,
133+ "/images/icons/CommandBlock.png" ,
134+ "/images/icons/CraftingTable.png" ,
135+ "/images/icons/GrassBlock.png" ,
136+ "/images/icons/StoneOldBeta.png" ,
137+ "/images/icons/YellowGlazedTerracotta.png" ,
138+ ] ,
139+ [
140+ "/images/icons/Fabric.png" ,
141+ "/images/icons/Anvil.png" ,
142+ "/images/icons/NeoForge.png" ,
143+ ...( instanceId
144+ ? [
145+ < Divider orientation = "vertical" key = "d2" /> ,
146+ "custom" , // will be converted by `getInstanceIconSrc()`
147+ < Button
148+ key = "add-btn"
149+ size = "xs"
150+ variant = "ghost"
151+ colorScheme = { primaryColor }
152+ onClick = { handleAddCustomIcon }
153+ >
154+ < HStack spacing = { 1.5 } >
155+ < Icon as = { LuCirclePlus } />
156+ < Text > { t ( "InstanceIconSelector.customize" ) } </ Text >
157+ </ HStack >
158+ </ Button > ,
159+ ]
160+ : [ ] ) ,
161+ ] ,
39162 ] ;
40163
41164 return (
42- < HStack h = "32px" { ...stackProps } >
43- { iconList . map ( ( iconSrc , index ) => {
44- return iconSrc === "divider" ? (
45- < Divider key = { index } orientation = "vertical" />
46- ) : (
47- < SelectableButton
48- key = { index }
49- value = { iconSrc }
50- isSelected = { iconSrc === value }
51- onClick = { ( ) => onIconSelect ( iconSrc ) }
52- paddingX = { 0.5 }
53- >
54- < Center w = "100%" >
55- < Image
56- src = { iconSrc }
57- alt = { iconSrc }
58- boxSize = "24px"
59- objectFit = "cover"
165+ < VStack spacing = { 1 } align = "stretch" { ...stackProps } >
166+ { itemRows . map ( ( row , rowIndex ) => (
167+ < HStack key = { rowIndex } h = "32px" >
168+ { row . map ( ( item , index ) =>
169+ typeof item === "string" ? (
170+ < IconSelectableButton
171+ key = { `i-${ rowIndex } -${ index } ` }
172+ src = { item }
173+ selectedValue = { value }
174+ onSelect = { onIconSelect }
175+ versionPath = { versionPath }
176+ refreshKey = { item === "custom" ? customIconRefreshKey : 0 }
60177 />
61- </ Center >
62- </ SelectableButton >
63- ) ;
64- } ) }
65- </ HStack >
178+ ) : (
179+ item
180+ )
181+ ) }
182+ </ HStack >
183+ ) ) }
184+ </ VStack >
66185 ) ;
67186} ;
68187
69188export const InstanceIconSelectorPopover : React . FC <
70189 InstanceIconSelectorProps
71190> = ( { ...props } ) => {
72191 return (
73- < Popover >
192+ < Popover placement = "bottom-end" >
74193 < PopoverTrigger >
75194 < IconButton
76195 icon = { < LuPenLine /> }
@@ -79,11 +198,13 @@ export const InstanceIconSelectorPopover: React.FC<
79198 aria-label = "edit"
80199 />
81200 </ PopoverTrigger >
82- < PopoverContent width = "auto" >
83- < PopoverBody >
84- < InstanceIconSelector { ...props } />
85- </ PopoverBody >
86- </ PopoverContent >
201+ < Portal >
202+ < PopoverContent width = "auto" >
203+ < PopoverBody >
204+ < InstanceIconSelector { ...props } />
205+ </ PopoverBody >
206+ </ PopoverContent >
207+ </ Portal >
87208 </ Popover >
88209 ) ;
89210} ;
0 commit comments