revert the revert

This commit is contained in:
neoapps-dev
2026-04-02 16:02:58 +03:00
parent 80bd77c312
commit 535735fcb2
217 changed files with 11898 additions and 5717 deletions
-29
View File
@@ -1,29 +0,0 @@
export const Icons = {
Discord: () => (
<svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor">
<path d="M6 6h20v4h2v12h-2v4h-4v-4h-8v4H6v-4H4V10h2V6zm4 6v4h4v-4h-4zm8 0v4h4v-4h-4z" />
</svg>
),
Github: () => (
<svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor">
<path d="M12 4h8v4h4v4h4v8h-4v4h-4v4h-8v-4H8v-4H4v-8h4V8h4V4zm2 8v4h4v-4h-4z" />
</svg>
),
Reddit: () => (
<svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor">
<path d="M10 4h12v4h4v4h2v12h-2v4H10v-4H4V12h2V8h4V4zm2 10v4h8v-4h-8z" />
</svg>
),
Volume: ({ level }: { level: number }) => (
<svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor">
<path d="M4 12h8l8-8v24l-8-8H4v-8z" />
{level > 0 && <path d="M24 12h2v8h-2z" />}
{level > 0.5 && <path d="M28 8h2v16h-2z" />}
</svg>
),
Linux: () => (
<svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor">
<path d="M16 4c-3.3 0-6 2.7-6 6 0 1.2.4 2.3 1 3.2C8.7 15.1 7 18.3 7 22h2c0-3.9 3.1-7 7-7s7 3.1 7 7h2c0-3.7-1.7-6.9-4-8.8.6-.9 1-2 1-3.2 0-3.3-2.7-6-6-6zm0 2c2.2 0 4 1.8 4 4s-1.8 4-4 4-4-1.8-4-4 1.8-4 4-4z" />
</svg>
),
};
+106
View File
@@ -0,0 +1,106 @@
import { useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
interface AchievementToastProps {
message: string | null;
onClose: () => void;
onClick?: () => void;
title?: string;
variant?: "error" | "update";
}
export function AchievementToast({
message,
onClose,
onClick,
title = "Error Get!",
variant = "error"
}: AchievementToastProps) {
useEffect(() => {
if (message) {
const timer = setTimeout(() => {
onClose();
}, 8000);
return () => clearTimeout(timer);
}
}, [message, onClose]);
const getIcon = () => {
if (variant === "update") {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="#FFFF55"
strokeWidth="3"
strokeLinecap="square"
className="drop-shadow-md"
>
<path d="M12 5v14M5 12l7 7 7-7" />
</svg>
);
}
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="#FF5555"
strokeWidth="3"
strokeLinecap="square"
className="drop-shadow-md"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
);
};
return (
<AnimatePresence>
{message && (
<motion.div
initial={{ x: 400, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 400, opacity: 0 }}
transition={{ type: "spring", damping: 20, stiffness: 100 }}
onClick={onClick ? () => {
onClick();
onClose();
} : undefined}
className={`fixed top-6 right-6 z-[9999] ${onClick ? "cursor-pointer" : ""}`}
>
<div
className="flex items-center gap-4 p-4 min-w-[300px] max-w-[450px]"
style={{
backgroundColor: "#212121",
border: "4px solid",
borderTopColor: "#7F7F7F",
borderLeftColor: "#7F7F7F",
borderBottomColor: "#3F3F3F",
borderRightColor: "#3F3F3F",
imageRendering: "pixelated",
}}
>
<div className="w-12 h-12 flex-shrink-0 flex items-center justify-center bg-[#3F3F3F] border-2 border-[#1A1A1A]">
{getIcon()}
</div>
<div className="flex flex-col">
<span className="text-[#FFFF55] text-lg font-bold mc-text-shadow leading-tight">
{title}
</span>
<span className="text-white text-base mc-text-shadow leading-tight break-words">
{message}
</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
+67
View File
@@ -0,0 +1,67 @@
import React, { useEffect, useState } from "react";
const SGA_CHARS = "abcdefghijklmnopqrstuvwxyz".split("");
interface Particle {
id: number;
char: string;
x: number;
y: number;
vX: number;
vY: number;
rotation: number;
}
export const ClickParticles: React.FC = React.memo(() => {
const [bursts, setBursts] = useState<Particle[]>([]);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
const newParticles: Particle[] = [];
const particleCount = 8;
for (let i = 0; i < particleCount; i++) {
newParticles.push({
id: Date.now() + Math.random(),
char: SGA_CHARS[Math.floor(Math.random() * SGA_CHARS.length)],
x: e.clientX,
y: e.clientY,
vX: (Math.random() - 0.5) * 200,
vY: (Math.random() - 0.5) * 200,
rotation: Math.random() * 360,
});
}
setBursts((prev) => [...prev, ...newParticles]);
setTimeout(() => {
setBursts((prev) => prev.filter(p => !newParticles.find(np => np.id === p.id)));
}, 1000);
};
window.addEventListener("mousedown", handleClick);
return () => window.removeEventListener("mousedown", handleClick);
}, []);
return (
<div className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden">
{bursts.map((p) => (
<img
key={p.id}
src={`/images/sga_${p.char}.png`}
className="absolute particle-burst pointer-events-none"
style={{
left: p.x,
top: p.y,
width: "24px",
height: "24px",
imageRendering: "pixelated",
"--vX": `${p.vX}px`,
"--vY": `${p.vY}px`,
"--rot": `${p.rotation}deg`,
} as React.CSSProperties}
alt="magic-particle"
/>
))}
</div>
);
});
-20
View File
@@ -1,20 +0,0 @@
import React from 'react';
interface NotificationProps {
title: string;
message: string;
}
export const Notification: React.FC<NotificationProps> = ({ title, message }) => {
return (
<div className="absolute top-6 right-6 bg-[#202020] border-2 border-black p-4 flex items-center gap-4 shadow-[5px_5px_15px_rgba(0,0,0,0.5)] z-[100] animate-in slide-in-from-right-10">
<div className="w-12 h-12 bg-emerald-500 border-2 border-black flex items-center justify-center text-3xl font-bold">
✓
</div>
<div className="flex flex-col">
<span className="text-[#ffff55] text-2xl font-bold">{title}</span>
<span className="text-white text-xl">{message}</span>
</div>
</div>
);
};
@@ -0,0 +1,70 @@
import React, { useEffect, useRef, useState } from 'react';
import { useUI } from '../../context/LauncherContext';
interface PanoramaProps {
profile: string;
isDay: boolean;
}
const PanoramaBackground = React.memo(({ profile, isDay }: PanoramaProps) => {
const { isWindowVisible } = useUI();
const PANORAMA_PROFILES = ['legacy_evolved', 'vanilla_tu19', 'vanilla_tu24'];
const profileId = PANORAMA_PROFILES.includes(profile) ? profile : 'legacy_evolved';
const currentPanorama = `/panorama/${profileId}_Panorama_Background_${isDay ? 'Day' : 'Night'}.png`;
const [bgWidth, setBgWidth] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let active = true;
const updateWidth = () => {
if (!containerRef.current) return;
const img = new Image();
img.src = currentPanorama;
img.onload = () => {
if (!active || !containerRef.current) return;
const height = containerRef.current.clientHeight;
const aspectRatio = img.naturalWidth / img.naturalHeight;
setBgWidth(Math.ceil(height * aspectRatio));
};
};
updateWidth();
window.addEventListener('resize', updateWidth);
return () => {
active = false;
window.removeEventListener('resize', updateWidth);
};
}, [currentPanorama]);
return (
<>
{bgWidth && (
<style>{`
@keyframes panoramaLoop {
0% { transform: translate3d(0, 0, 0); }
100% { transform: translate3d(-${bgWidth}px, 0, 0); }
}
`}</style>
)}
<div ref={containerRef} className="absolute inset-0 overflow-hidden pointer-events-none transition-opacity duration-500">
{isWindowVisible && (
<div
className="absolute top-0 left-0 h-full will-change-transform"
style={{
width: bgWidth ? `calc(100vw + ${bgWidth}px)` : '200vw',
backgroundImage: `url("${currentPanorama}")`,
backgroundSize: bgWidth ? `${bgWidth}px 100%` : 'auto 100%',
backgroundRepeat: 'repeat-x',
animation: bgWidth ? 'panoramaLoop 140s linear infinite' : 'none'
}}
/>
)}
</div>
<div className="absolute inset-0 bg-black/35 pointer-events-none" />
</>
);
});
export default PanoramaBackground;
+328
View File
@@ -0,0 +1,328 @@
import { useEffect, useRef, useState, memo } from 'react';
import { motion } from 'framer-motion';
import * as THREE from 'three';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { useConfig } from '../../context/LauncherContext';
interface SkinViewerProps {
username: string;
setUsername: (name: string) => void;
playClickSound: () => void;
skinUrl: string;
setSkinUrl: (url: string) => void;
setActiveView: (view: string) => void;
isFocusedSection: boolean;
onNavigateRight: () => void;
}
const SkinViewer = memo(function SkinViewer({ username, setUsername, playClickSound, skinUrl, setSkinUrl, setActiveView, isFocusedSection, onNavigateRight }: SkinViewerProps) {
const mountRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [focusIndex, setFocusIndex] = useState(0);
const { legacyMode } = useConfig();
const [showLayers, setShowLayers] = useLocalStorage('lce-show-layers', true);
const overlaysRef = useRef<THREE.Mesh[]>([]);
const requestRenderRef = useRef<(() => void) | null>(null);
useEffect(() => {
if (!mountRef.current) return;
const width = 260;
const height = 450;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 1000);
camera.position.set(0, 0, 68);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
mountRef.current.innerHTML = "";
mountRef.current.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 0.6));
const dl = new THREE.DirectionalLight(0xffffff, 0.8);
dl.position.set(10, 20, 10);
scene.add(dl);
const playerGroup = new THREE.Group();
playerGroup.position.y = -1.5;
scene.add(playerGroup);
const textureLoader = new THREE.TextureLoader();
textureLoader.load(skinUrl || "/images/Default.png", (texture) => {
texture.magFilter = THREE.NearestFilter;
texture.minFilter = THREE.NearestFilter;
texture.colorSpace = THREE.SRGBColorSpace;
const img = texture.image;
const isLegacy = img.height === 32;
const createFaceMaterial = (x: number, y: number, w: number, h: number, flipX = false, flipY = false) => {
const matTex = texture.clone();
matTex.repeat.set((flipX ? -w : w) / 64, (flipY ? -h : h) / img.height);
matTex.offset.set((flipX ? (x + w) : x) / 64, 1 - (flipY ? y : (y + h)) / img.height);
matTex.needsUpdate = true;
return new THREE.MeshLambertMaterial({ map: matTex, transparent: true, alphaTest: 0.5, side: THREE.FrontSide });
};
const createPart = (w: number, h: number, d: number, uv: any, overlayUv?: any, swapMats = false, isLegacyMirror = false) => {
const group = new THREE.Group();
const geo = new THREE.BoxGeometry(w, h, d);
const getMats = (uvSet: any) => {
const flipX = isLegacyMirror;
return [
createFaceMaterial(swapMats ? uvSet.right[0] : uvSet.left[0], uvSet.left[1], uvSet.left[2], uvSet.left[3], flipX), // +x (L)
createFaceMaterial(swapMats ? uvSet.left[0] : uvSet.right[0], uvSet.right[1], uvSet.right[2], uvSet.right[3], flipX), // -x (R)
createFaceMaterial(uvSet.top[0], uvSet.top[1], uvSet.top[2], uvSet.top[3], flipX, true), // +y (T)
createFaceMaterial(uvSet.bottom[0], uvSet.bottom[1], uvSet.bottom[2], uvSet.bottom[3], flipX, true), // -y (B)
createFaceMaterial(uvSet.front[0], uvSet.front[1], uvSet.front[2], uvSet.front[3], flipX), // +z (F)
createFaceMaterial(uvSet.back[0], uvSet.back[1], uvSet.back[2], uvSet.back[3], !flipX) // -z (B)
];
};
const mesh = new THREE.Mesh(geo, getMats(uv));
group.add(mesh);
if (overlayUv) {
const oGeo = new THREE.BoxGeometry(w + 0.5, h + 0.5, d + 0.5);
const oMesh = new THREE.Mesh(oGeo, getMats(overlayUv));
oMesh.visible = showLayers;
overlaysRef.current.push(oMesh);
group.add(oMesh);
}
return group;
};
const limbUv = (x: number, y: number, w = 4) => ({
top: [x + 4, y, w, 4], bottom: [x + 4 + w, y, w, 4],
right: [x, y + 4, 4, 12], front: [x + 4, y + 4, w, 12],
left: [x + 4 + w, y + 4, 4, 12], back: [x + 8 + w, y + 4, w, 12]
});
const isSlim = !isLegacy && (() => {
const canvas = document.createElement('canvas');
canvas.width = img.width; canvas.height = img.height;
const ctx = canvas.getContext('2d');
if (!ctx) return false;
ctx.drawImage(img, 0, 0);
const data = ctx.getImageData(42, 48, 1, 1).data;
return data[3] === 0;
})();
const armW = isSlim ? 3 : 4;
const headUv = { top: [8, 0, 8, 8], bottom: [16, 0, 8, 8], right: [0, 8, 8, 8], left: [16, 8, 8, 8], front: [8, 8, 8, 8], back: [24, 8, 8, 8] };
const hatUv = { top: [40, 0, 8, 8], bottom: [48, 0, 8, 8], right: [32, 8, 8, 8], left: [48, 8, 8, 8], front: [40, 8, 8, 8], back: [56, 8, 8, 8] };
const head = createPart(8, 8, 8, headUv, hatUv);
head.position.y = 10;
playerGroup.add(head);
const bodyUv = { top: [20, 16, 8, 4], bottom: [28, 16, 8, 4], right: [16, 20, 4, 12], left: [28, 20, 4, 12], front: [20, 20, 8, 12], back: [32, 20, 8, 12] };
const jacketUv = { top: [20, 32, 8, 4], bottom: [28, 32, 8, 4], right: [16, 36, 4, 12], left: [28, 36, 4, 12], front: [20, 36, 8, 12], back: [32, 36, 8, 12] };
playerGroup.add(createPart(8, 12, 4, bodyUv, isLegacy ? undefined : jacketUv));
const rightArm = createPart(armW, 12, 4, limbUv(40, 16, armW), isLegacy ? undefined : limbUv(40, 32, armW));
rightArm.position.set(isSlim ? -5.5 : -6, 0, 0);
playerGroup.add(rightArm);
const leftArm = createPart(armW, 12, 4, isLegacy ? limbUv(40, 16, armW) : limbUv(32, 48, armW), isLegacy ? undefined : limbUv(48, 48, armW), true, isLegacy);
leftArm.position.set(isSlim ? 5.5 : 6, 0, 0);
playerGroup.add(leftArm);
const rightLeg = createPart(4, 12, 4, limbUv(0, 16), isLegacy ? undefined : limbUv(0, 32));
rightLeg.position.set(-2, -12, 0);
playerGroup.add(rightLeg);
const leftLeg = createPart(4, 12, 4, isLegacy ? limbUv(0, 16) : limbUv(16, 48), isLegacy ? undefined : limbUv(0, 48), true, isLegacy);
leftLeg.position.set(2, -12, 0);
playerGroup.add(leftLeg);
playerGroup.rotation.y = -0.3;
requestRenderRef.current?.();
});
let isDragging = false;
let previousMousePosition = { x: 0, y: 0 };
const onMouseDown = (e: MouseEvent) => {
isDragging = true;
previousMousePosition = { x: e.clientX, y: e.clientY };
};
const onMouseUp = () => { isDragging = false; };
const onMouseMove = (e: MouseEvent) => {
if (isDragging) {
playerGroup.rotation.y += (e.clientX - previousMousePosition.x) * 0.01;
previousMousePosition = { x: e.clientX, y: e.clientY };
requestRenderRef.current?.();
}
};
renderer.domElement.addEventListener("mousedown", onMouseDown);
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
requestRenderRef.current = () => renderer.render(scene, camera);
requestRenderRef.current();
return () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
scene.traverse((object) => {
if (object instanceof THREE.Mesh) {
if (object.geometry) object.geometry.dispose();
if (object.material) {
if (Array.isArray(object.material)) {
object.material.forEach((mat) => {
if (mat.map) mat.map.dispose();
mat.dispose();
});
} else {
if (object.material.map) object.material.map.dispose();
object.material.dispose();
}
}
}
});
renderer.dispose();
overlaysRef.current = [];
requestRenderRef.current = null;
};
}, [skinUrl]);
useEffect(() => {
overlaysRef.current.forEach(overlay => {
overlay.visible = showLayers;
});
requestRenderRef.current?.();
}, [showLayers]);
useEffect(() => {
if (!isFocusedSection) {
setFocusIndex(legacyMode ? 1 : 0);
return;
}
const handleKeyDown = (e: KeyboardEvent) => {
if (document.activeElement?.tagName === 'INPUT' && e.key !== 'ArrowDown' && e.key !== 'ArrowRight') return;
if (e.key === 'ArrowRight') {
if (legacyMode) onNavigateRight();
else if (focusIndex === 3) onNavigateRight();
else if (focusIndex === 1 || focusIndex === 2) setFocusIndex(prev => prev + 1);
} else if (e.key === 'ArrowLeft') {
if (legacyMode) return;
if (focusIndex === 2 || focusIndex === 3) setFocusIndex(prev => prev - 1);
} else if (e.key === 'ArrowDown') {
if (legacyMode) {
return;
} else {
setFocusIndex(prev => (prev < 3 ? prev + 1 : prev));
}
} else if (e.key === 'ArrowUp') {
if (legacyMode) {
return;
} else {
setFocusIndex(prev => (prev > 0 ? prev - 1 : prev));
}
} else if (e.key === 'Enter') {
if (focusIndex === 0) {
(containerRef.current?.querySelector('input') as HTMLElement)?.focus();
} else if (focusIndex === 1) {
playClickSound();
setActiveView('skins');
} else if (focusIndex === 2) {
playClickSound();
setShowLayers(!showLayers);
} else if (focusIndex === 3) {
playClickSound();
setSkinUrl('/images/Default.png');
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isFocusedSection, focusIndex, onNavigateRight, playClickSound, setActiveView, setShowLayers, showLayers, setSkinUrl, legacyMode]);
useEffect(() => {
if (isFocusedSection) {
const el = containerRef.current?.querySelector(`[data-focus="${focusIndex}"]`) as HTMLElement;
if (el && document.activeElement?.tagName !== 'INPUT') el.focus();
}
}, [isFocusedSection, focusIndex]);
return (
<motion.div
ref={containerRef}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: useConfig().animationsEnabled ? 0.3 : 0 }}
className={`absolute ${legacyMode ? 'left-[calc(50vw-340px)]' : 'left-16'} ${legacyMode ? 'top-1/2' : 'top-[40%]'} -translate-y-1/2 flex flex-col items-center gap-1 outline-none z-10`}
>
{!legacyMode && (
<div className={`bg-black/20 flex justify-center items-center ${legacyMode ? 'mb-0' : 'mb-2'} px-2 py-1 rounded-sm border-2 transition-colors ${isFocusedSection && focusIndex === 0 ? 'border-[#FFFF55]' : 'border-transparent'}`} data-focus="0" tabIndex={0}>
<input
type="text" value={username} maxLength={16}
style={{ width: `${Math.max(username.length, 3) + 2}ch` }}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'ArrowDown') {
e.currentTarget.blur();
e.stopPropagation();
}
}}
className="bg-transparent text-white focus:text-[#FFFF55] outline-none border-none text-center font-['Mojangles'] mc-text-shadow tracking-widest text-xl cursor-default"
/>
</div>
)}
{!legacyMode && (
<div className="w-[220px] h-[380px] relative flex items-center justify-center">
<div ref={mountRef} className="absolute drop-shadow-[0_8px_8px_rgba(0,0,0,0.8)] cursor-ew-resize outline-none w-[260px] h-[450px] -translate-y-6" />
</div>
)}
<div className={`flex ${legacyMode ? 'flex-col gap-2 mt-0' : 'flex-row gap-4 mt-2'} items-center`}>
<button
data-focus="1" tabIndex={0}
onMouseEnter={() => isFocusedSection && setFocusIndex(1)}
onClick={() => { playClickSound(); setActiveView('skins'); }}
className={`mc-sq-btn w-12 h-12 flex items-center justify-center outline-none border-none transition-all ${isFocusedSection && focusIndex === 1 ? 'scale-110' : ''}`}
style={isFocusedSection && focusIndex === 1 ? { backgroundImage: "url('/images/Button_Square_Highlighted.png')" } : {}}
title="Change Skin"
>
<img src="/images/Change_Skin_Icon.png" alt="Skin" className="w-8 h-8 object-contain" style={{ imageRendering: 'pixelated' }} />
</button>
{!legacyMode && (
<button
data-focus="2" tabIndex={0}
onMouseEnter={() => isFocusedSection && setFocusIndex(2)}
onClick={() => { playClickSound(); setShowLayers(!showLayers); }}
className={`mc-sq-btn w-12 h-12 flex items-center justify-center outline-none border-none transition-all ${isFocusedSection && focusIndex === 2 ? 'scale-110' : ''}`}
style={isFocusedSection && focusIndex === 2 ? { backgroundImage: "url('/images/Button_Square_Highlighted.png')" } : {}}
title="Toggle Layers"
>
<img src="/images/Layer_Icon.png" alt="Layers" className="w-8 h-8 object-contain" style={{ imageRendering: 'pixelated' }} />
</button>
)}
{!legacyMode && (
<button
data-focus="3" tabIndex={0}
onMouseEnter={() => isFocusedSection && setFocusIndex(3)}
onClick={() => { playClickSound(); setSkinUrl('/images/Default.png'); }}
className={`mc-sq-btn w-12 h-12 flex items-center justify-center outline-none border-none transition-all ${isFocusedSection && focusIndex === 3 ? 'scale-110' : ''}`}
style={isFocusedSection && focusIndex === 3 ? { backgroundImage: "url('/images/Button_Square_Highlighted.png')" } : {}}
title="Reset to Default"
>
<img src="/images/Trash_Bin_Icon.png" alt="Delete" className="w-8 h-8 object-contain brightness-200" style={{ imageRendering: 'pixelated' }} />
</button>
)}
</div>
</motion.div>
);
});
export default SkinViewer;
+99
View File
@@ -0,0 +1,99 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { motion } from "framer-motion";
import { memo } from "react";
const appWindow = getCurrentWindow();
interface AppHeaderProps {
playClickSound: () => void;
uiFade: any;
}
export const AppHeader = memo(function AppHeader({ playClickSound, uiFade }: AppHeaderProps) {
return (
<motion.div
key="header"
{...uiFade}
data-tauri-drag-region
className="h-10 w-full flex justify-between items-center px-1 absolute top-0 left-0 z-50 bg-gradient-to-b from-black/80 to-transparent"
>
<div
data-tauri-drag-region
className="pl-3 flex items-center justify-center gap-1.5 pointer-events-none h-full pt-0.5"
>
<img
src="/images/icon.png"
alt="Icon"
className="w-4 h-4 object-contain block"
style={{ imageRendering: "pixelated" }}
/>
<span className="text-xs text-gray-300 mc-text-shadow opacity-90 tracking-wide leading-none block pt-[1px]">
Emerald Legacy Launcher
</span>
</div>
<div className="flex items-center gap-1 pr-2">
<button
onClick={() => {
playClickSound();
appWindow.minimize();
}}
className="w-10 h-8 flex items-center justify-center text-gray-300 hover:text-white hover:bg-white/20 transition-all bg-transparent"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="square"
>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
<button
onClick={() => {
playClickSound();
appWindow.toggleMaximize();
}}
className="w-10 h-8 flex items-center justify-center text-gray-300 hover:text-white hover:bg-white/20 transition-all bg-transparent"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="square"
>
<rect x="3" y="3" width="18" height="18"></rect>
</svg>
</button>
<button
onClick={() => {
playClickSound();
appWindow.close();
}}
className="w-10 h-8 flex items-center justify-center text-gray-300 hover:text-white hover:bg-red-600 transition-all bg-transparent"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="square"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</motion.div>
);
});
+54
View File
@@ -0,0 +1,54 @@
import { motion } from "framer-motion";
import { memo } from "react";
interface DownloadOverlayProps {
downloadProgress: number | null;
downloadingId: string | null;
editions: any[];
}
export const DownloadOverlay = memo(function DownloadOverlay({ downloadProgress, downloadingId, editions }: DownloadOverlayProps) {
if (downloadProgress === null) return null;
return (
<motion.div
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 50 }}
className="absolute top-14 right-8 z-100 w-64 p-4 shadow-2xl flex flex-col gap-2"
style={{
backgroundImage: "url('/images/Download_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<div className="flex flex-col gap-1 w-full">
<span className="text-[15px] text-[#FFFF55] mc-text-shadow uppercase tracking-widest text-center w-full">
Downloading
</span>
<div className="text-[10px] text-gray-300 mc-text-shadow truncate uppercase opacity-80 pb-1 text-center w-full">
{editions.find((e) => e.id === downloadingId)?.name || "Game Files"}
</div>
<div className="flex items-center gap-2 w-full">
<span className="text-[10px] text-white mc-text-shadow w-6 text-right shrink-0 flex items-center justify-end h-[14px] leading-none">
{Math.floor(downloadProgress)}%
</span>
<div className="flex-1 h-3.5 border-2 border-white bg-black/40 relative">
<div
className="h-full bg-white transition-all duration-300"
style={{ width: `${downloadProgress}%` }}
/>
</div>
<div className="w-6 flex items-center justify-start shrink-0">
<img
src="/images/loading.gif"
alt="Loading"
className="w-4 h-4 object-contain"
style={{ imageRendering: "pixelated" }}
/>
</div>
</div>
</div>
</motion.div>
);
});
-95
View File
@@ -1,95 +0,0 @@
import React from 'react';
import { openUrl } from "@tauri-apps/plugin-opener";
import { TauriService } from '../../services/tauri';
interface SidebarProps {
activeTab: string;
setActiveTab: (tab: string) => void;
playSfx: (name: string, multiplier?: number) => void;
updateAllStatus: () => void;
installingInstance: string | null;
downloadProgress: number;
}
export const Sidebar: React.FC<SidebarProps> = ({
activeTab,
setActiveTab,
playSfx,
updateAllStatus,
installingInstance,
downloadProgress,
}) => {
return (
<aside className="w-64 bg-[#2a2a2a] border-r-4 border-black p-6 flex flex-col gap-2 z-20 shadow-[inset_-4px_0_#555]">
<div className="mb-10 px-2">
<img src="/images/logo.png" alt="Logo" />
</div>
<nav className="flex flex-col gap-3">
<button
onClick={() => {
playSfx('click.wav');
setActiveTab("home");
updateAllStatus();
}}
className={`p-4 legacy-btn justify-start ${activeTab === "home" ? "active-tab" : ""}`}
>
HOME
</button>
<button
onClick={() => {
playSfx('click.wav');
setActiveTab("versions");
updateAllStatus();
}}
className={`p-4 legacy-btn justify-start ${activeTab === "versions" ? "active-tab" : ""}`}
>
VERSIONS
</button>
<button
onClick={() => {
playSfx('click.wav');
setActiveTab("settings");
}}
className={`p-4 legacy-btn justify-start ${activeTab === "settings" ? "active-tab" : ""}`}
>
SETTINGS
</button>
</nav>
{installingInstance && (
<div className="sidebar-progress mt-auto">
<div className="flex justify-between mb-3 text-slate-300 font-bold text-[10px] uppercase tracking-widest px-1">
<span>Installing</span>
<button
onClick={() => {
playSfx('back.ogg');
TauriService.cancelDownload();
}}
className="text-red-500 hover:underline"
>
CANCEL
</button>
</div>
<div className="mc-progress-container">
<div
className="mc-progress-bar transition-all duration-300"
style={{ width: `${downloadProgress}%` }}
></div>
<div className="mc-progress-text">{downloadProgress}%</div>
</div>
</div>
)}
<div
onClick={() => {
playSfx('click.wav');
openUrl("https://github.com/KayJannOnGit");
}}
className={`${installingInstance ? "pt-6" : "mt-auto pt-6"} flex flex-col items-center border-t-4 border-black/30 cursor-pointer group`}
>
<span className="text-slate-500 text-[10px] uppercase">Developed by</span>
<span className="text-emerald-500 text-sm font-bold group-hover:underline">KayJann</span>
</div>
</aside>
);
};
+186
View File
@@ -0,0 +1,186 @@
import { motion } from "framer-motion";
import { useState, useEffect } from "react";
export default function CustomTUModal({
isOpen,
onClose,
onImport,
playSfx,
editingEdition = null,
}: any) {
const [name, setName] = useState("");
const [desc, setDesc] = useState("");
const [url, setUrl] = useState("");
const [error, setError] = useState("");
const [focusIndex, setFocusIndex] = useState(0);
useEffect(() => {
if (isOpen && editingEdition) {
setName(editingEdition.name);
setDesc(editingEdition.desc);
setUrl(editingEdition.url);
} else if (!isOpen) {
setName("");
setDesc("");
setUrl("");
setError("");
}
}, [editingEdition, isOpen]);
useEffect(() => {
if (!isOpen) {
setFocusIndex(0);
return;
}
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
playSfx("close_click.wav");
onClose();
} else if (e.key === "Enter") {
if (focusIndex === 3) {
playSfx("close_click.wav");
onClose();
} else if (focusIndex === 4 || e.ctrlKey) {
playSfx("save_click.wav");
handleImport();
}
} else if (e.key === "ArrowDown" || e.key === "Tab") {
e.preventDefault();
setFocusIndex((prev) => (prev + 1) % 5);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setFocusIndex((prev) => (prev - 1 + 5) % 5);
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [isOpen, focusIndex, name, desc, url]);
if (!isOpen) return null;
const handleImport = () => {
if (!name || !url) {
setError("Name and URL are required");
return;
}
if (!url.startsWith("http")) {
setError("Invalid URL");
return;
}
setError("");
onImport({ name, desc: desc || "Custom imported TU", url });
onClose();
setName("");
setDesc("");
setUrl("");
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-[100] flex items-center justify-center backdrop-blur-sm outline-none border-none"
>
<div
className="relative w-[450px] p-8 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-6 border-b-2 border-[#373737] pb-2 w-full text-center uppercase font-bold tracking-widest">
{editingEdition ? "Edit Custom TU" : "Import Custom TU"}
</h2>
<div className="flex flex-col gap-5 w-full">
<div className="flex flex-col gap-2">
<label className="text-gray-300 text-sm mc-text-shadow uppercase tracking-widest ml-1">
TU Name
</label>
<input
type="text"
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onFocus={() => setFocusIndex(0)}
placeholder="e.g. My Awesome Mod"
className={`w-full h-12 px-4 bg-black/40 border-2 text-white text-lg transition-colors outline-none font-['Mojangles'] ${focusIndex === 0 ? "border-[#FFFF55]" : "border-[#373737]"}`}
style={{ imageRendering: "pixelated" }}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-gray-300 text-sm mc-text-shadow uppercase tracking-widest ml-1">
Description (Optional)
</label>
<input
type="text"
value={desc}
onChange={(e) => setDesc(e.target.value)}
onFocus={() => setFocusIndex(1)}
placeholder="A brief description..."
className={`w-full h-12 px-4 bg-black/40 border-2 text-white text-lg transition-colors outline-none font-['Mojangles'] ${focusIndex === 1 ? "border-[#FFFF55]" : "border-[#373737]"}`}
style={{ imageRendering: "pixelated" }}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-gray-300 text-sm mc-text-shadow uppercase tracking-widest ml-1">
Download URL (.zip)
</label>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
onFocus={() => setFocusIndex(2)}
placeholder="https://example.com/mod.zip"
className={`w-full h-12 px-4 bg-black/40 border-2 text-white text-lg transition-colors outline-none font-['Mojangles'] ${focusIndex === 2 ? "border-[#FFFF55]" : "border-[#373737]"}`}
style={{ imageRendering: "pixelated" }}
/>
</div>
{error && (
<div className="text-red-500 text-center mc-text-shadow uppercase text-xs tracking-widest mt-1">
{error}
</div>
)}
</div>
<div className="flex gap-4 mt-8 w-full">
<button
onMouseEnter={() => setFocusIndex(3)}
onClick={() => {
playSfx("close_click.wav");
onClose();
}}
className={`flex-1 h-12 flex items-center justify-center text-xl mc-text-shadow transition-all outline-none border-none bg-transparent ${focusIndex === 3 ? "text-[#FFFF55]" : "text-white"}`}
style={{
backgroundImage: focusIndex === 3 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Cancel
</button>
<button
onMouseEnter={() => setFocusIndex(4)}
onClick={() => {
playSfx("save_click.wav");
handleImport();
}}
className={`flex-1 h-12 flex items-center justify-center text-xl mc-text-shadow transition-all outline-none border-none bg-transparent ${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",
}}
>
{editingEdition ? "Save" : "Import"}
</button>
</div>
</div>
</motion.div>
);
}
-49
View File
@@ -1,49 +0,0 @@
import React from 'react';
import { ReinstallModalData } from '../../types';
interface ReinstallModalProps {
data: ReinstallModalData;
onCancel: () => void;
onConfirm: (id: string, url: string) => void;
playSfx: (name: string, multiplier?: number) => void;
}
export const ReinstallModal: React.FC<ReinstallModalProps> = ({
data,
onCancel,
onConfirm,
playSfx,
}) => {
return (
<div className="absolute inset-0 bg-black/80 z-[200] flex items-center justify-center animate-in fade-in">
<div className="bg-[#2a2a2a] border-4 border-black p-8 w-[600px] text-center shadow-[inset_4px_4px_#555,inset_-4px_-4px_#111]">
<h3 className="text-4xl text-[#ff5555] mb-6 font-bold uppercase tracking-widest">
Warning
</h3>
<p className="text-2xl mb-10 leading-relaxed text-white">
Reinstalling will delete all data. Continue?
</p>
<div className="flex gap-6">
<button
onClick={() => {
playSfx('back.ogg');
onCancel();
}}
className="legacy-btn px-8 py-4 text-3xl w-1/2"
>
Cancel
</button>
<button
onClick={() => {
playSfx('click.wav');
onConfirm(data.id, data.url);
}}
className="legacy-btn px-8 py-4 text-3xl w-1/2 confirm-red-btn"
>
Confirm
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,98 @@
import { motion } from "framer-motion";
import { useState, useEffect } from "react";
export default function SpecialThanksModal({
isOpen,
onClose,
playSfx,
}: any) {
const [focusIndex, setFocusIndex] = useState(0);
const contributors = [
{ name: "smartcmd & LCE Community", desc: "Research & Foundations" },
{ name: "Andi_pog & AFanFromWalmart", desc: "Project Testers" }
];
useEffect(() => {
if (!isOpen) {
setFocusIndex(0);
return;
}
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
playSfx("close_click.wav");
onClose();
} else if (e.key === "ArrowDown" || e.key === "Tab") {
e.preventDefault();
setFocusIndex((prev) => (prev + 1) % (contributors.length + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setFocusIndex((prev) => (prev - 1 + (contributors.length + 1)) % (contributors.length + 1));
} else if (e.key === "Enter") {
if (focusIndex === contributors.length) {
playSfx("close_click.wav");
onClose();
}
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [isOpen, focusIndex]);
if (!isOpen) return null;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-[110] flex items-center justify-center bg-black/80 backdrop-blur-sm outline-none border-none"
>
<div
className="relative w-[400px] 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">
Special Thanks
</h2>
<div className="flex flex-col gap-2 w-full items-center max-h-[320px] overflow-y-auto overflow-x-hidden scrollbar-hide py-2">
{contributors.map((item, idx) => (
<div
key={item.name}
onMouseEnter={() => setFocusIndex(idx)}
className={`w-[90%] p-2 flex flex-col items-center justify-center mc-text-shadow transition-transform outline-none border-none bg-transparent ${focusIndex === idx ? "scale-105 text-[#FFFF55]" : "opacity-80 text-white"}`}
>
<span className="text-xl">
{item.name}
</span>
<span className="text-[10px] text-[#A0A0A0] uppercase tracking-widest mt-1">
{item.desc}
</span>
</div>
))}
</div>
<button
onMouseEnter={() => setFocusIndex(contributors.length)}
onClick={() => {
playSfx("close_click.wav");
onClose();
}}
className={`mt-6 w-56 h-12 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none ${focusIndex === contributors.length ? "text-[#FFFF55]" : "text-white"}`}
style={{
backgroundImage: focusIndex === contributors.length
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Close
</button>
</div>
</motion.div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { motion } from "framer-motion";
import { useState, useEffect } from "react";
export default function TeamModal({
isOpen,
onClose,
playClickSound,
playSfx,
}: any) {
const [focusIndex, setFocusIndex] = useState(0);
const team = [
{ name: "Leon", url: "https://github.com/hornyalcoholic" },
{ name: "Criador_Mods", url: "https://github.com/CriadorMods" },
{ name: "journ3ym3m", url: "https://github.com/journ3ym3n" },
{ name: "KayJann", url: "https://github.com/KayJannOnGit" },
{ name: "neoapps", url: "https://github.com/neoapps-dev" },
{ name: "Santiago Fisela", url: "https://github.com/PinkLittleKitty" },
];
useEffect(() => {
if (!isOpen) {
setFocusIndex(0);
return;
}
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
playSfx("close_click.wav");
onClose();
} else if (e.key === "ArrowDown" || e.key === "Tab") {
e.preventDefault();
setFocusIndex((prev) => (prev + 1) % (team.length + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setFocusIndex((prev) => (prev - 1 + (team.length + 1)) % (team.length + 1));
} else if (e.key === "Enter") {
if (focusIndex === team.length) {
playSfx("close_click.wav");
onClose();
} else {
playClickSound();
window.open(team[focusIndex].url, "_blank", "noopener,noreferrer");
}
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [isOpen, focusIndex]);
if (!isOpen) return null;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm outline-none border-none"
>
<div
className="relative w-[360px] 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">
Emerald Team
</h2>
<div className="flex flex-col gap-3 w-full items-center">
{team.map((dev, idx) => (
<a
key={dev.name}
href={dev.url}
target="_blank"
rel="noopener noreferrer"
onClick={() => playClickSound()}
onMouseEnter={() => setFocusIndex(idx)}
className={`w-56 h-10 flex items-center justify-center mc-text-shadow text-xl transition-all outline-none border-none bg-transparent ${focusIndex === idx ? "text-[#FFFF55]" : "text-white"}`}
style={{
backgroundImage: focusIndex === idx
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
{dev.name}
</a>
))}
</div>
<button
onMouseEnter={() => setFocusIndex(team.length)}
onClick={() => {
playSfx("close_click.wav");
onClose();
}}
className={`mt-6 w-56 h-12 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none ${focusIndex === team.length ? "text-[#FFFF55]" : "text-white"}`}
style={{
backgroundImage: focusIndex === team.length
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Close
</button>
</div>
</motion.div>
);
}
-62
View File
@@ -1,62 +0,0 @@
import React from 'react';
import { TauriService } from '../../services/tauri';
import { Runner } from '../../types';
interface FirstRunViewProps {
username: string;
setUsername: (name: string) => void;
isLinux: boolean;
selectedRunner: string;
availableRunners: Runner[];
setIsFirstRun: (val: boolean) => void;
playRandomMusic: () => void;
playSfx: (name: string, multiplier?: number) => void;
ensureAudio: () => void;
}
export const FirstRunView: React.FC<FirstRunViewProps> = ({
username,
setUsername,
isLinux,
selectedRunner,
availableRunners,
setIsFirstRun,
playRandomMusic,
playSfx,
ensureAudio,
}) => {
return (
<div
className="h-screen flex flex-col items-center justify-center bg-black text-white p-12 select-none"
onContextMenu={(e) => e.preventDefault()}
>
<img src="/images/MenuTitle.png" className="w-[500px] mb-12" alt="Menu Title" />
<div className="bg-[#2a2a2a] p-10 border-4 border-black w-full max-w-2xl text-center shadow-[inset_4px_4px_#555,inset_-4px_-4px_#111]">
<h2 className="text-4xl text-emerald-400 mb-4">Welcome to Emerald Legacy!</h2>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full bg-black border-4 border-emerald-900 p-4 text-3xl text-center mb-8 outline-none"
placeholder="Username..."
/>
<button
onClick={() => {
ensureAudio();
playSfx('click.wav');
TauriService.saveConfig({
username,
linuxRunner: isLinux ? selectedRunner : undefined,
});
setIsFirstRun(false);
setTimeout(playRandomMusic, 500);
}}
disabled={!username.trim() || (isLinux && availableRunners.length === 0)}
className="legacy-btn py-4 px-12 text-3xl w-full"
>
Start Setup
</button>
</div>
</div>
);
};
+166 -70
View File
@@ -1,79 +1,175 @@
import React from 'react';
import { useState, useEffect, useMemo, memo } from "react";
import { motion } from "framer-motion";
import { useUI, useConfig, useAudio, useGame } from "../../context/LauncherContext";
interface HomeViewProps {
username: string;
selectedInstance: string;
setSelectedInstance: (id: string) => void;
installedStatus: Record<string, boolean>;
isRunning: boolean;
installingInstance: string | null;
fadeAndLaunch: () => void;
playSfx: (name: string, multiplier?: number) => void;
setActiveTab: (tab: string) => void;
}
const HomeView = memo(function HomeView() {
const { setActiveView, setShowCredits, setShowSpecialThanks, focusSection, onNavigateToSkin } = useUI();
const { profile, legacyMode } = useConfig();
const { playClickSound, playSfx } = useAudio();
const { handleLaunch, isGameRunning, editions, installs, toggleInstall, downloadProgress, downloadingId } = useGame();
export const HomeView: React.FC<HomeViewProps> = ({
username,
selectedInstance,
setSelectedInstance,
installedStatus,
isRunning,
installingInstance,
fadeAndLaunch,
playSfx,
setActiveTab,
}) => {
const hasInstalledInstance = installedStatus.vanilla_tu19 || installedStatus.vanilla_tu24;
const isFocusedSection = focusSection === "menu";
const selectedEdition = editions.find((e: any) => e.id === profile);
const selectedVersionName = selectedEdition?.name || "Game";
const isInstalled = installs.includes(profile);
const isDownloading = downloadingId === profile;
const [menuFocus, setMenuFocus] = useState<number | null>(null);
const buttons = useMemo(
() => [
{
label: isDownloading
? `Downloading... ${Math.floor(downloadProgress || 0)}%`
: isInstalled
? `Play Game`
: `Download ${selectedVersionName}`,
action: isDownloading
? () => {}
: isInstalled
? handleLaunch
: () => toggleInstall(profile),
isDanger: false,
},
{ label: "Help & Options", action: () => setActiveView("settings") },
{ label: "Versions", action: () => setActiveView("versions") },
{ label: "Workshop", action: () => setActiveView("workshop") },
{ label: "Themes & Tools", action: () => setActiveView("themes") },
],
[
isGameRunning,
isDownloading,
downloadProgress,
isInstalled,
selectedVersionName,
handleLaunch,
toggleInstall,
profile,
setActiveView,
],
);
useEffect(() => {
if (!isFocusedSection) {
setMenuFocus(null);
return;
}
const handleKeyDown = (e: KeyboardEvent) => {
if (document.activeElement?.tagName === "INPUT") return;
if (e.key === "ArrowDown")
setMenuFocus((prev) =>
prev === null ? 0 : prev < buttons.length - 1 ? prev + 1 : prev,
);
if (e.key === "ArrowUp")
setMenuFocus((prev) =>
prev === null ? buttons.length - 1 : prev > 0 ? prev - 1 : prev,
);
if (e.key === "ArrowLeft") onNavigateToSkin();
if (e.key === "Enter" && menuFocus !== null) {
buttons[menuFocus].action();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [menuFocus, buttons, playClickSound, isFocusedSection, onNavigateToSkin]);
return (
<div className="flex flex-col items-center text-center animate-in fade-in">
<div className="relative mb-12 flex flex-col items-center">
<img src="/images/MenuTitle.png" className="w-[550px]" alt="Menu Title" />
<div className="splash-text absolute bottom-2 -right-12 text-3xl">
Welcome, {username}!
</div>
</div>
<div className="bg-black/80 p-8 border-4 border-black w-[550px] flex flex-col gap-6 mt-12">
{hasInstalledInstance ? (
<>
<select
value={selectedInstance}
onChange={(e) => {
playSfx('click.wav');
setSelectedInstance(e.target.value);
}}
className="w-full legacy-select p-3 text-2xl outline-none"
>
{installedStatus.vanilla_tu19 && (
<option value="vanilla_tu19">Vanilla Nightly (TU19)</option>
)}
{installedStatus.vanilla_tu24 && (
<option value="vanilla_tu24">Vanilla TU24</option>
)}
</select>
<button
onClick={fadeAndLaunch}
disabled={isRunning || !!installingInstance}
className="legacy-btn py-4 text-6xl w-full"
>
{installingInstance ? "WAITING..." : isRunning ? "RUNNING..." : "PLAY"}
</button>
</>
) : (
<div className="text-center">
<p className="text-2xl text-red-400 mb-6 font-bold uppercase">Game not installed</p>
<button
<motion.div
tabIndex={-1}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: isFocusedSection ? 1 : 0.5, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: useConfig().animationsEnabled ? 0.3 : 0 }}
className="w-full max-w-[540px] flex flex-col space-y-3 outline-none"
>
{buttons.map((btn: any, i: number) => (
<button
key={i}
onMouseEnter={() => isFocusedSection && setMenuFocus(i)}
onMouseLeave={() => setMenuFocus(null)}
onClick={() => {
if (isFocusedSection) {
playClickSound();
btn.action();
}
}}
className={`w-full h-12 flex items-center justify-center text-2xl mc-text-shadow transition-colors outline-none border-none ${menuFocus === i ? (btn.isDanger ? "text-red-400" : "text-[#FFFF55]") : btn.isDanger ? "text-red-500" : "text-white"}`}
style={{
backgroundImage:
menuFocus === i
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
{btn.label}
</button>
))}
{!legacyMode && (
<div className="pt-4 flex flex-col items-center w-full gap-3">
<div className="flex gap-8">
<a
href="https://discord.gg/YBy7kbnR4m"
target="_blank"
rel="noopener noreferrer"
onClick={() => {
playSfx('click.wav');
setActiveTab("versions");
if (isFocusedSection) playClickSound();
}}
className="legacy-btn py-4 px-8 text-3xl w-full"
className={`hover:scale-110 transition-transform ${!isFocusedSection ? "pointer-events-none" : ""}`}
>
Go to Versions
</button>
<img
src="/images/discord.png"
className="w-16 h-16 drop-shadow-md object-contain"
style={{ imageRendering: "pixelated" }}
loading="lazy"
decoding="async"
/>
</a>
<a
href="https://github.com/Emerald-Legacy-Launcher/Emerald-Legacy-Launcher"
target="_blank"
rel="noopener noreferrer"
onClick={() => {
if (isFocusedSection) playClickSound();
}}
className={`hover:scale-110 transition-transform ${!isFocusedSection ? "pointer-events-none" : ""}`}
>
<img
src="/images/github.png"
className="w-16 h-16 drop-shadow-md object-contain"
style={{ imageRendering: "pixelated" }}
loading="lazy"
decoding="async"
/>
</a>
</div>
)}
</div>
</div>
<div className="border-b-[3px] border-[#A0A0A0] w-48 opacity-60" />
<button
onClick={() => {
if (isFocusedSection) {
playSfx("orb.ogg");
setShowCredits(true);
}
}}
className={`text-white hover:text-[#FFFF55] text-xl mc-text-shadow tracking-widest transition-colors mt-1 bg-transparent border-none outline-none ${!isFocusedSection ? "cursor-default pointer-events-none" : ""}`}
>
EMERALD TEAM
</button>
<button
onClick={() => {
if (isFocusedSection) {
playSfx("orb.ogg");
setShowSpecialThanks(true);
}
}}
className={`text-white/60 hover:text-[#FFFF55] text-xs mc-text-shadow tracking-[0.2em] transition-colors bg-transparent border-none outline-none ${!isFocusedSection ? "cursor-default pointer-events-none" : ""}`}
>
SPECIAL THANKS
</button>
</div>
)}
</motion.div>
);
};
});
export default HomeView;
+564 -169
View File
@@ -1,177 +1,572 @@
import React from 'react';
import { Icons } from '../Icons';
import { TauriService } from '../../services/tauri';
import { Runner } from '../../types';
import { openUrl } from "@tauri-apps/plugin-opener";
import { useState, useEffect, useRef, useMemo, memo } from "react";
import { motion } from "framer-motion";
import { TauriService, Runner } from "../../services/TauriService";
import { usePlatform } from "../../hooks/usePlatform";
import { useUI, useConfig, useAudio, useGame } from "../../context/LauncherContext";
interface SettingsViewProps {
username: string;
setUsername: (name: string) => void;
isLinux: boolean;
selectedRunner: string;
setSelectedRunner: (runner: string) => void;
availableRunners: Runner[];
musicVol: number;
setMusicVol: (vol: number) => void;
sfxVol: number;
setSfxVol: (vol: number) => void;
isMuted: boolean;
setIsMuted: (muted: boolean) => void;
playSfx: (name: string, multiplier?: number) => void;
}
const SettingsView = memo(function SettingsView() {
const { setActiveView } = useUI();
const { vfxEnabled, setVfxEnabled, animationsEnabled, setAnimationsEnabled, musicVol: musicVolume, setMusicVol: setMusicVolume, sfxVol: sfxVolume, setSfxVol: setSfxVolume, layout, setLayout, linuxRunner, setLinuxRunner, perfBoost, setPerfBoost, rpcEnabled, setRpcEnabled, legacyMode, setLegacyMode, keepLauncherOpen, setKeepLauncherOpen, enableTrayIcon, setEnableTrayIcon } = useConfig();
const { currentTrack, setCurrentTrack, tracks, playClickSound, playBackSound } = useAudio();
const { isGameRunning, stopGame, isRunnerDownloading, runnerDownloadProgress, downloadRunner } = useGame();
const { isLinux, isMac } = usePlatform();
const [focusIndex, setFocusIndex] = useState<number | null>(null);
const [currentSubMenu, setCurrentSubMenu] = useState<"main" | "audio" | "video" | "controls" | "launcher">("main");
const [runners, setRunners] = useState<Runner[]>([]);
const containerRef = useRef<HTMLDivElement>(null);
export const SettingsView: React.FC<SettingsViewProps> = ({
username,
setUsername,
isLinux,
selectedRunner,
setSelectedRunner,
availableRunners,
musicVol,
setMusicVol,
sfxVol,
setSfxVol,
isMuted,
setIsMuted,
playSfx,
}) => {
return (
<div className="w-full max-w-3xl bg-black/80 p-12 border-4 border-black h-full overflow-y-auto no-scrollbar animate-in fade-in">
<h2 className="text-5xl mb-8 border-b-4 border-white/20 pb-4">Settings</h2>
<div className="flex flex-col gap-10">
<div className="flex flex-col gap-4">
<label className="text-xl text-slate-400 italic">In-game Username</label>
<div className="flex gap-4">
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="flex-1 bg-black border-4 border-slate-700 p-4 text-3xl outline-none focus:border-emerald-500"
/>
<button
onClick={() => {
playSfx('wood click.wav');
TauriService.saveConfig({
username,
linuxRunner: selectedRunner || undefined,
});
}}
className="legacy-btn px-8 text-2xl relative"
>
Save
</button>
</div>
</div>
const layouts = ["KBM", "PLAYSTATION", "XBOX"];
{isLinux && (
<div className="flex flex-col gap-4">
<label className="text-xl text-slate-400 italic flex items-center gap-2">
<Icons.Linux /> Linux Runner
</label>
<div className="flex flex-col gap-2">
<select
value={selectedRunner}
onChange={(e) => {
playSfx('click.wav');
setSelectedRunner(e.target.value);
}}
className="w-full legacy-select p-4 text-2xl outline-none focus:border-emerald-500"
>
<option value="" disabled>Select a runner...</option>
{availableRunners.map((r) => (
<option key={r.id} value={r.id}>
{r.name} ({r.type})
</option>
))}
</select>
{availableRunners.length === 0 && (
<p className="text-red-500 text-sm">
No Proton or Wine installations found. Please install Steam or Wine.
</p>
)}
</div>
</div>
)}
useEffect(() => {
TauriService.getAvailableRunners().then(setRunners);
}, [isRunnerDownloading]);
<div className="flex flex-col gap-4 bg-[#2a2a2a] p-6 border-4 border-black shadow-[inset_4px_4px_#555]">
<label className="text-xl flex items-center gap-4">
<Icons.Volume level={musicVol} /> Audio Controls
</label>
<div className="grid grid-cols-2 gap-8">
<div className="flex flex-col gap-2">
<span className="text-sm uppercase opacity-50">
Music {Math.round(musicVol * 100)}%
</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={musicVol}
onChange={(e) => setMusicVol(parseFloat(e.target.value))}
className="mc-range"
/>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm uppercase opacity-50">
SFX {Math.round(sfxVol * 100)}%
</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={sfxVol}
onChange={(e) => setSfxVol(parseFloat(e.target.value))}
className="mc-range"
/>
</div>
</div>
<button
onClick={() => {
setIsMuted(!isMuted);
playSfx('pop.wav');
}}
className="legacy-btn mt-4 py-2"
>
{isMuted ? "UNMUTE ALL" : "MUTE ALL"}
</button>
</div>
const handleLayoutToggle = () => {
playClickSound();
const currentIndex = layouts.indexOf(layout);
const nextIndex = (currentIndex + 1) % layouts.length;
setLayout(layouts[nextIndex]);
};
<div className="about-section border-4 border-black bg-[#2a2a2a] p-6 shadow-[inset_4px_4px_#555]">
<h3 className="text-2xl text-[#ffff55] mb-2 uppercase tracking-wide">
About the project
</h3>
<p className="text-xl text-white leading-relaxed mb-6 opacity-90">
I'm <span className="text-emerald-400">KayJann</span>, and I absolutely love this project! It's my very first one,
and my goal is to create a central hub for the LCE community to bring us all together.
</p>
<h3 className="text-sm text-slate-500 mb-4 uppercase tracking-widest">Social Links</h3>
<div className="flex gap-6">
<button
onClick={() => openUrl("https://discord.gg/nzbxB8Hxjh")}
className="social-btn btn-discord"
title="Discord"
>
<Icons.Discord />
</button>
<button
onClick={() => openUrl("https://github.com/KayJannOnGit")}
className="social-btn btn-github"
title="GitHub"
>
<Icons.Github />
</button>
<button
onClick={() => openUrl("https://reddit.com/user/KayJann")}
className="social-btn btn-reddit"
title="Reddit"
>
<Icons.Reddit />
</button>
</div>
const handleVfxToggle = () => {
playClickSound();
setVfxEnabled(!vfxEnabled);
};
const handleAnimationsToggle = () => {
playClickSound();
setAnimationsEnabled(!animationsEnabled);
};
const handlePerfToggle = () => {
playClickSound();
setPerfBoost(!perfBoost);
};
const handleRpcToggle = () => {
playClickSound();
setRpcEnabled(!rpcEnabled);
};
const handleLegacyToggle = () => {
playClickSound();
setLegacyMode(!legacyMode);
};
const handleKeepOpenToggle = () => {
playClickSound();
setKeepLauncherOpen(!keepLauncherOpen);
};
const handleTrayToggle = () => {
playClickSound();
setEnableTrayIcon(!enableTrayIcon);
};
const handleRunnerToggle = () => {
playClickSound();
if (runners.length === 0) return;
const currentIndex = runners.findIndex((r) => r.id === linuxRunner);
const nextIndex = (currentIndex + 1) % runners.length;
setLinuxRunner(runners[nextIndex].id);
};
const handleTrackToggle = () => {
playClickSound();
setCurrentTrack((currentTrack + 1) % tracks.length);
};
const handleResetSetup = () => {
playClickSound();
// Create styled confirmation dialog
const dialog = document.createElement('div');
dialog.className = 'fixed inset-0 bg-black/80 flex items-center justify-center z-50';
dialog.innerHTML = `
<div class="relative p-8 max-w-md mx-4" style="background-image: url('/images/frame_background.png'); background-size: 100% 100%; background-repeat: no-repeat; image-rendering: pixelated;">
<h3 class="text-2xl font-bold text-white mb-4 text-center" style="text-shadow: 2px 2px 0px rgba(0,0,0,0.8)">Reset Setup</h3>
<p class="text-white mb-6 text-center">Are you sure you want to reset launcher setup?</p>
<div class="flex gap-4 justify-center">
<button id="reset-yes" class="mc-sq-btn px-6 py-3 text-white hover:scale-105 active:scale-95 transition-transform">Yes</button>
<button id="reset-no" class="mc-sq-btn px-6 py-3 text-white hover:scale-105 active:scale-95 transition-transform">No</button>
</div>
</div>
</div>
`;
document.body.appendChild(dialog);
const handleYes = () => {
document.body.removeChild(dialog);
showSecondConfirmation();
};
const handleNo = () => {
document.body.removeChild(dialog);
};
dialog.querySelector('#reset-yes')?.addEventListener('click', handleYes);
dialog.querySelector('#reset-no')?.addEventListener('click', handleNo);
dialog.addEventListener('click', (e) => {
if (e.target === dialog) {
document.body.removeChild(dialog);
}
});
};
const showSecondConfirmation = () => {
const dialog = document.createElement('div');
dialog.className = 'fixed inset-0 bg-black/80 flex items-center justify-center z-50';
dialog.innerHTML = `
<div class="relative p-8 max-w-md mx-4" style="background-image: url('/images/frame_background.png'); background-size: 100% 100%; background-repeat: no-repeat; image-rendering: pixelated;">
<h3 class="text-2xl font-bold text-yellow-400 mb-4 text-center" style="text-shadow: 2px 2px 0px rgba(0,0,0,0.8)">CONFIRM RESET</h3>
<div class="text-white mb-6 text-left">
<p class="mb-2">⚠️ This will:</p>
<ul class="list-disc list-inside space-y-1 text-sm">
<li>Clear all launcher settings</li>
<li>Reset your username</li>
<li>Show setup screen again</li>
<li>Require reconfiguration</li>
</ul>
<p class="mt-3 text-yellow-400 font-bold">This action cannot be undone!</p>
</div>
<div class="flex gap-4 justify-center">
<button id="reset-final-yes" class="mc-sq-btn px-6 py-3 text-yellow-400 hover:scale-105 active:scale-95 transition-transform">YES, RESET</button>
<button id="reset-final-no" class="mc-sq-btn px-6 py-3 text-white hover:scale-105 active:scale-95 transition-transform">Cancel</button>
</div>
</div>
`;
document.body.appendChild(dialog);
const handleFinalYes = () => {
document.body.removeChild(dialog);
performReset();
};
const handleFinalNo = () => {
document.body.removeChild(dialog);
};
dialog.querySelector('#reset-final-yes')?.addEventListener('click', handleFinalYes);
dialog.querySelector('#reset-final-no')?.addEventListener('click', handleFinalNo);
dialog.addEventListener('click', (e) => {
if (e.target === dialog) {
document.body.removeChild(dialog);
}
});
};
const performReset = () => {
// Clear all localStorage data
localStorage.clear();
// Set setup as not completed
localStorage.setItem('lce-setup-completed', 'false');
// Force reload to show setup screen
window.location.reload();
};
let trackName = "Unknown";
if (tracks && tracks.length > 0) {
const fullPath = tracks[currentTrack];
if (fullPath) {
trackName = fullPath
.split("/")
.pop()
?.replace(".ogg", "")
.replace(".wav", "") || "Unknown";
}
}
const selectedRunnerName =
runners.find((r) => r.id === linuxRunner)?.name || "Native / Default";
type SettingsItem =
| {
id: string;
label: string;
type: "slider";
value: number;
onChange: (val: any) => void;
}
| {
id: string;
label: string;
type: "button";
onClick: () => void;
small?: boolean;
color?: string;
};
const settingsItems = useMemo<SettingsItem[]>(() => {
const items: SettingsItem[] = [];
if (currentSubMenu === "main") {
items.push({
id: "audio_menu",
label: "Audio",
type: "button",
onClick: () => { playClickSound(); setCurrentSubMenu("audio"); setFocusIndex(0); },
});
items.push({
id: "video_menu",
label: "User Interface",
type: "button",
onClick: () => { playClickSound(); setCurrentSubMenu("video"); setFocusIndex(0); },
});
items.push({
id: "controls_menu",
label: "Controls",
type: "button",
onClick: () => { playClickSound(); setCurrentSubMenu("controls"); setFocusIndex(0); },
});
items.push({
id: "launcher_menu",
label: "Options",
type: "button",
onClick: () => { playClickSound(); setCurrentSubMenu("launcher"); setFocusIndex(0); },
});
} else if (currentSubMenu === "audio") {
items.push({
id: "music",
label: `Music: ${musicVolume ?? 50}%`,
type: "slider",
value: musicVolume ?? 50,
onChange: setMusicVolume,
});
items.push({
id: "sfx",
label: `SFX: ${sfxVolume ?? 100}%`,
type: "slider",
value: sfxVolume ?? 100,
onChange: setSfxVolume,
});
items.push({
id: "track",
label: `${trackName} - C418`,
type: "button",
onClick: handleTrackToggle,
});
} else if (currentSubMenu === "video") {
items.push({
id: "vfx",
label: `VFX: ${vfxEnabled ? "ON" : "OFF"}`,
type: "button",
onClick: handleVfxToggle,
});
items.push({
id: "animations",
label: `Animations: ${animationsEnabled ? "ON" : "OFF"}`,
type: "button",
onClick: handleAnimationsToggle,
});
if (isMac) {
items.push({
id: "perf",
label: `M1/M2 Boost: ${perfBoost ? "Enabled" : "Disabled"}`,
type: "button",
onClick: handlePerfToggle,
});
}
} else if (currentSubMenu === "controls") {
items.push({
id: "layout",
label: `Layout: ${layout}`,
type: "button",
onClick: handleLayoutToggle,
});
} else if (currentSubMenu === "launcher") {
items.push({
id: "rpc",
label: `Discord RPC: ${rpcEnabled ? "ON" : "OFF"}`,
type: "button",
onClick: handleRpcToggle,
});
items.push({
id: "legacy",
label: `Legacy Mode: ${legacyMode ? "ON" : "OFF"}`,
type: "button",
onClick: handleLegacyToggle,
});
items.push({
id: "keep_open",
label: `Keep Launcher Open: ${keepLauncherOpen ? "ON" : "OFF"}`,
type: "button",
onClick: handleKeepOpenToggle,
});
items.push({
id: "tray_icon",
label: `Tray Icon: ${enableTrayIcon ? "ON" : "OFF"}`,
type: "button",
onClick: handleTrayToggle,
});
if (isLinux) {
items.push({
id: "runner",
label: `Runner: ${selectedRunnerName}`,
type: "button",
onClick: handleRunnerToggle,
});
if (runners.length === 0 || runners.every(r => r.type !== 'proton')) {
items.push({
id: "download_runner",
label: isRunnerDownloading
? `Downloading Runner... ${Math.floor(runnerDownloadProgress || 0)}%`
: "Download GE-Proton (Recommended)",
type: "button",
onClick: () => {
if (!isRunnerDownloading) {
downloadRunner("GE-Proton9-25", "https://github.com/GloriousEggroll/proton-ge-custom/releases/download/GE-Proton9-25/GE-Proton9-25.tar.gz");
}
},
small: true,
});
}
}
items.push({
id: "reset_setup",
label: "Reset Setup",
type: "button",
onClick: handleResetSetup,
color: "orange",
small: true,
});
}
if (isGameRunning) {
items.push({
id: "stop",
label: "STOP GAME",
type: "button",
onClick: stopGame,
color: "red",
});
}
items.push({
id: "back",
label: currentSubMenu === "main" ? "Done" : "Back",
type: "button",
onClick: () => {
playBackSound();
if (currentSubMenu === "main") {
setActiveView("main");
} else {
setCurrentSubMenu("main");
setFocusIndex(0);
}
},
});
return items;
}, [
currentSubMenu,
musicVolume,
sfxVolume,
trackName,
vfxEnabled,
rpcEnabled,
legacyMode,
animationsEnabled,
keepLauncherOpen,
enableTrayIcon,
layout,
isLinux,
selectedRunnerName,
isRunnerDownloading,
runnerDownloadProgress,
isMac,
perfBoost,
isGameRunning,
handleTrackToggle,
handleVfxToggle,
handleRpcToggle,
handleLegacyToggle,
handleAnimationsToggle,
handleKeepOpenToggle,
handleTrayToggle,
handleLayoutToggle,
handleRunnerToggle,
handlePerfToggle,
handleResetSetup,
stopGame,
downloadRunner,
playClickSound,
playBackSound,
setActiveView,
runners,
]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" || e.key === "Backspace") {
playBackSound();
if (currentSubMenu !== "main") {
setCurrentSubMenu("main");
setFocusIndex(0);
} else {
setActiveView("main");
}
return;
}
const itemCount = settingsItems.length;
if (e.key === "ArrowDown") {
setFocusIndex((prev) =>
prev === null || prev >= itemCount - 1 ? 0 : prev + 1,
);
} else if (e.key === "ArrowUp") {
setFocusIndex((prev) =>
prev === null || prev <= 0 ? itemCount - 1 : prev - 1,
);
} else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
if (focusIndex === null) return;
const item = settingsItems[focusIndex];
if (item.type === "slider") {
const delta = e.key === "ArrowRight" ? 5 : -5;
item.onChange((v: number) => Math.max(0, Math.min(100, v + delta)));
}
} else if (e.key === "Enter" && focusIndex !== null) {
const item = settingsItems[focusIndex];
if (item.type === "button") {
item.onClick();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [focusIndex, settingsItems, playBackSound, setActiveView, currentSubMenu]);
useEffect(() => {
if (focusIndex !== null) {
const el = containerRef.current?.querySelector(
`[data-index="${focusIndex}"]`,
) as HTMLElement;
if (el) el.focus();
}
}, [focusIndex]);
const getItemStyle = (index: number) => ({
backgroundImage:
focusIndex === index
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated" as const,
});
const getSliderStyle = (index: number) => ({
backgroundImage: "url('/images/Button_Background2.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated" as const,
color: focusIndex === index ? "#FFFF55" : "white",
});
return (
<motion.div
ref={containerRef}
tabIndex={-1}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
className="flex flex-col items-center w-full max-w-2xl outline-none"
>
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 border-b-2 border-[#373737] pb-2 w-[40%] max-w-[200px] text-center tracking-widest uppercase opacity-80 font-bold whitespace-nowrap px-4">
{currentSubMenu === "main" ? "Settings" : currentSubMenu === "audio" ? "Audio" : currentSubMenu === "video" ? "User Interface" : currentSubMenu === "controls" ? "Controls" : "Options"}
</h2>
<div className="w-full max-w-[540px] space-y-2 mb-4 p-6 flex flex-col items-center overflow-y-auto max-h-[55vh]">
{settingsItems.map((item, index) => {
if (item.id === "back") return null;
if (item.type === "slider") {
return (
<div
key={item.id}
data-index={index}
tabIndex={0}
onMouseEnter={() => setFocusIndex(index)}
className="relative w-[360px] h-10 flex items-center justify-center cursor-pointer transition-all outline-none border-none hover:text-[#FFFF55] shrink-0"
style={getSliderStyle(index)}
>
<span
className={`absolute z-10 text-xl mc-text-shadow pointer-events-none transition-colors tracking-widest ${focusIndex === index ? "text-[#FFFF55]" : "text-white"}`}
>
{item.label}
</span>
<div className="absolute w-full h-full flex items-center justify-center">
<input
type="range"
min="0"
max="100"
step="1"
value={item.value}
onChange={(e) => item.onChange(parseInt(e.target.value))}
onMouseUp={playClickSound}
className="mc-slider-custom w-[calc(100%+16px)] h-full opacity-100 cursor-pointer z-20 outline-none m-0"
/>
</div>
</div>
);
}
const isRed = (item as any).color === "red";
const isSmall = (item as any).small;
return (
<button
key={item.id}
data-index={index}
onMouseEnter={() => setFocusIndex(index)}
onClick={item.onClick}
className={`w-[360px] h-10 flex items-center justify-center px-4 relative z-30 transition-colors outline-none border-none shrink-0 ${isRed
? focusIndex === index
? "text-red-400"
: "text-red-200"
: focusIndex === index
? "text-[#FFFF55]"
: "text-white"
} ${isRed ? "hover:text-red-500" : "hover:text-[#FFFF55]"}`}
style={getItemStyle(index)}
>
<span
className={`mc-text-shadow tracking-widest uppercase ${isSmall ? "text-xs" : item.label.length > 20 ? "text-lg" : "text-xl"} truncate w-full text-center`}
>
{item.label}
</span>
</button>
);
})}
</div>
{(() => {
const backIndex = settingsItems.findIndex((i) => i.id === "back");
const backItem = settingsItems[backIndex];
if (!backItem || backItem.type !== "button") return null;
return (
<button
data-index={backIndex}
onMouseEnter={() => setFocusIndex(backIndex)}
onClick={backItem.onClick}
className={`w-72 h-10 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] ${focusIndex === backIndex ? "text-[#FFFF55]" : "text-white"
}`}
style={getItemStyle(backIndex)}
>
Back
</button>
);
})()}
</motion.div>
);
};
});
export default SettingsView;
+626
View File
@@ -0,0 +1,626 @@
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { TauriService, Runner } from "../../services/TauriService";
import { usePlatform } from "../../hooks/usePlatform";
import { useConfig, useAudio, useGame } from "../../context/LauncherContext";
interface SetupViewProps {
onComplete: () => void;
}
const SetupView: React.FC<SetupViewProps> = ({ onComplete }) => {
const { isLinux, isMac } = usePlatform();
const {
username, setUsername,
setHasCompletedSetup,
profile,
setEnableTrayIcon: setConfigTray,
setVfxEnabled: setConfigVfx,
setRpcEnabled: setConfigRpc,
setKeepLauncherOpen: setConfigKeepOpen,
setLinuxRunner,
linuxRunner: configLinuxRunner,
vfxEnabled: configVfx,
enableTrayIcon: configTray,
rpcEnabled: configRpc,
keepLauncherOpen: configKeepOpen
} = useConfig();
const { playClickSound, playSfx } = useAudio();
const { editions } = useGame();
const titleImage = editions.find(e => e.id === profile)?.titleImage || "/images/MenuTitle.png";
const [currentStep, setCurrentStep] = useState(0);
const [focusIndex, setFocusIndex] = useState(0);
const [tempUsername, setTempUsername] = useState(username);
const [runners, setRunners] = useState<Runner[]>([]);
const [selectedRunner, setSelectedRunner] = useState<string>("");
const [isSettingUpRuntime, setIsSettingUpRuntime] = useState(false);
const [setupProgress, setSetupProgress] = useState<{ stage: string; message: string; percent?: number } | null>(null);
const [runtimeAlreadyInstalled, setRuntimeAlreadyInstalled] = useState(false);
const [enableTrayIcon, setEnableTrayIcon] = useState(configTray);
const [enableVfx, setEnableVfx] = useState(configVfx);
const [enableDiscordRPC, setEnableDiscordRPC] = useState(configRpc);
const [keepLauncherOpen, setKeepLauncherOpen] = useState(configKeepOpen);
const totalSteps = isLinux ? 4 : 4;
useEffect(() => {
if (isLinux || isMac) {
TauriService.getAvailableRunners().then(availableRunners => {
setRunners(availableRunners);
if (configLinuxRunner && availableRunners.find(r => r.id === configLinuxRunner)) {
setSelectedRunner(configLinuxRunner);
}
});
}
if (isMac) {
checkMacOSRuntime();
const unlisten = TauriService.onMacosProgress((progress) => {
console.log("[macOS Setup Progress]", progress);
setSetupProgress(progress);
});
return () => {
unlisten.then(f => f?.());
};
}
}, [isLinux, isMac]);
const checkMacOSRuntime = async () => {
try {
const localStorageInstalled = localStorage.getItem('lce-macos-runtime-installed') === 'true';
if (localStorageInstalled) {
console.log("[macOS Runtime] Using cached installation status");
try {
const runtimeCheck = await TauriService.checkMacOSRuntimeInstalledFast();
if (runtimeCheck) {
setRuntimeAlreadyInstalled(true);
return;
} else {
console.log("[macOS Runtime] Cache was wrong, clearing");
localStorage.removeItem('lce-macos-runtime-installed');
setRuntimeAlreadyInstalled(false);
return;
}
} catch (error) {
console.log("[macOS Runtime] Fast check failed, using cache");
setRuntimeAlreadyInstalled(true);
return;
}
} else {
console.log("[macOS Runtime] No installation detected");
setRuntimeAlreadyInstalled(false);
}
} catch (error) {
console.error("[macOS Runtime] Error checking:", error);
setRuntimeAlreadyInstalled(false);
}
};
const handleRunnerSelect = (runnerId: string) => {
playClickSound();
setSelectedRunner(runnerId);
};
const handleNext = async () => {
playClickSound();
if (currentStep === 0) {
setUsername(tempUsername);
setCurrentStep(1);
setFocusIndex(0);
} else if (currentStep === 1) {
if (isLinux && selectedRunner) {
setLinuxRunner(selectedRunner);
}
setCurrentStep(2);
setFocusIndex(0);
} else if (currentStep === 2) {
setConfigTray(enableTrayIcon);
setConfigVfx(enableVfx);
setConfigRpc(enableDiscordRPC);
setConfigKeepOpen(keepLauncherOpen);
setCurrentStep(3);
setFocusIndex(0);
} else if (currentStep === 3) {
playSfx("levelup.ogg");
setHasCompletedSetup(true);
onComplete();
}
};
const handleBack = () => {
playClickSound();
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
setFocusIndex(0);
}
};
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
// Elements count per step
let count = 0;
if (currentStep === 0) count = 2; // Input, Next
else if (currentStep === 1) {
if (isLinux) count = runners.length + 2; // Runners, Back, Next
else if (isMac) count = 3; // Install, Back, Next
else count = 2; // Back, Next
} else if (currentStep === 2) count = 6; // 4 Toggles, Back, Next
else if (currentStep === 3) count = 2; // Back, Finish
if (e.key === "ArrowDown" || e.key === "Tab") {
e.preventDefault();
setFocusIndex((prev) => (prev + 1) % count);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setFocusIndex((prev) => (prev - 1 + count) % count);
} else if (e.key === "Enter") {
// Handle enter based on focusIndex and step
if (currentStep === 0) {
if (focusIndex === 0) handleNext(); // For input field
else if (focusIndex === 1) handleNext(); // Next button
} else if (currentStep === 1) {
if (isLinux) {
if (focusIndex < runners.length) handleRunnerSelect(runners[focusIndex].id);
else if (focusIndex === runners.length) handleBack();
else if (focusIndex === runners.length + 1) handleNext();
} else if (isMac) {
if (focusIndex === 0) handleMacosSetup();
else if (focusIndex === 1) handleBack();
else if (focusIndex === 2) handleNext();
} else {
if (focusIndex === 0) handleBack();
else if (focusIndex === 1) handleNext();
}
} else if (currentStep === 2) {
if (focusIndex === 0) { setEnableTrayIcon(!enableTrayIcon); playClickSound(); }
else if (focusIndex === 1) { setEnableVfx(!enableVfx); playClickSound(); }
else if (focusIndex === 2) { setEnableDiscordRPC(!enableDiscordRPC); playClickSound(); }
else if (focusIndex === 3) { setKeepLauncherOpen(!keepLauncherOpen); playClickSound(); }
else if (focusIndex === 4) handleBack();
else if (focusIndex === 5) handleNext();
} else if (currentStep === 3) {
if (focusIndex === 0) handleBack();
else if (focusIndex === 1) handleNext();
}
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [currentStep, focusIndex, runners, enableTrayIcon, enableVfx, enableDiscordRPC, keepLauncherOpen, isLinux, isMac, tempUsername]);
const handleMacosSetup = async () => {
playClickSound();
setIsSettingUpRuntime(true);
setSetupProgress({ stage: "preparing", message: "Preparing macOS runtime setup...", percent: 0 });
try {
console.log("[macOS Setup] Starting runtime installation...");
await TauriService.setupMacosRuntime();
console.log("[macOS Setup] Runtime installation completed successfully!");
setSetupProgress({ stage: "completed", message: "Setup completed successfully!", percent: 100 });
localStorage.setItem('lce-macos-runtime-installed', 'true');
setRuntimeAlreadyInstalled(true);
setTimeout(() => {
setCurrentStep(2);
setIsSettingUpRuntime(false);
setSetupProgress(null);
}, 2000);
} catch (e) {
console.error("[macOS Setup] Error:", e);
setSetupProgress({ stage: "error", message: `Setup failed: ${e}`, percent: 0 });
setIsSettingUpRuntime(false);
}
};
const canProceed = () => {
if (currentStep === 0) {
return tempUsername.trim().length > 0;
}
if (currentStep === 1 && isMac) {
return runtimeAlreadyInstalled;
}
return true;
};
return (
<div className="w-full h-full flex items-center justify-center bg-black">
<div className="relative w-full h-full flex items-center justify-center p-8">
<div className="absolute top-8 left-1/2 transform -translate-x-1/2">
<img
src={titleImage}
alt="Emerald Legacy"
className="h-16"
style={{ imageRendering: "pixelated" }}
/>
</div>
<AnimatePresence mode="wait">
<motion.div
key={currentStep}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: useConfig().animationsEnabled ? 0.2 : 0 }}
className="max-w-2xl w-full mx-auto flex flex-col"
>
<div className="relative p-8 flex flex-col"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
backgroundRepeat: "no-repeat",
imageRendering: "pixelated",
transformOrigin: "center center",
maxHeight: "85vh",
}}>
<div className="overflow-y-auto flex-1" style={{ scrollbarWidth: "thin", scrollbarColor: "#555 transparent" }}>
<div className="flex justify-center space-x-2 mb-8">
{Array.from({ length: totalSteps }, (_, i) => (
<motion.div
key={i}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: useConfig().animationsEnabled ? i * 0.05 : 0 }}
className={`h-2 w-16 transition-all ${i <= currentStep ? "bg-white" : "bg-white/20"
}`}
/>
))}
</div>
<AnimatePresence mode="wait">
<motion.div
key={`content-${currentStep}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: useConfig().animationsEnabled ? 0.3 : 0, delay: useConfig().animationsEnabled ? 0.1 : 0 }}
>
{currentStep === 0 && (
<div className="text-center">
<h2 className="text-3xl font-bold mb-6 text-white" style={{ textShadow: "2px 2px 0px rgba(0,0,0,0.8)" }}>
Welcome to Emerald Legacy
</h2>
<p className="text-lg mb-8 text-white/80">Let's configure your launcher</p>
<div className="space-y-4">
<label className="block text-left">
<span className="text-white font-bold mb-2 block">Username</span>
<input
type="text"
value={tempUsername}
onChange={(e) => setTempUsername(e.target.value)}
onFocus={() => setFocusIndex(0)}
className={`w-full px-4 py-3 bg-black/50 border-2 font-bold focus:outline-none transition-colors ${focusIndex === 0 ? "border-yellow-400" : "border-white"}`}
placeholder="Enter your username"
maxLength={16}
autoFocus
/>
</label>
</div>
</div>
)}
{currentStep === 1 && isMac && (
<div className="text-center">
<h2 className="text-3xl font-bold mb-6 text-white" style={{ textShadow: "2px 2px 0px rgba(0,0,0,0.8)" }}>
macOS Compatibility
</h2>
<p className="text-lg mb-6 text-white/80">
{runtimeAlreadyInstalled
? "Emerald Legacy compatibility runtime is already installed"
: "Emerald Legacy needs compatibility runtime for macOS"
}
</p>
{setupProgress && (
<div className="mb-4 p-4 bg-black/50 border border-white/20 rounded">
<p className="text-sm font-bold text-yellow-400 mb-2">{setupProgress.stage.toUpperCase()}</p>
<p className="text-xs opacity-80">{setupProgress.message}</p>
{setupProgress.percent !== undefined && (
<div className="w-full bg-white/20 h-2 rounded-full mt-3">
<div
className="h-full bg-green-500 rounded-full transition-all duration-300"
style={{ width: `${setupProgress.percent}%` }}
/>
</div>
)}
</div>
)}
<div className="space-y-4">
<div className={`p-4 rounded-lg ${runtimeAlreadyInstalled
? "bg-green-600/20 border-2 border-green-400"
: "bg-yellow-600/20 border-2 border-yellow-400"
}`}>
<p className={`font-bold mb-2 ${runtimeAlreadyInstalled ? "text-green-400" : "text-yellow-400"
}`}>
{runtimeAlreadyInstalled ? "✓ Runtime Detected" : "⚠ Runtime Not Detected"}
</p>
<p className="text-xs text-white/80">
{runtimeAlreadyInstalled
? "The compatibility runtime is properly installed and ready to use."
: "You must install the compatibility runtime before proceeding to the next step."
}
</p>
</div>
<button
onClick={handleMacosSetup}
onMouseEnter={() => setFocusIndex(0)}
disabled={isSettingUpRuntime}
className={`px-6 py-3 text-white font-bold bg-green-600 hover:bg-green-500 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105 active:scale-95 border-4 ${focusIndex === 0 ? "border-yellow-400" : "border-green-400"}`}
style={{
fontFamily: "'Mojangles', monospace",
imageRendering: "pixelated",
textShadow: "2px 2px 0px rgba(0,0,0,0.8)",
boxShadow: "4px 4px 0px rgba(0,0,0,0.3)",
fontSize: "16px",
letterSpacing: "1px"
}}
>
{isSettingUpRuntime ? "Installing..." : runtimeAlreadyInstalled ? "Reinstall Runtime" : "Install Runtime"}
</button>
{!runtimeAlreadyInstalled && (
<p className="text-xs text-red-400 font-bold">
⚠ Installation required before proceeding to next step
</p>
)}
</div>
</div>
)}
{currentStep === 1 && isLinux && (
<div className="text-center">
<h2 className="text-3xl font-bold mb-6 text-white" style={{ textShadow: "2px 2px 0px rgba(0,0,0,0.8)" }}>
Linux Compatibility
</h2>
<p className="text-lg mb-6 text-white/80">Choose your preferred compatibility layer</p>
{runners.length === 0 ? (
<div className="p-4 bg-yellow-500/20 border-2 border-yellow-500/50">
<p className="text-yellow-400">No compatible runners found. Please install Wine or Proton.</p>
</div>
) : (
<div className="space-y-3">
{runners.map((runner, idx) => (
<button
key={runner.id}
onClick={() => handleRunnerSelect(runner.id)}
onMouseEnter={() => setFocusIndex(idx)}
className={`w-full p-4 text-left border-2 transition-all duration-200 ${selectedRunner === runner.id
? "bg-white/20 border-white shadow-[0_0_15px_rgba(255,255,255,0.2)]"
: "bg-black/50 border-white/20"
} ${focusIndex === idx ? "border-yellow-400" : ""}`}
>
<p className="font-bold text-white">{runner.name}</p>
<p className="text-xs text-white/60 mt-1">{runner.type}</p>
</button>
))}
</div>
)}
<p className="text-xs mt-4 text-white/60">You can change this later in settings</p>
</div>
)}
{currentStep === 1 && !isMac && !isLinux && (
<div className="text-center">
<h2 className="text-3xl font-bold mb-6 text-white" style={{ textShadow: "2px 2px 0px rgba(0,0,0,0.8)" }}>
Windows Setup
</h2>
<p className="text-lg mb-6 text-white/80">Everything is ready to go!</p>
<div className="text-green-400 font-bold">✓ Native compatibility</div>
<div className="mt-6 p-4 bg-green-600/20 border-2 border-green-400 rounded-lg">
<p className="text-green-400 font-bold mb-2">✓ Windows Native Support</p>
<p className="text-xs text-white/80">Emerald Legacy runs natively on Windows without additional requirements.</p>
</div>
</div>
)}
{currentStep === 2 && (
<div className="text-center">
<h2 className="text-3xl font-bold mb-6 text-white" style={{ textShadow: "2px 2px 0px rgba(0,0,0,0.8)" }}>
Customize Your Experience
</h2>
<p className="text-lg mb-8 text-white/80">Choose your preferred launcher settings</p>
<div className="space-y-4 max-w-md mx-auto">
<div className="bg-black/50 border-2 border-white/20 p-4">
<div className="flex items-center justify-between">
<div className="text-left">
<p className="text-white font-bold">System Tray Icon</p>
<p className="text-xs text-white/60">Keep launcher accessible in system tray</p>
</div>
<button
onClick={() => {
playClickSound();
setEnableTrayIcon(!enableTrayIcon);
}}
onMouseEnter={() => setFocusIndex(0)}
className={`w-12 h-6 outline-none border-none bg-transparent transition-all duration-200 hover:border-yellow-400 hover:shadow-[0_0_8px_rgba(250,204,21,0.3)] ${focusIndex === 0 ? "scale-110 shadow-[0_0_8px_rgba(250,204,21,0.6)]" : ""}`}
style={{ imageRendering: "pixelated" }}
>
<img
src={enableTrayIcon ? "/images/Toggle_Switch_On.png" : "/images/Toggle_Switch_Off.png"}
alt="Toggle"
className="w-full h-full object-contain"
/>
</button>
</div>
</div>
<div className="bg-black/50 border-2 border-white/20 p-4">
<div className="flex items-center justify-between">
<div className="text-left">
<p className="text-white font-bold">Visual Effects</p>
<p className="text-xs text-white/60">Click particles and animations</p>
</div>
<button
onClick={() => {
playClickSound();
setEnableVfx(!enableVfx);
}}
onMouseEnter={() => setFocusIndex(1)}
className={`w-12 h-6 outline-none border-none bg-transparent transition-all duration-200 hover:border-yellow-400 hover:shadow-[0_0_8px_rgba(250,204,21,0.3)] ${focusIndex === 1 ? "scale-110 shadow-[0_0_8px_rgba(250,204,21,0.6)]" : ""}`}
style={{ imageRendering: "pixelated" }}
>
<img
src={enableVfx ? "/images/Toggle_Switch_On.png" : "/images/Toggle_Switch_Off.png"}
alt="Toggle"
className="w-full h-full object-contain"
/>
</button>
</div>
</div>
<div className="bg-black/50 border-2 border-white/20 p-4">
<div className="flex items-center justify-between">
<div className="text-left">
<p className="text-white font-bold">Discord Rich Presence</p>
<p className="text-xs text-white/60">Show your Emerald Legacy status on Discord</p>
</div>
<button
onClick={() => {
playClickSound();
setEnableDiscordRPC(!enableDiscordRPC);
}}
onMouseEnter={() => setFocusIndex(2)}
className={`w-12 h-6 outline-none border-none bg-transparent transition-all duration-200 hover:border-yellow-400 hover:shadow-[0_0_8px_rgba(250,204,21,0.3)] ${focusIndex === 2 ? "scale-110 shadow-[0_0_8px_rgba(250,204,21,0.6)]" : ""}`}
style={{ imageRendering: "pixelated" }}
>
<img
src={enableDiscordRPC ? "/images/Toggle_Switch_On.png" : "/images/Toggle_Switch_Off.png"}
alt="Toggle"
className="w-full h-full object-contain"
/>
</button>
</div>
</div>
<div className="bg-black/50 border-2 border-white/20 p-4">
<div className="flex items-center justify-between">
<div className="text-left">
<p className="text-white font-bold">Keep Launcher Open</p>
<p className="text-xs text-white/60">Keep launcher running after game launch</p>
</div>
<button
onClick={() => {
playClickSound();
setKeepLauncherOpen(!keepLauncherOpen);
}}
onMouseEnter={() => setFocusIndex(3)}
className={`w-12 h-6 outline-none border-none bg-transparent transition-all duration-200 hover:border-yellow-400 hover:shadow-[0_0_8px_rgba(250,204,21,0.3)] ${focusIndex === 3 ? "scale-110 shadow-[0_0_8px_rgba(250,204,21,0.6)]" : ""}`}
style={{ imageRendering: "pixelated" }}
>
<img
src={keepLauncherOpen ? "/images/Toggle_Switch_On.png" : "/images/Toggle_Switch_Off.png"}
alt="Toggle"
className="w-full h-full object-contain"
/>
</button>
</div>
</div>
</div>
<p className="text-xs mt-6 text-white/60">You can change these later in settings</p>
</div>
)}
{currentStep === 3 && (
<div className="text-center">
<h2 className="text-3xl font-bold mb-6 text-white" style={{ textShadow: "2px 2px 0px rgba(0,0,0,0.8)" }}>
Setup Complete!
</h2>
<div className="space-y-4 mb-8">
<div className="text-left bg-black/50 border-2 border-white/20 p-4">
<p className="text-white">Username: <span className="font-bold text-green-400">{tempUsername}</span></p>
{isMac && (
<p className="text-white">
Runtime: <span className="font-bold text-green-400">Ready</span>
</p>
)}
{isLinux && selectedRunner && (
<p className="text-white">
Runner: <span className="font-bold text-green-400">{runners.find(r => r.id === selectedRunner)?.name}</span>
</p>
)}
<div className="mt-2 pt-2 border-t border-white/20">
<p className="text-white text-sm">Customization:</p>
<div className="flex flex-wrap gap-2 mt-1">
{enableTrayIcon && <span className="text-xs bg-green-600/30 px-2 py-1 border border-green-400">Tray Icon</span>}
{enableVfx && <span className="text-xs bg-green-600/30 px-2 py-1 border border-green-400">Visual Effects</span>}
{enableDiscordRPC && <span className="text-xs bg-green-600/30 px-2 py-1 border border-green-400">Discord RPC</span>}
{keepLauncherOpen && <span className="text-xs bg-green-600/30 px-2 py-1 border border-green-400">Keep Open</span>}
</div>
</div>
</div>
</div>
<p className="text-white/80">Emerald Legacy is now configured and ready to use!</p>
</div>
)}
</motion.div>
</AnimatePresence>
</div>
<div className="flex justify-between mt-4">
{currentStep > 0 && (
<button
onClick={handleBack}
onMouseEnter={() => {
if (currentStep === 0) setFocusIndex(1);
else if (currentStep === 1) setFocusIndex(isLinux ? runners.length : (isMac ? 1 : 0));
else if (currentStep === 2) setFocusIndex(4);
else if (currentStep === 3) setFocusIndex(0);
}}
disabled={currentStep === 0}
className={`mc-setup-nav-btn px-6 py-3 text-white font-bold disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 hover:border-yellow-400 hover:shadow-[0_0_10px_rgba(250,204,21,0.3)] ${(currentStep === 1 && ((isLinux && focusIndex === runners.length) || (isMac && focusIndex === 1) || (!isLinux && !isMac && focusIndex === 0))) ||
(currentStep === 2 && focusIndex === 4) ||
(currentStep === 3 && focusIndex === 0)
? "border-yellow-400 shadow-[0_0_10px_rgba(250,204,21,0.3)]" : ""
}`}
>
Back
</button>
)}
<button
onClick={handleNext}
onMouseEnter={() => {
if (currentStep === 0) setFocusIndex(1);
else if (currentStep === 1) setFocusIndex(isLinux ? runners.length + 1 : (isMac ? 2 : 1));
else if (currentStep === 2) setFocusIndex(5);
else if (currentStep === 3) setFocusIndex(1);
}}
disabled={!canProceed()}
className={`${currentStep > 0 ? '' : 'ml-auto'} mc-setup-nav-btn px-6 py-3 text-white font-bold disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 hover:border-yellow-400 hover:shadow-[0_0_10px_rgba(250,204,21,0.3)] ${(currentStep === 0 && focusIndex === 1) ||
(currentStep === 1 && ((isLinux && focusIndex === runners.length + 1) || (isMac && focusIndex === 2) || (!isLinux && !isMac && focusIndex === 1))) ||
(currentStep === 2 && focusIndex === 5) ||
(currentStep === 3 && focusIndex === 1)
? "border-yellow-400 shadow-[0_0_10px_rgba(250,204,21,0.3)]" : ""
}`}
>
{currentStep === totalSteps - 1 ? "Finish" : "Next"}
</button>
</div>
</div>
</motion.div>
</AnimatePresence>
</div>
</div>
);
};
export default SetupView;
+404
View File
@@ -0,0 +1,404 @@
import { useState, useEffect, useRef, memo } from 'react';
import { motion } from 'framer-motion';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { TauriService } from '../../services/TauriService';
import { useUI, useAudio, useSkin, useConfig } from '../../context/LauncherContext';
interface SavedSkin {
id: string;
name: string;
url: string;
}
const DEFAULT_SKINS: SavedSkin[] = [
{ id: 'default', name: 'Default Steve', url: '/images/Default.png' },
{ id: 'journ3ym3n', name: 'Journ3ym3n', url: '/Skins/Journ3ym3n.png' },
{ id: 'justneki', name: 'JustNeki', url: '/Skins/JustNeki.png' },
{ id: 'kayjann', name: 'KayJann', url: '/Skins/KayJann.png' },
{ id: 'leon', name: 'Leon', url: '/Skins/Leon.png' },
{ id: 'mr_anilex', name: 'mr_anilex', url: '/Skins/mr_anilex.png' },
{ id: 'neoapps', name: 'neoapps', url: '/Skins/neoapps.png' },
{ id: 'peter', name: 'Peter', url: '/Skins/Peter.png' },
];
const SkinsView = memo(function SkinsView() {
const { setActiveView } = useUI();
const { playClickSound, playBackSound } = useAudio();
const { skinUrl, setSkinUrl } = useSkin();
const [focusIndex, setFocusIndex] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [storedSkins, setStoredSkins] = useLocalStorage<SavedSkin[]>('lce-custom-skins', []);
const savedSkins = [...DEFAULT_SKINS, ...storedSkins.filter(s => !DEFAULT_SKINS.some(d => d.id === s.id))];
const TOP_BUTTONS_COUNT = 3; // Import, Delete, Folder
const SKINS_START_INDEX = TOP_BUTTONS_COUNT;
const BACK_BUTTON_INDEX = SKINS_START_INDEX + savedSkins.length;
const ITEM_COUNT = BACK_BUTTON_INDEX + 1;
const setSavedSkins = (newSkins: SavedSkin[] | ((val: SavedSkin[]) => SavedSkin[])) => {
const updatedSkins = typeof newSkins === 'function' ? newSkins(savedSkins) : newSkins;
const customOnes = updatedSkins.filter(s => !DEFAULT_SKINS.some(d => d.id === s.id));
setStoredSkins(customOnes);
};
const [activeSkinId, setActiveSkinId] = useState<string | null>(null);
const [showImportModal, setShowImportModal] = useState(false);
const [modalFocusIndex, setModalFocusIndex] = useState(0);
const [importMode, setImportMode] = useState<'file' | 'username' | null>(null);
const [importUsername, setImportUsername] = useState('');
const [isImporting, setIsImporting] = useState(false);
const [importError, setImportError] = useState('');
const processSkinImage = (url: string, defaultName: string) => {
const img = new Image();
img.crossOrigin = "Anonymous";
img.onload = () => {
const cvs = document.createElement("canvas");
cvs.width = 64;
cvs.height = 32;
const ctx = cvs.getContext("2d");
if (ctx) {
ctx.drawImage(img, 0, 0, 64, 32, 0, 0, 64, 32);
const base64String = cvs.toDataURL("image/png");
const newId = Date.now().toString();
const newSkin = { id: newId, name: defaultName, url: base64String };
setSavedSkins(prev => [...prev, newSkin]);
setSkinUrl(base64String);
setActiveSkinId(newId);
}
};
img.src = url;
};
const handleFetchUsername = async () => {
if (!importUsername.trim()) return;
playClickSound();
setIsImporting(true);
setImportError('');
try {
const [base64Raw, exactName] = await TauriService.fetchSkin(importUsername.trim());
const skinBase64 = `data:image/png;base64,${base64Raw}`;
processSkinImage(skinBase64, exactName.substring(0, 16));
setShowImportModal(false);
setImportMode(null);
setImportUsername('');
} catch (e: any) {
setImportError(typeof e === 'string' ? e : (e.message || 'Failed to fetch skin'));
} finally {
setIsImporting(false);
}
};
useEffect(() => {
if (!activeSkinId) {
const match = savedSkins.find(s => s.url === skinUrl);
if (match) setActiveSkinId(match.id);
}
}, [activeSkinId, savedSkins, skinUrl]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (showImportModal) {
if (e.key === 'Escape') {
playBackSound();
if (importMode) {
setImportMode(null);
setImportUsername('');
setImportError('');
setModalFocusIndex(0);
} else {
setShowImportModal(false);
setModalFocusIndex(0);
}
} else if (e.key === 'ArrowDown' || e.key === 'Tab') {
e.preventDefault();
setModalFocusIndex(prev => (prev + 1) % 3);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setModalFocusIndex(prev => (prev - 1 + 3) % 3);
} else if (e.key === 'Enter') {
if (!importMode) {
if (modalFocusIndex === 0) { playClickSound(); fileInputRef.current?.click(); }
else if (modalFocusIndex === 1) { playClickSound(); setImportMode('username'); setModalFocusIndex(0); }
else if (modalFocusIndex === 2) {
playBackSound();
setShowImportModal(false);
setModalFocusIndex(0);
}
} else {
if (modalFocusIndex === 0 || modalFocusIndex === 1) handleFetchUsername();
else if (modalFocusIndex === 2) {
playBackSound();
setImportMode(null);
setImportUsername('');
setImportError('');
setModalFocusIndex(0);
}
}
}
return;
}
if (document.activeElement?.tagName === 'INPUT') return;
if (e.key === 'Escape') {
playBackSound();
setActiveView('main');
return;
}
if (e.key === 'ArrowRight') {
setFocusIndex(prev => (prev === null || prev >= ITEM_COUNT - 1) ? 0 : prev + 1);
} else if (e.key === 'ArrowLeft') {
setFocusIndex(prev => (prev === null || prev <= 0) ? ITEM_COUNT - 1 : prev - 1);
} else if (e.key === 'ArrowDown') {
if (focusIndex === null || focusIndex < TOP_BUTTONS_COUNT) {
setFocusIndex(SKINS_START_INDEX);
} else if (focusIndex < BACK_BUTTON_INDEX) {
const next = focusIndex + 4;
setFocusIndex(next >= BACK_BUTTON_INDEX ? BACK_BUTTON_INDEX : next);
}
} else if (e.key === 'ArrowUp') {
if (focusIndex === null) {
setFocusIndex(0);
} else if (focusIndex === BACK_BUTTON_INDEX) {
setFocusIndex(SKINS_START_INDEX + savedSkins.length - 1);
} else if (focusIndex >= SKINS_START_INDEX) {
const next = focusIndex - 4;
setFocusIndex(next < SKINS_START_INDEX ? 0 : next);
}
} else if (e.key === 'Enter' && focusIndex !== null) {
if (focusIndex === 0) handleImportClick();
else if (focusIndex === 1) handleDeleteActive();
else if (focusIndex === 2) { playClickSound(); TauriService.openInstanceFolder('Skins').catch(() => { }); }
else if (focusIndex < BACK_BUTTON_INDEX) {
handleSkinSelect(savedSkins[focusIndex - SKINS_START_INDEX]);
} else {
playBackSound();
setActiveView('main');
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [focusIndex, savedSkins.length, playBackSound, setActiveView, playClickSound, showImportModal, importMode, modalFocusIndex, importUsername]);
useEffect(() => {
if (focusIndex !== null) {
const el = containerRef.current?.querySelector(`[data-index="${focusIndex}"]`) as HTMLElement;
if (el) el.focus();
}
}, [focusIndex]);
const handleImportClick = () => {
playClickSound();
setShowImportModal(true);
setModalFocusIndex(0);
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.type !== 'image/png') return;
const defaultName = file.name.replace('.png', '').substring(0, 16);
const reader = new FileReader();
reader.onload = (event) => {
const url = event.target?.result as string;
processSkinImage(url, defaultName);
};
reader.readAsDataURL(file);
e.target.value = '';
setShowImportModal(false);
setImportMode(null);
};
const handleSkinSelect = (skin: SavedSkin) => {
playClickSound();
setActiveSkinId(skin.id);
setSkinUrl(skin.url);
};
const isDefaultSkin = (id: string | null) => DEFAULT_SKINS.some(d => d.id === id);
const handleDeleteActive = () => {
if (!activeSkinId || isDefaultSkin(activeSkinId)) return;
playClickSound();
const updatedSkins = savedSkins.filter(s => s.id !== activeSkinId);
setSavedSkins(updatedSkins);
setSkinUrl('/images/Default.png');
setActiveSkinId('default');
};
const handleNameChange = (id: string, newName: string) => {
const updatedSkins = savedSkins.map(s => s.id === id ? { ...s, name: newName } : s);
setSavedSkins(updatedSkins);
};
const isActiveDefault = isDefaultSkin(activeSkinId) || (!activeSkinId && skinUrl === '/images/Default.png');
return (
<motion.div ref={containerRef} tabIndex={-1} initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} transition={{ duration: useConfig().animationsEnabled ? 0.3 : 0 }} className="flex flex-col items-center w-full max-w-3xl outline-none">
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 border-b-2 border-[#373737] pb-2 w-[60%] max-w-75 text-center tracking-widest uppercase opacity-80 font-bold">Skin Library</h2>
<div className="w-full max-w-160 h-85 mb-4 p-5 shadow-2xl flex flex-col relative" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
<div className="w-full flex items-center border-b-2 border-[#373737] pb-4 mb-4 relative min-h-10">
<div className="absolute left-0 right-0 flex justify-center gap-4 items-center">
<button
data-index="0"
onMouseEnter={() => setFocusIndex(0)}
onClick={handleImportClick}
className={`w-40 h-10 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] ${focusIndex === 0 ? 'text-[#FFFF55]' : 'text-white'}`}
style={{ backgroundImage: focusIndex === 0 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
>
Import Skin
</button>
<button
data-index="1"
onMouseEnter={() => !isActiveDefault && setFocusIndex(1)}
onClick={handleDeleteActive}
className={`w-40 h-10 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none ${isActiveDefault ? 'text-gray-400 opacity-80 cursor-not-allowed' : (focusIndex === 1 ? 'text-[#FFFF55]' : 'text-white')}`}
style={{
backgroundImage: isActiveDefault ? "url('/images/Button_Background2.png')" : (focusIndex === 1 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')"),
backgroundSize: '100% 100%',
imageRendering: 'pixelated'
}}
>
Delete Skin
</button>
</div>
<div className="flex-1"></div>
<div className="flex justify-end z-10">
<button
data-index="2"
onMouseEnter={() => setFocusIndex(2)}
onClick={() => { playClickSound(); TauriService.openInstanceFolder('Skins').catch(() => { }); }}
className={`mc-sq-btn w-10 h-10 flex items-center justify-center outline-none border-none transition-all`}
style={{ backgroundImage: focusIndex === 2 ? "url('/images/Button_Square_Highlighted.png')" : "url('/images/Button_Square.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
>
<img src="/images/Folder_Icon.png" alt="Skins Folder" className="w-8 h-8 object-contain pointer-events-none drop-shadow-md" style={{ imageRendering: 'pixelated' }} loading="lazy" decoding="async" />
</button>
</div>
<input type="file" ref={fileInputRef} onChange={handleFileChange} accept=".png" className="hidden" />
</div>
<div className="flex-1 overflow-y-auto pr-2 flex flex-wrap gap-x-8 gap-y-6 items-start content-start justify-center">
{savedSkins.map((skin, i) => {
const idx = SKINS_START_INDEX + i;
const isActive = activeSkinId ? activeSkinId === skin.id : skinUrl === skin.url;
const isFocused = focusIndex === idx;
return (
<div key={skin.id} data-index={idx} tabIndex={0} onMouseEnter={() => setFocusIndex(idx)} className="flex flex-col items-center gap-1 w-32 outline-none">
<div className="h-4">
{isActive && <span className="text-[#FFFF55] text-xs mc-text-shadow uppercase tracking-widest">Active</span>}
</div>
<div
onClick={() => handleSkinSelect(skin)}
className={`w-16 h-16 bg-black/40 border-2 shadow-inner relative cursor-pointer overflow-hidden transition-colors outline-none ${(isActive || isFocused) ? 'border-[#FFFF55]' : 'border-[#373737] hover:border-[#A0A0A0]'}`}
>
<img src={skin.url} draggable={false} alt={skin.name} className="absolute max-w-none" style={{ width: '800%', height: 'auto', left: '-100%', top: '-100%', imageRendering: 'pixelated' }} loading="lazy" decoding="async" />
</div>
<input
type="text" value={skin.name} maxLength={16}
onChange={(e) => handleNameChange(skin.id, e.target.value)}
className={`bg-transparent text-center outline-none border-none text-base mc-text-shadow w-full truncate transition-colors ${(isActive || isFocused) ? 'text-[#FFFF55]' : 'text-white'} ${isDefaultSkin(skin.id) ? 'pointer-events-none' : ''}`}
onClick={(e) => e.stopPropagation()} spellCheck={false}
readOnly={isDefaultSkin(skin.id)}
/>
</div>
);
})}
</div>
</div>
<button
data-index={BACK_BUTTON_INDEX}
onMouseEnter={() => setFocusIndex(BACK_BUTTON_INDEX)}
onClick={() => { playBackSound(); setActiveView('main'); }}
className={`w-72 h-14 flex items-center justify-center transition-colors text-2xl mc-text-shadow mt-2 outline-none border-none hover:text-[#FFFF55] ${focusIndex === BACK_BUTTON_INDEX ? 'text-[#FFFF55]' : 'text-white'}`}
style={{ backgroundImage: focusIndex === BACK_BUTTON_INDEX ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
>
Back
</button>
{showImportModal && (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="flex flex-col items-center bg-[#252525] p-6 border-4 border-[#373737] shadow-[0_0_20px_rgba(0,0,0,0.8)] relative" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated", minWidth: '400px' }}>
<h2 className="text-2xl text-white mc-text-shadow mb-6 tracking-widest uppercase font-bold text-center">Import Skin</h2>
{!importMode ? (
<div className="flex flex-col gap-4 w-full px-4 mb-2">
<button
onMouseEnter={() => setModalFocusIndex(0)}
onClick={() => { playClickSound(); fileInputRef.current?.click(); }}
className={`w-full h-12 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none ${modalFocusIndex === 0 ? 'text-[#FFFF55]' : 'text-white'}`}
style={{ backgroundImage: modalFocusIndex === 0 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
>
From File
</button>
<button
onMouseEnter={() => setModalFocusIndex(1)}
onClick={() => { playClickSound(); setImportMode('username'); setModalFocusIndex(0); }}
className={`w-full h-12 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none ${modalFocusIndex === 1 ? 'text-[#FFFF55]' : 'text-white'}`}
style={{ backgroundImage: modalFocusIndex === 1 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
>
From Username
</button>
</div>
) : (
<div className="flex flex-col gap-4 w-full px-4 mb-2">
<input
type="text"
placeholder="Minecraft Username"
value={importUsername}
onChange={(e) => setImportUsername(e.target.value)}
onFocus={() => setModalFocusIndex(0)}
autoFocus
spellCheck={false}
className={`w-full h-12 bg-black/50 border-2 text-white px-4 text-xl outline-none transition-colors ${modalFocusIndex === 0 ? 'border-[#FFFF55]' : 'border-[#373737]'}`}
/>
{importError && <span className="text-red-400 text-sm text-center mc-text-shadow">{importError}</span>}
<button
onMouseEnter={() => setModalFocusIndex(1)}
onClick={handleFetchUsername}
disabled={isImporting}
className={`w-full h-12 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none ${isImporting ? 'opacity-50' : (modalFocusIndex === 1 ? 'text-[#FFFF55]' : 'text-white')}`}
style={{ backgroundImage: modalFocusIndex === 1 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
>
{isImporting ? 'Fetching...' : 'Fetch Skin'}
</button>
</div>
)}
<button
onMouseEnter={() => setModalFocusIndex(2)}
onClick={() => {
playBackSound();
setShowImportModal(false);
setImportMode(null);
setImportUsername('');
setImportError('');
setModalFocusIndex(0);
}}
className={`w-40 h-10 flex items-center justify-center transition-colors text-lg mc-text-shadow mt-6 outline-none ${modalFocusIndex === 2 ? 'text-[#FFFF55]' : 'text-white'}`}
style={{ backgroundImage: modalFocusIndex === 2 ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')", backgroundSize: '100% 100%', imageRendering: 'pixelated' }}
>
Cancel
</button>
</div>
</div>
)}
</motion.div>
);
});
export default SkinsView;
+157
View File
@@ -0,0 +1,157 @@
import { useState, useEffect, useRef, memo } from "react";
import { motion } from "framer-motion";
import { TauriService, ThemePalette } from "../../services/TauriService";
import { useUI, useConfig, useAudio } from "../../context/LauncherContext";
const ThemesView = memo(function ThemesView() {
const { setActiveView } = useUI();
const { theme: currentTheme, setTheme } = useConfig();
const { playClickSound, playBackSound } = useAudio();
const [focusIndex, setFocusIndex] = useState<number | null>(null);
const [externalPalettes, setExternalPalettes] = useState<ThemePalette[]>([]);
const containerRef = useRef<HTMLDivElement>(null);
const baseThemes = ["Default", "Modern"];
useEffect(() => {
TauriService.getExternalPalettes().then(setExternalPalettes);
}, []);
const totalPalettes = [...baseThemes, ...externalPalettes.map((p) => p.name)];
const ITEM_COUNT = 3; // Theme Cycle, Import Theme, Back
const handleImport = async () => {
playClickSound();
try {
const result = await TauriService.importTheme();
if (result === "success") {
const updated = await TauriService.getExternalPalettes();
setExternalPalettes(updated);
}
} catch (e) {
console.error(e);
}
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" || e.key === "Backspace") {
playBackSound();
setActiveView("main");
return;
}
if (e.key === "ArrowDown") {
setFocusIndex((prev) =>
prev === null || prev >= ITEM_COUNT - 1 ? 0 : prev + 1,
);
} else if (e.key === "ArrowUp") {
setFocusIndex((prev) =>
prev === null || prev <= 0 ? ITEM_COUNT - 1 : prev - 1,
);
} else if (e.key === "Enter" && focusIndex !== null) {
if (focusIndex === 0) {
playClickSound();
const currentIndex = totalPalettes.indexOf(currentTheme);
const nextIndex = (currentIndex + 1) % totalPalettes.length;
setTheme(totalPalettes[nextIndex]);
} else if (focusIndex === 1) {
handleImport();
} else {
playBackSound();
setActiveView("main");
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
focusIndex,
currentTheme,
playClickSound,
playBackSound,
setActiveView,
setTheme,
totalPalettes,
]);
useEffect(() => {
if (focusIndex !== null) {
const el = containerRef.current?.querySelector(
`[data-index="${focusIndex}"]`,
) as HTMLElement;
if (el) el.focus();
}
}, [focusIndex]);
const getItemStyle = (index: number) => ({
backgroundImage:
focusIndex === index
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated" as const,
});
return (
<motion.div
ref={containerRef}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: useConfig().animationsEnabled ? 0.3 : 0 }}
className="flex flex-col items-center w-full max-w-2xl outline-none"
>
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 border-b-2 border-[#373737] pb-2 w-[60%] max-w-[300px] text-center tracking-widest uppercase opacity-80">
Themes & Styles
</h2>
<div className="w-full max-w-[540px] flex flex-col items-center gap-4 mt-4 mb-8">
<button
data-index="0"
onMouseEnter={() => setFocusIndex(0)}
onClick={() => {
playClickSound();
const currentIndex = totalPalettes.indexOf(currentTheme);
const nextIndex = (currentIndex + 1) % totalPalettes.length;
setTheme(totalPalettes[nextIndex]);
}}
className={`w-72 h-12 flex items-center justify-center px-4 relative transition-colors outline-none border-none hover:text-[#FFFF55] ${focusIndex === 0 ? "text-[#FFFF55]" : "text-white"}`}
style={getItemStyle(0)}
>
<span className="text-2xl mc-text-shadow tracking-widest uppercase">
{currentTheme}
</span>
</button>
<button
data-index="1"
onMouseEnter={() => setFocusIndex(1)}
onClick={handleImport}
className={`w-72 h-12 flex items-center justify-center px-4 relative transition-colors outline-none border-none hover:text-[#FFFF55] ${focusIndex === 1 ? "text-[#FFFF55]" : "text-white"}`}
style={getItemStyle(1)}
>
<span className="text-xl mc-text-shadow tracking-widest uppercase">
Import Theme
</span>
</button>
</div>
<button
data-index="2"
onMouseEnter={() => setFocusIndex(2)}
onClick={() => {
playBackSound();
setActiveView("main");
}}
className={`w-72 h-12 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] ${focusIndex === 2 ? "text-[#FFFF55]" : "text-white"}`}
style={getItemStyle(2)}
>
Back
</button>
</motion.div>
);
});
export default ThemesView;
+586 -88
View File
@@ -1,96 +1,594 @@
import React from 'react';
import { TauriService } from '../../services/tauri';
import { ReinstallModalData } from '../../types';
import { useState, useEffect, useRef, memo } from "react";
import { motion } from "framer-motion";
import { TauriService } from "../../services/TauriService";
import CustomTUModal from "../modals/CustomTUModal";
import { useUI, useConfig, useAudio, useGame } from "../../context/LauncherContext";
interface VersionsViewProps {
installedStatus: Record<string, boolean>;
installingInstance: string | null;
executeInstall: (id: string, url: string) => void;
setReinstallModal: (data: ReinstallModalData | null) => void;
playSfx: (name: string, multiplier?: number) => void;
}
const VersionsView = memo(function VersionsView() {
const { setActiveView } = useUI();
const { profile: selectedProfile, setProfile: setSelectedProfile } = useConfig();
const { playClickSound, playBackSound, playSfx } = useAudio();
const { editions, installs: installedVersions, toggleInstall, handleUninstall: onUninstall, deleteCustomEdition: onDeleteEdition, addCustomEdition: onAddEdition, updateCustomEdition: onUpdateEdition, downloadingId } = useGame();
export const VersionsView: React.FC<VersionsViewProps> = ({
installedStatus,
installingInstance,
executeInstall,
setReinstallModal,
playSfx,
}) => {
const versions = [
{
id: "vanilla_tu19",
name: "Vanilla Nightly (TU19)",
desc: "Leaked 4J Studios build.",
url: "https://huggingface.co/datasets/KayJann/emerald-legacy-assets/resolve/main/emerald_tu19_vanilla.zip"
},
{
id: "vanilla_tu24",
name: "Vanilla TU24",
desc: "Horses and Wither update.",
url: "https://huggingface.co/datasets/KayJann/emerald-legacy-assets/resolve/main/emerald_tu24_vanilla.zip"
}
];
const [focusRow, setFocusRow] = useState<number>(0);
const [focusCol, setFocusCol] = useState<number>(0);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [editingEdition, setEditingEdition] = useState<any>(null);
const containerRef = useRef<HTMLDivElement>(null);
const ITEM_COUNT = editions.length + 2;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (document.activeElement?.tagName === "INPUT") return;
if (e.key === "Escape" || e.key === "Backspace") {
playBackSound();
setActiveView("main");
return;
}
if (e.key === "ArrowDown") {
setFocusRow((prev) => (prev >= ITEM_COUNT - 1 ? 0 : prev + 1));
setFocusCol(0);
} else if (e.key === "ArrowUp") {
setFocusRow((prev) => (prev <= 0 ? ITEM_COUNT - 1 : prev - 1));
setFocusCol(0);
} else if (e.key === "ArrowRight") {
if (focusRow < editions.length) {
const edition = editions[focusRow];
const isInstalled = installedVersions.includes(edition.id);
const isCustom = edition.id.startsWith("custom_");
const hasCredits = !isCustom && edition.credits;
let maxCol = 1;
if (isInstalled) maxCol = 3;
if (isCustom) maxCol = isInstalled ? 5 : 3;
if (hasCredits) maxCol = Math.max(maxCol, 0); // credits button is at col -1
setFocusCol((prev) => (prev < maxCol ? prev + 1 : prev));
}
} else if (e.key === "ArrowLeft") {
if (focusRow < editions.length) {
const edition = editions[focusRow];
const isCustom = edition.id.startsWith("custom_");
const hasCredits = !isCustom && edition.credits;
if (hasCredits && focusCol > -1) {
setFocusCol(-1);
} else if (focusCol > 0) {
setFocusCol((prev) => prev - 1);
}
} else {
setFocusCol((prev) => (prev > 0 ? prev - 1 : prev));
}
} else if (e.key === "Enter") {
if (focusRow < editions.length) {
const edition = editions[focusRow];
const isInstalled = installedVersions.includes(edition.id);
const isCustom = edition.id.startsWith("custom_");
if (focusCol === -1) {
// Credits button
if (edition.credits) {
playClickSound();
window.open(edition.credits.url, '_blank');
}
} else if (focusCol === 1) {
if (!downloadingId) {
playClickSound();
toggleInstall(edition.id);
}
} else if (focusCol === 2) {
if (isInstalled) {
playClickSound();
TauriService.openInstanceFolder(edition.id);
} else if (isCustom) {
playClickSound();
setEditingEdition(edition);
setIsImportModalOpen(true);
}
} else if (focusCol === 3) {
if (isInstalled) {
playBackSound();
onUninstall(edition.id);
} else if (isCustom) {
playBackSound();
onDeleteEdition(edition.id);
}
} else if (focusCol === 4) {
if (isCustom) {
playClickSound();
setEditingEdition(edition);
setIsImportModalOpen(true);
}
} else if (focusCol === 5) {
if (isCustom) {
playBackSound();
onDeleteEdition(edition.id);
}
}
} else if (focusRow === editions.length) {
playClickSound();
setIsImportModalOpen(true);
} else {
playBackSound();
setActiveView("main");
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [editions, focusRow, focusCol, downloadingId, installedVersions, onUninstall, onDeleteEdition, ITEM_COUNT]);
useEffect(() => {
const el = containerRef.current?.querySelector(
`[data-row="${focusRow}"][data-col="${focusCol}"]`,
) as HTMLElement;
if (el) el.focus();
}, [focusRow, focusCol]);
return (
<div className="w-full max-w-3xl bg-black/80 p-12 border-4 border-black h-full overflow-y-auto no-scrollbar animate-in fade-in">
<h2 className="text-5xl mb-8 border-b-4 border-white/20 pb-4">Instances</h2>
<div className="flex flex-col gap-6">
{versions.map(v => (
<div key={v.id} className="flex justify-between items-center bg-[#2a2a2a] border-4 border-black p-6">
<div>
<h3 className="text-2xl font-bold">{v.name}</h3>
<p className="text-slate-400 text-sm">{v.desc}</p>
</div>
<div className="flex gap-2">
{installedStatus[v.id] ? (
<>
<button
onClick={() => {
playSfx('pop.wav');
TauriService.openInstanceFolder(v.id);
}}
className="legacy-btn px-4 py-2 text-xl"
>
Folder
</button>
<button
onClick={() => {
playSfx('click.wav');
setReinstallModal({ id: v.id, url: v.url });
}}
disabled={!!installingInstance}
className="legacy-btn px-4 py-2 text-xl reinstall-btn"
>
Reinstall
</button>
</>
) : (
<button
onClick={() => {
playSfx('click.wav');
executeInstall(v.id, v.url);
}}
disabled={!!installingInstance}
className="legacy-btn px-6 py-2 text-xl"
>
INSTALL
</button>
)}
</div>
</div>
))}
<motion.div
ref={containerRef}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: useConfig().animationsEnabled ? 0.3 : 0 }}
className="flex flex-col items-center w-full max-w-4xl outline-none"
>
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 border-b-2 border-[#373737] pb-2 w-[40%] max-w-[200px] text-center tracking-widest uppercase opacity-80 font-bold">
Versions
</h2>
{['TU75', 'TU9', 'Modded Pack'].map(v => (
<div key={v} className="flex justify-between items-center bg-[#1a1a1a] border-4 border-black p-6 opacity-50 grayscale">
<div>
<h3 className="text-2xl font-bold text-slate-500">Vanilla {v}</h3>
<p className="text-slate-600 text-sm">Legacy version.</p>
</div>
<span className="text-[#ffff55] text-2xl font-bold italic">SOON</span>
<div className="w-full max-w-[740px] h-[380px] overflow-y-auto mb-6 p-6 relative">
<div
className="w-full p-6"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
backgroundRepeat: "no-repeat",
imageRendering: "pixelated",
minHeight: "340px",
}}
>
<div className="flex flex-col gap-3">
{editions.map((edition: any, i: number) => {
const isInstalled = installedVersions.includes(edition.id);
const isSelected = selectedProfile === edition.id;
const isRowFocused = focusRow === i;
const isCustom = edition.id.startsWith("custom_");
const isPlaceholder = edition.id === "lmrp_placeholder";
return (
<div
key={edition.id}
className={`w-full p-4 flex items-center transition-all border-none outline-none overflow-hidden relative ${isPlaceholder ? 'bg-gray-800/50 border-2 border-gray-600 opacity-50' : isSelected ? 'bg-[#50C878]/20 border-2 border-[#50C878]' :
isRowFocused ? 'bg-white/5 border-2 border-white/50' :
'bg-black/30 border-2 border-transparent hover:bg-white/5'
}`}
onMouseEnter={() => {
if (!isPlaceholder) {
setFocusRow(i);
setFocusCol(0);
}
}}
onClick={() => {
if (!isPlaceholder && isInstalled) {
playClickSound();
setSelectedProfile(edition.id);
}
}}
style={{
backdropFilter: 'blur(4px)',
cursor: isPlaceholder ? 'not-allowed' : isInstalled ? 'pointer' : 'default',
imageRendering: 'pixelated',
borderRadius: '0'
}}
>
<div className="flex flex-col flex-1">
<div className="flex items-center gap-3 mb-1">
<span
className={`text-xl mc-text-shadow ${isSelected ? "text-[#50C878]" : "text-white"}`}
style={{ imageRendering: 'pixelated' }}
>
{edition.name}
</span>
{isCustom && (
<span
className="text-[10px] bg-[#50C878] text-black px-1 font-bold uppercase mc-text-shadow-none"
style={{ imageRendering: 'pixelated' }}
>
Custom
</span>
)}
{edition.id === "revelations_edition" && (
<>
<span
className="text-[10px] bg-[#50C878] text-white px-1 font-bold uppercase mc-text-shadow-none"
style={{ imageRendering: 'pixelated' }}
>
New
</span>
<span
className="text-[10px] bg-[#FFD700] text-black px-1 font-bold uppercase mc-text-shadow-none"
style={{ imageRendering: 'pixelated' }}
>
Recommended
</span>
</>
)}
{edition.id === "360revived" && (
<span
className="text-[10px] bg-[#50C878] text-white px-1 font-bold uppercase mc-text-shadow-none"
style={{ imageRendering: 'pixelated' }}
>
New
</span>
)}
{edition.id === "lmrp_placeholder" && (
<span
className="text-[10px] bg-gray-500 text-white px-1 font-bold uppercase mc-text-shadow-none"
style={{ imageRendering: 'pixelated' }}
>
Coming Soon
</span>
)}
{!isCustom && edition.credits && (
<span
className="text-xs text-[#B0B0B0] mc-text-shadow"
style={{ imageRendering: 'pixelated' }}
>
by {edition.credits.developer}
</span>
)}
</div>
<div
className="text-sm text-[#E0E0E0] mc-text-shadow"
style={{ imageRendering: 'pixelated' }}
>
{edition.desc}
</div>
</div>
{!isPlaceholder && (
<div className="flex items-center gap-2">
{!isCustom && edition.credits && (
<button
data-row={i}
data-col={-1}
onMouseEnter={(e) => {
e.stopPropagation();
setFocusRow(i);
setFocusCol(-1);
}}
onClick={(e) => {
e.stopPropagation();
playClickSound();
window.open(edition.credits.url, '_blank');
}}
className={`mc-sq-btn w-8 h-8 flex items-center justify-center outline-none border-none transition-all`}
style={{
backgroundImage:
isRowFocused && focusCol === -1
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
title={`Credits: ${edition.credits.developer} (${edition.credits.platform})`}
>
{edition.credits?.platform === "codeberg" ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
className="text-white drop-shadow-md"
>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-white drop-shadow-md"
>
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path>
</svg>
)}
</button>
)}
{!isInstalled ? (
<button
data-row={i}
data-col={1}
onMouseEnter={(e) => {
e.stopPropagation();
setFocusRow(i);
setFocusCol(1);
}}
onClick={(e) => {
e.stopPropagation();
if (!downloadingId) {
playClickSound();
toggleInstall(edition.id);
}
}}
className={`mc-sq-btn w-8 h-8 flex items-center justify-center outline-none border-none transition-all ${downloadingId === edition.id ? "opacity-100" : downloadingId ? "opacity-50" : ""}`}
style={{
backgroundImage:
isRowFocused && focusCol === 1
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
{downloadingId === edition.id ? (
<img
src="/images/loading.gif"
alt="Loading"
className="w-6 h-6 object-contain pointer-events-none drop-shadow-md"
style={{ imageRendering: "pixelated" }}
loading="lazy"
decoding="async"
/>
) : (
<img
src="/images/Download_Icon.png"
alt="Download"
className="w-6 h-6 object-contain pointer-events-none drop-shadow-md"
style={{ imageRendering: "pixelated" }}
loading="lazy"
decoding="async"
/>
)}
</button>
) : (
<>
<button
data-row={i}
data-col={2}
onMouseEnter={(e) => {
e.stopPropagation();
setFocusRow(i);
setFocusCol(2);
}}
onClick={(e) => {
e.stopPropagation();
playClickSound();
TauriService.openInstanceFolder(edition.id);
}}
className="mc-sq-btn w-8 h-8 flex items-center justify-center outline-none border-none transition-all"
style={{
backgroundImage:
isRowFocused && focusCol === 2
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<img
src="/images/Folder_Icon.png"
alt="Folder"
className="w-6 h-6 object-contain pointer-events-none drop-shadow-md"
style={{ imageRendering: "pixelated" }}
loading="lazy"
decoding="async"
/>
</button>
<button
data-row={i}
data-col={3}
onMouseEnter={(e) => {
e.stopPropagation();
setFocusRow(i);
setFocusCol(3);
}}
onClick={(e) => {
e.stopPropagation();
playBackSound();
onUninstall(edition.id);
}}
className="mc-sq-btn w-8 h-8 flex items-center justify-center outline-none border-none transition-all"
style={{
backgroundImage:
isRowFocused && focusCol === 3
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="square"
className="text-white drop-shadow-md"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</>
)}
{isCustom && (
<>
<button
data-row={i}
data-col={isInstalled ? 4 : 2}
onMouseEnter={(e) => {
e.stopPropagation();
setFocusRow(i);
setFocusCol(isInstalled ? 4 : 2);
}}
onClick={(e) => {
e.stopPropagation();
playClickSound();
setEditingEdition(edition);
setIsImportModalOpen(true);
}}
className="mc-sq-btn w-8 h-8 flex items-center justify-center outline-none border-none transition-all"
style={{
backgroundImage:
isRowFocused && focusCol === (isInstalled ? 4 : 2)
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="square"
className="text-white drop-shadow-md"
>
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
</button>
<button
data-row={i}
data-col={isInstalled ? 5 : 3}
onMouseEnter={(e) => {
e.stopPropagation();
setFocusRow(i);
setFocusCol(isInstalled ? 5 : 3);
}}
onClick={(e) => {
e.stopPropagation();
playBackSound();
onDeleteEdition(edition.id);
}}
className="mc-sq-btn w-8 h-8 flex items-center justify-center outline-none border-none transition-all"
style={{
backgroundImage:
isRowFocused && focusCol === (isInstalled ? 5 : 3)
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="square"
className="text-red-500 drop-shadow-md"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</>
)}
</div>
)}
</div>
);
})}
</div>
))}
</div>
</div>
</div>
<div className="flex gap-4 mb-6">
<button
data-row={editions.length}
data-col={0}
onMouseEnter={() => {
setFocusRow(editions.length);
setFocusCol(0);
}}
onClick={() => {
playClickSound();
setIsImportModalOpen(true);
}}
className={`w-72 h-14 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none ${focusRow === editions.length ? "text-[#50C878]" : "text-white"}`}
style={{
backgroundImage:
focusRow === editions.length
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Import Custom TU
</button>
<button
data-row={editions.length + 1}
data-col={0}
onMouseEnter={() => {
setFocusRow(editions.length + 1);
setFocusCol(0);
}}
onClick={() => {
playBackSound();
setActiveView("main");
}}
className={`w-72 h-14 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none ${focusRow === editions.length + 1 ? "text-[#50C878]" : "text-white"}`}
style={{
backgroundImage:
focusRow === editions.length + 1
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Back
</button>
</div>
<CustomTUModal
isOpen={isImportModalOpen}
onClose={() => {
setIsImportModalOpen(false);
setEditingEdition(null);
}}
onImport={(ed: any) => {
if (editingEdition) {
onUpdateEdition(editingEdition.id, ed);
} else {
const id = onAddEdition(ed);
setSelectedProfile(id);
}
}}
playSfx={playSfx}
editingEdition={editingEdition}
/>
</motion.div>
);
};
});
export default VersionsView;
+47
View File
@@ -0,0 +1,47 @@
import { useState, useEffect, memo } from 'react';
import { motion } from 'framer-motion';
import { useUI, useAudio, useConfig } from '../../context/LauncherContext';
const WorkshopView = memo(function WorkshopView() {
const { setActiveView } = useUI();
const { playBackSound } = useAudio();
const [backHover, setBackHover] = useState(false);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' || e.key === 'Backspace') {
playBackSound();
setActiveView('main');
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [playBackSound, setActiveView]);
return (
<motion.div tabIndex={0} initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} transition={{ duration: useConfig().animationsEnabled ? 0.3 : 0 }} className="flex flex-col items-center w-full max-w-4xl outline-none"
>
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 border-b-2 border-[#373737] pb-2 w-[60%] max-w-[300px] text-center tracking-widest uppercase opacity-80">Workshop</h2>
<div className="w-full max-w-135 h-48 mb-6 p-8 shadow-2xl flex items-center justify-center" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
<span className="text-[#E0E0E0] text-xl mc-text-shadow tracking-wide">Workshop support coming soon...</span>
</div>
<button
onMouseEnter={() => setBackHover(true)}
onMouseLeave={() => setBackHover(false)}
onClick={() => { playBackSound(); setActiveView('main'); }}
className={`w-72 h-12 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] ${backHover ? 'text-[#FFFF55]' : 'text-white'}`}
style={{
backgroundImage: backHover ? "url('/images/button_highlighted.png')" : "url('/images/Button_Background.png')",
backgroundSize: '100% 100%',
imageRendering: 'pixelated'
}}
>
Back
</button>
</motion.div>
);
});
export default WorkshopView;