feat: customize title image and panorama

This commit is contained in:
neoapps-dev 2026-05-28 22:37:13 +03:00
parent 04073b5ef9
commit 5a01c5fcd3
7 changed files with 362 additions and 13 deletions

View file

@ -10,7 +10,10 @@ const PanoramaBackground = React.memo(({ profile, isDay }: PanoramaProps) => {
const { isWindowVisible } = useUI();
const baseId = profile;
const profileId = baseId ? baseId : "vanilla_tu19";
const currentPanorama = `/panorama/${profileId}_Panorama_Background_${isDay ? "Day" : "Night"}.png`;
const isCustomUrl = profile.startsWith("data:") || profile.startsWith("blob:");
const currentPanorama = isCustomUrl
? profile
: `/panorama/${profileId}_Panorama_Background_${isDay ? "Day" : "Night"}.png`;
const [bgWidth, setBgWidth] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);

View file

@ -0,0 +1,246 @@
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { TauriService } from "../../services/TauriService";
function isFilePath(value: string): boolean {
return value.startsWith("/") && !value.startsWith("/images/") && !value.startsWith("/panorama/");
}
async function resolvePath(value: string | undefined): Promise<string | undefined> {
if (!value) return undefined;
if (isFilePath(value)) {
try {
return await TauriService.readScreenshotAsDataUrl(value);
} catch {
return value;
}
}
return value;
}
export default function CustomizeModal({
isOpen,
onClose,
playPressSound,
playBackSound,
editionName,
currentTitleImage,
currentPanorama,
onSave,
}: {
isOpen: boolean;
onClose: () => void;
playPressSound: (s?: string) => void;
playBackSound: (s?: string) => void;
editionName: string;
currentTitleImage?: string;
currentPanorama?: string;
onSave: (updates: { titleImage?: string; panorama?: string }) => void;
}) {
const [titleImage, setTitleImage] = useState(currentTitleImage || "");
const [panorama, setPanorama] = useState(currentPanorama || "");
const [focusIndex, setFocusIndex] = useState(0);
useEffect(() => {
if (isOpen) {
setTitleImage(currentTitleImage || "");
setPanorama(currentPanorama || "");
setFocusIndex(0);
}
}, [isOpen, currentTitleImage, currentPanorama]);
useEffect(() => {
if (!isOpen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
playBackSound("close_click.wav");
onClose();
} else if (e.key === "Enter") {
if (focusIndex === 4) {
playBackSound("close_click.wav");
onClose();
} else if (focusIndex === 5) {
handleSave();
}
} else if (e.key === "ArrowDown" || e.key === "Tab") {
e.preventDefault();
setFocusIndex((prev) => (prev + 1) % 6);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setFocusIndex((prev) => (prev - 1 + 6) % 6);
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [isOpen, focusIndex, titleImage, panorama]);
const handleSave = async () => {
playPressSound("save_click.wav");
const [resolvedTitle, resolvedPanorama] = await Promise.all([
resolvePath(titleImage || undefined),
resolvePath(panorama || undefined),
]);
onSave({
titleImage: resolvedTitle,
panorama: resolvedPanorama,
});
onClose();
};
const handlePickFile = async (field: "titleImage" | "panorama") => {
try {
const path = await TauriService.pickFile(
field === "titleImage" ? "Select Title Image" : "Select Panorama Background",
["png", "jpg", "jpeg", "bmp"],
);
if (path) {
if (field === "titleImage") setTitleImage(path);
else setPanorama(path);
playPressSound();
}
} catch (e) {
if (e !== "CANCELED") console.error(e);
}
};
const handleReset = (field: "titleImage" | "panorama") => {
playPressSound();
if (field === "titleImage") setTitleImage("");
else setPanorama("");
};
if (!isOpen) return null;
const pickerSection = (
field: "titleImage" | "panorama",
value: string,
idx: number,
resetIdx: number,
) => (
<div className="flex flex-col gap-2">
<label className="text-[#AAAAAA] text-xs mc-text-shadow uppercase tracking-widest">
{field === "titleImage" ? "Title Image" : "Panorama Background"}
</label>
<div className="flex gap-2">
<button
onClick={() => handlePickFile(field)}
onMouseEnter={() => setFocusIndex(idx)}
className="flex-1 h-10 flex items-center justify-center text-xs mc-text-shadow text-white outline-none border-none bg-transparent"
style={{
backgroundImage:
focusIndex === idx
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
{value ? "Change Image" : "Pick Image"}
</button>
{value && (
<button
onClick={() => handleReset(field)}
onMouseEnter={() => setFocusIndex(resetIdx)}
className="w-24 h-10 flex items-center justify-center text-xs mc-text-shadow text-red-400 outline-none border-none bg-transparent"
style={{
backgroundImage:
focusIndex === resetIdx
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Reset
</button>
)}
</div>
{value && (
<div className="flex items-center gap-2 mt-1">
<img
src={value}
alt="Preview"
className="w-10 h-6 object-contain border border-[#555]"
style={{ imageRendering: "pixelated" }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
<span className="text-[10px] text-white/50 truncate flex-1">
{value.split("/").pop() || value}
</span>
</div>
)}
</div>
);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 w-screen h-screen z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md outline-none border-none"
>
<div
className="relative w-[420px] p-6 flex flex-col items-center shadow-2xl"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-4 border-b-2 border-[#373737] pb-2 w-full text-center uppercase">
Customize
</h2>
<p className="text-white/70 text-xs mc-text-shadow mb-4 -mt-2 truncate max-w-full">
{editionName}
</p>
<div className="flex flex-col gap-4 w-full">
{pickerSection("titleImage", titleImage, 0, 1)}
{pickerSection("panorama", panorama, 2, 3)}
</div>
<div className="flex gap-4 mt-6 w-full justify-center">
<button
onMouseEnter={() => setFocusIndex(4)}
onClick={() => {
playBackSound("close_click.wav");
onClose();
}}
className={`w-32 h-10 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none ${
focusIndex === 4 ? "text-[#FFFF55]" : "text-white"
}`}
style={{
backgroundImage:
focusIndex === 4
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Cancel
</button>
<button
onMouseEnter={() => setFocusIndex(5)}
onClick={handleSave}
className={`w-32 h-10 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none ${
focusIndex === 5 ? "text-[#FFFF55]" : "text-white"
}`}
style={{
backgroundImage:
focusIndex === 5
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Save
</button>
</div>
</div>
</motion.div>
);
}

View file

@ -5,6 +5,7 @@ import CustomTUModal from "../modals/CustomTUModal";
import SetUidModal from "../modals/SetUidModal";
import ImportWorldModal from "../modals/ImportWorldModal";
import PlaytimeModal from "../modals/PlaytimeModal";
import CustomizeModal from "../modals/CustomizeModal";
import {
useUI,
useConfig,
@ -77,6 +78,8 @@ const VersionsView = memo(function VersionsView() {
updatesAvailable,
addToSteam,
cycleBranch,
customizations,
updateCustomization,
} = useGame();
const { isDayTime } = useConfig();
const [focusIndex, setFocusIndex] = useState<number>(0);
@ -89,6 +92,8 @@ const VersionsView = memo(function VersionsView() {
const [importWorldTarget, setImportWorldTarget] = useState<{ id: string; name: string } | null>(null);
const [isPlaytimeModalOpen, setIsPlaytimeModalOpen] = useState(false);
const [playtimeTarget, setPlaytimeTarget] = useState<{ id: string; name: string } | null>(null);
const [isCustomizeModalOpen, setIsCustomizeModalOpen] = useState(false);
const [customizeTarget, setCustomizeTarget] = useState<Edition | null>(null);
const [playtimeMap, setPlaytimeMap] = useState<Record<string, PlaytimeResponse>>({});
const [initialPath, setInitialPath] = useState<string>("");
const [hoveredBtn, setHoveredBtn] = useState<{
@ -673,6 +678,29 @@ const VersionsView = memo(function VersionsView() {
</svg>
Restore
</button>
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
setCustomizeTarget(edition);
setIsCustomizeModalOpen(true);
setOpenMenuId(null);
}}
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="w-3.5 h-3.5"
>
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
Customize
</button>
<button
onClick={(e) => {
e.stopPropagation();
@ -887,6 +915,21 @@ const VersionsView = memo(function VersionsView() {
instanceName={playtimeTarget?.name ?? ""}
/>
<CustomizeModal
isOpen={isCustomizeModalOpen}
onClose={() => { setIsCustomizeModalOpen(false); setCustomizeTarget(null); }}
playPressSound={playPressSound}
playBackSound={playBackSound}
editionName={customizeTarget?.name ?? ""}
currentTitleImage={customizeTarget ? customizations[customizeTarget.instanceId]?.titleImage || customizeTarget.titleImage : undefined}
currentPanorama={customizeTarget ? customizations[customizeTarget.instanceId]?.panorama || customizeTarget.panorama : undefined}
onSave={(updates) => {
if (customizeTarget) {
updateCustomization(customizeTarget.instanceId, updates);
}
}}
/>
{deleteConfirmEdition && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div

View file

@ -51,6 +51,8 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
setProfile: configRaw.setProfile,
customEditions: configRaw.customEditions,
setCustomEditions: configRaw.setCustomEditions,
customizations: configRaw.customizations,
setCustomizations: configRaw.setCustomizations,
extraLaunchArgs: configRaw.extraLaunchArgs,
});
const skinSync = useSkinSync({ username: configRaw.username, profile: configRaw.profile, editions: gameRaw.editions });
@ -78,7 +80,8 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
gameRaw.updatesAvailable, gameRaw.addToSteam, gameRaw.steamSuccessMessage,
gameRaw.cycleBranch, gameRaw.toggleInstall, gameRaw.checkInstalls,
gameRaw.handleLaunch, gameRaw.stopGame, gameRaw.addCustomEdition,
gameRaw.deleteCustomEdition, gameRaw.downloadRunner
gameRaw.deleteCustomEdition, gameRaw.downloadRunner,
gameRaw.customizations, gameRaw.updateCustomization,
]);
const audio = useMemo(() => audioRaw, [

View file

@ -18,6 +18,7 @@ export function useAppConfig() {
const [linuxRunner, setLinuxRunner] = useState<string | undefined>();
const [perfBoost, setPerfBoost] = useState(false);
const [customEditions, setCustomEditions] = useState<CustomEdition[]>([]);
const [customizations, setCustomizations] = useState<Record<string, { titleImage?: string; panorama?: string }>>({});
const [mangohudEnabled, setMangohudEnabled] = useState(false);
const [extraLaunchArgs, setExtraLaunchArgs] = useState<string[] | undefined>();
const [launchPrefix, setLaunchPrefix] = useState<string | undefined>();
@ -30,6 +31,7 @@ export function useAppConfig() {
if (config.appleSiliconPerformanceBoost !== undefined)
setPerfBoost(config.appleSiliconPerformanceBoost);
if (config.customEditions) setCustomEditions(config.customEditions);
if (config.customizations) setCustomizations(config.customizations);
if (config.profile) setProfile(config.profile);
if (config.vfxEnabled !== undefined) setVfxEnabled(config.vfxEnabled);
if (config.animationsEnabled !== undefined) setAnimationsEnabled(config.animationsEnabled);
@ -54,6 +56,7 @@ export function useAppConfig() {
appleSiliconPerformanceBoost: perfBoost,
profile,
customEditions,
customizations,
animationsEnabled,
vfxEnabled,
rpcEnabled,
@ -66,7 +69,7 @@ export function useAppConfig() {
launchEnvVars,
}).catch(console.error);
}
}, [username, theme, linuxRunner, perfBoost, profile, customEditions, animationsEnabled, vfxEnabled, rpcEnabled, musicVol, sfxVol, legacyMode, mangohudEnabled, extraLaunchArgs, launchPrefix, launchEnvVars, isLoaded]);
}, [username, theme, linuxRunner, perfBoost, profile, customEditions, customizations, animationsEnabled, vfxEnabled, rpcEnabled, musicVol, sfxVol, legacyMode, mangohudEnabled, extraLaunchArgs, launchPrefix, launchEnvVars, isLoaded]);
return {
username,
@ -97,6 +100,8 @@ export function useAppConfig() {
setPerfBoost,
customEditions,
setCustomEditions,
customizations,
setCustomizations,
isLoaded,
hasCompletedSetup,
setHasCompletedSetup,

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useState, useEffect, useCallback, useMemo, useRef, type Dispatch, type SetStateAction } from "react";
import { TauriService, type CustomEdition } from "../services/TauriService";
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { Edition } from "../types/edition";
@ -77,6 +77,8 @@ interface GameManagerProps {
setProfile: (id: string) => void;
customEditions: CustomEdition[];
setCustomEditions: (editions: CustomEdition[]) => void;
customizations: Record<string, { titleImage?: string; panorama?: string }>;
setCustomizations: Dispatch<SetStateAction<Record<string, { titleImage?: string; panorama?: string }>>>;
extraLaunchArgs?: string[];
}
@ -102,6 +104,8 @@ export function useGameManager({
setProfile,
customEditions,
setCustomEditions,
customizations,
setCustomizations,
extraLaunchArgs,
}: GameManagerProps) {
const [installs, setInstalls] = useState<string[]>([]);
@ -126,7 +130,6 @@ export function useGameManager({
>({});
const branchesFetched = useRef<Set<string>>(new Set());
const initialBranchesSet = useRef(false);
useEffect(() => {
if (initialBranchesSet.current || !profile) return;
BASE_EDITIONS.forEach((e) => {
@ -253,7 +256,7 @@ export function useGameManager({
url = `${baseUrl}/releases/download/${branchToUse}/${filename}`;
}
return {
const edition = {
...e,
url,
branches: availableBranches,
@ -261,10 +264,20 @@ export function useGameManager({
instanceId:
selectedBranch === "Stable" ? e.id : `${e.id}_${selectedBranch}`,
};
const custom = customizations[e.id];
if (custom?.titleImage) edition.titleImage = custom.titleImage;
if (custom?.panorama) edition.panorama = custom.panorama;
return edition;
}),
...customEditions.map((e) => {
const edition: Edition = { ...e, instanceId: e.id };
const custom = customizations[e.id];
if (custom?.titleImage) edition.titleImage = custom.titleImage;
if (custom?.panorama) edition.panorama = custom.panorama;
return edition;
}),
...customEditions.map((e) => ({ ...e, instanceId: e.id })),
];
}, [customEditions, dynamicUrls, branches, selectedBranches]);
}, [customEditions, dynamicUrls, branches, selectedBranches, customizations]);
const checkInstalls = useCallback(async () => {
const results = await Promise.all(
@ -349,7 +362,11 @@ export function useGameManager({
} catch (e: unknown) {
console.error(e);
setError(
e instanceof Error ? e.message : typeof e === "string" ? e : "Failed to download runner",
e instanceof Error
? e.message
: typeof e === "string"
? e
: "Failed to download runner",
);
} finally {
setIsRunnerDownloading(false);
@ -376,7 +393,11 @@ export function useGameManager({
} catch (e: unknown) {
console.error(e);
setError(
e instanceof Error ? e.message : typeof e === "string" ? e : "Failed to install version",
e instanceof Error
? e.message
: typeof e === "string"
? e
: "Failed to install version",
);
setDownloadProgress(null);
setDownloadingId(null);
@ -412,11 +433,19 @@ export function useGameManager({
setIsGameRunning(true);
try {
getCurrentWindow().minimize();
await TauriService.launchGame(profile, PARTNERSHIP_SERVERS, extraLaunchArgs);
await TauriService.launchGame(
profile,
PARTNERSHIP_SERVERS,
extraLaunchArgs,
);
} catch (e: unknown) {
console.error(e);
setError(
e instanceof Error ? e.message : typeof e === "string" ? e : "Failed to launch game",
e instanceof Error
? e.message
: typeof e === "string"
? e
: "Failed to launch game",
);
} finally {
setIsGameRunning(false);
@ -474,6 +503,19 @@ export function useGameManager({
[customEditions, setCustomEditions],
);
const updateCustomization = useCallback(
(
instanceId: string,
updates: { titleImage?: string; panorama?: string },
) => {
setCustomizations((prev) => ({
...prev,
[instanceId]: { ...prev[instanceId], ...updates },
}));
},
[],
);
const addToSteam = useCallback(
async (
id: string,
@ -491,7 +533,11 @@ export function useGameManager({
} catch (e: unknown) {
console.error(e);
setError(
e instanceof Error ? e.message : typeof e === "string" ? e : "Failed to add to Steam",
e instanceof Error
? e.message
: typeof e === "string"
? e
: "Failed to add to Steam",
);
}
},
@ -525,5 +571,7 @@ export function useGameManager({
updatesAvailable,
addToSteam,
cycleBranch,
customizations,
updateCustomization,
};
}

View file

@ -44,6 +44,7 @@ export interface AppConfig {
extraLaunchArgs?: string[];
launchPrefix?: string;
launchEnvVars?: Record<string, string>;
customizations?: Record<string, { titleImage?: string; panorama?: string }>;
}
export interface ThemePalette {