mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-09-24 17:11:00 +00:00
feat: customize title image and panorama
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user