Skip to content

Commit 64c8064

Browse files
authored
feat(instance): support customize instance icon (UNIkeEN#1228)
1 parent 7a87094 commit 64c8064

File tree

11 files changed

+256
-50
lines changed

11 files changed

+256
-50
lines changed

src-tauri/src/instance/commands.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1239,3 +1239,29 @@ pub async fn retrieve_modpack_meta_info(
12391239
let file = fs::File::open(&path).map_err(|_| InstanceError::FileNotFoundError)?;
12401240
ModpackMetaInfo::from_archive(&app, &file).await
12411241
}
1242+
1243+
#[tauri::command]
1244+
pub fn add_custom_instance_icon(
1245+
app: AppHandle,
1246+
instance_id: String,
1247+
source_src: String,
1248+
) -> SJMCLResult<()> {
1249+
let version_path = {
1250+
let binding = app.state::<Mutex<HashMap<String, Instance>>>();
1251+
let state = binding.lock()?;
1252+
let instance = state
1253+
.get(&instance_id)
1254+
.ok_or(InstanceError::InstanceNotFoundByID)?;
1255+
instance.version_path.clone()
1256+
};
1257+
1258+
let source_path = Path::new(&source_src);
1259+
if !source_path.exists() || !source_path.is_file() {
1260+
return Err(InstanceError::FileNotFoundError.into());
1261+
}
1262+
1263+
let dest_path = Path::new(&version_path).join("icon");
1264+
fs::copy(source_path, &dest_path)?;
1265+
1266+
Ok(())
1267+
}

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ pub async fn run() {
122122
instance::commands::check_change_mod_loader_availablity,
123123
instance::commands::change_mod_loader,
124124
instance::commands::retrieve_modpack_meta_info,
125+
instance::commands::add_custom_instance_icon,
125126
launch::commands::select_suitable_jre,
126127
launch::commands::validate_game_files,
127128
launch::commands::validate_selected_player,

src/components/instance-basic-settings.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// This component is used in the create-instance modal, instead of instance settings page.
12
import { Box, HStack, Image, Radio, VStack } from "@chakra-ui/react";
23
import { t } from "i18next";
34
import { useEffect } from "react";
Lines changed: 164 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,195 @@
11
import {
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";
1422
import 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

1628
interface 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+
2174
export 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

69188
export 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
};

src/components/instance-widgets.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import {
5050
formatRelativeTime,
5151
formatTimeInterval,
5252
} from "@/utils/datetime";
53-
import { parseModLoaderVersion } from "@/utils/instance";
53+
import { getInstanceIconSrc, parseModLoaderVersion } from "@/utils/instance";
5454
import { base64ImgSrc } from "@/utils/string";
5555

5656
// All these widgets are used in InstanceContext with WarpCard wrapped.
@@ -133,7 +133,12 @@ export const InstanceBasicInfoWidget = () => {
133133
</VStack>
134134
}
135135
prefixElement={
136-
<Image src={summary?.iconSrc} alt={summary?.iconSrc} boxSize="28px" />
136+
<Image
137+
src={getInstanceIconSrc(summary?.iconSrc, summary?.versionPath)}
138+
alt={summary?.iconSrc}
139+
boxSize="28px"
140+
fallbackSrc="/images/icons/JEIcon_Release.png"
141+
/>
137142
}
138143
zIndex={998}
139144
/>

src/components/instances-view.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import InstanceMenu from "@/components/instance-menu";
1818
import { useLauncherConfig } from "@/contexts/config";
1919
import { useGlobalData } from "@/contexts/global-data";
2020
import { InstanceSummary } from "@/models/instance/misc";
21-
import { generateInstanceDesc } from "@/utils/instance";
21+
import { generateInstanceDesc, getInstanceIconSrc } from "@/utils/instance";
2222

2323
interface InstancesViewProps extends BoxProps {
2424
instances: InstanceSummary[];
@@ -63,9 +63,9 @@ const InstancesView: React.FC<InstancesViewProps> = ({
6363
/>
6464
<Image
6565
boxSize="32px"
66-
objectFit="cover"
67-
src={instance.iconSrc}
66+
src={getInstanceIconSrc(instance.iconSrc, instance.versionPath)}
6867
alt={instance.name}
68+
fallbackSrc="/images/icons/JEIcon_Release.png"
6969
/>
7070
</HStack>
7171
),

src/locales/en.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1185,6 +1185,9 @@
11851185
"launch": "Launch Instance"
11861186
}
11871187
},
1188+
"InstanceIconSelector": {
1189+
"customize": "Customize Icon"
1190+
},
11881191
"InstanceMenu": {
11891192
"label": {
11901193
"details": "Instance Details",
@@ -2389,6 +2392,12 @@
23892392
"MODPACK_MANIFEST_PARSE_ERROR": "Modpack manifest file parse error"
23902393
}
23912394
}
2395+
},
2396+
"addCustomInstanceIcon": {
2397+
"success": "Successfully added custom instance icon",
2398+
"error": {
2399+
"title": "Failed to add custom instance icon"
2400+
}
23922401
}
23932402
},
23942403
"task": {

src/locales/zh-Hans.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1185,6 +1185,9 @@
11851185
"launch": "启动实例"
11861186
}
11871187
},
1188+
"InstanceIconSelector": {
1189+
"customize": "自定义图像"
1190+
},
11881191
"InstanceMenu": {
11891192
"label": {
11901193
"details": "实例详情",
@@ -2389,6 +2392,12 @@
23892392
"MODPACK_MANIFEST_PARSE_ERROR": "整合包清单文件解析错误"
23902393
}
23912394
}
2395+
},
2396+
"addCustomInstanceIcon": {
2397+
"success": "自定义实例图标添加成功",
2398+
"error": {
2399+
"title": "自定义实例图标添加失败"
2400+
}
23922401
}
23932402
},
23942403
"task": {

0 commit comments

Comments
 (0)