feat(SkinsView): new UI refresh (wip), add ency and individul

This commit is contained in:
neoapps-dev
2026-09-18 15:00:56 +03:00
parent a243b8362c
commit 31f3e2d9d8
4 changed files with 880 additions and 271 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 B

+614
View File
@@ -0,0 +1,614 @@
import { useEffect, useRef, memo } from "react";
import * as THREE from "three";
type UVSet = Record<string, number[]>;
interface SkinModel3DProps {
src: string;
slim?: boolean;
capeUrl?: string | null;
animate?: boolean;
className?: string;
}
const clamp = (v: number, min: number, max: number) =>
Math.min(max, Math.max(min, v));
let snapshotChain: Promise<void> = Promise.resolve();
const snapshotCache = new Map<string, string>();
function createFaceMaterial(
tex: THREE.Texture,
x: number,
y: number,
w: number,
h: number,
flipX = false,
flipY = false,
) {
const img = tex.image as { width: number; height: number } | undefined;
const imgH = img && img.height ? img.height : 64;
const matTex = tex.clone();
matTex.repeat.set((flipX ? -w : w) / 64, (flipY ? -h : h) / imgH);
matTex.offset.set((flipX ? x + w : x) / 64, 1 - (flipY ? y : y + h) / imgH);
matTex.needsUpdate = true;
return new THREE.MeshLambertMaterial({
map: matTex,
transparent: true,
alphaTest: 0.5,
side: THREE.FrontSide,
});
}
function createPart(
tex: THREE.Texture,
w: number,
h: number,
d: number,
uv: UVSet,
overlayUv?: UVSet,
swapMats = false,
isLegacyMirror = false,
) {
const group = new THREE.Group();
const geo = new THREE.BoxGeometry(w, h, d);
const getMats = (uvSet: UVSet) => {
const flipX = isLegacyMirror;
return [
createFaceMaterial(
tex,
swapMats ? uvSet.right[0] : uvSet.left[0],
uvSet.left[1],
uvSet.left[2],
uvSet.left[3],
flipX,
),
createFaceMaterial(
tex,
swapMats ? uvSet.left[0] : uvSet.right[0],
uvSet.right[1],
uvSet.right[2],
uvSet.right[3],
flipX,
),
createFaceMaterial(
tex,
uvSet.top[0],
uvSet.top[1],
uvSet.top[2],
uvSet.top[3],
flipX,
true,
),
createFaceMaterial(
tex,
uvSet.bottom[0],
uvSet.bottom[1],
uvSet.bottom[2],
uvSet.bottom[3],
flipX,
true,
),
createFaceMaterial(
tex,
uvSet.front[0],
uvSet.front[1],
uvSet.front[2],
uvSet.front[3],
flipX,
),
createFaceMaterial(
tex,
uvSet.back[0],
uvSet.back[1],
uvSet.back[2],
uvSet.back[3],
!flipX,
),
];
};
group.add(new THREE.Mesh(geo, getMats(uv)));
if (overlayUv) {
const oGeo = new THREE.BoxGeometry(w + 0.5, h + 0.5, d + 0.5);
group.add(new THREE.Mesh(oGeo, getMats(overlayUv)));
}
return group;
}
const limbUv = (x: number, y: number, w = 4): UVSet => ({
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],
});
function buildPlayer(
playerGroup: THREE.Group,
texture: THREE.Texture,
slimOpt?: boolean,
) {
const img = texture.image as HTMLImageElement;
const isLegacy = (img?.height || 64) === 32;
const isSlim =
slimOpt !== undefined
? slimOpt
: !isLegacy &&
(() => {
const canvas = document.createElement("canvas");
canvas.width = img?.width || 64;
canvas.height = img?.height || 64;
const ctx = canvas.getContext("2d");
if (!ctx) return false;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
try {
return ctx.getImageData(42, 48, 1, 1).data[3] === 0;
} catch {
return false;
}
})();
const armW = isSlim ? 3 : 4;
const headUv: UVSet = {
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: UVSet = {
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(texture, 8, 8, 8, headUv, hatUv);
head.position.y = 10;
playerGroup.add(head);
const bodyUv: UVSet = {
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: UVSet = {
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(texture, 8, 12, 4, bodyUv, isLegacy ? undefined : jacketUv),
);
const rightArm = createPart(
texture,
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(
texture,
armW,
12,
4,
isLegacy ? limbUv(40, 16, armW) : limbUv(32, 48, armW),
isLegacy ? undefined : limbUv(48, 48, armW),
isLegacy,
isLegacy,
);
leftArm.position.set(isSlim ? 5.5 : 6, 0, 0);
playerGroup.add(leftArm);
const rightLeg = createPart(
texture,
4,
12,
4,
limbUv(0, 16),
isLegacy ? undefined : limbUv(0, 32),
);
rightLeg.position.set(-2, -12, 0);
playerGroup.add(rightLeg);
const leftLeg = createPart(
texture,
4,
12,
4,
isLegacy ? limbUv(0, 16) : limbUv(16, 48),
isLegacy ? undefined : limbUv(0, 48),
isLegacy,
isLegacy,
);
leftLeg.position.set(2, -12, 0);
playerGroup.add(leftLeg);
}
function addCape(playerGroup: THREE.Group, texture: THREE.Texture) {
const capeUv: UVSet = {
top: [1, 0, 10, 1],
bottom: [11, 0, 10, 1],
right: [0, 1, 1, 16],
front: [1, 1, 10, 16],
left: [11, 1, 1, 16],
back: [12, 1, 10, 16],
};
const capeGroup = new THREE.Group();
const capeGeo = new THREE.BoxGeometry(10, 16, 1);
const capeMats = [
createFaceMaterial(
texture,
capeUv.left[0],
capeUv.left[1],
capeUv.left[2],
capeUv.left[3],
),
createFaceMaterial(
texture,
capeUv.right[0],
capeUv.right[1],
capeUv.right[2],
capeUv.right[3],
),
createFaceMaterial(
texture,
capeUv.top[0],
capeUv.top[1],
capeUv.top[2],
capeUv.top[3],
false,
true,
),
createFaceMaterial(
texture,
capeUv.bottom[0],
capeUv.bottom[1],
capeUv.bottom[2],
capeUv.bottom[3],
false,
true,
),
createFaceMaterial(
texture,
capeUv.back[0],
capeUv.back[1],
capeUv.back[2],
capeUv.back[3],
),
createFaceMaterial(
texture,
capeUv.front[0],
capeUv.front[1],
capeUv.front[2],
capeUv.front[3],
),
];
const capeMesh = new THREE.Mesh(capeGeo, capeMats);
capeMesh.position.set(0, -8, -0.5);
capeGroup.add(capeMesh);
capeGroup.position.set(0, 6, -2.35);
capeGroup.rotation.x = 0.15;
playerGroup.add(capeGroup);
}
function fitCamera(camera: THREE.PerspectiveCamera, target: THREE.Object3D) {
const box = new THREE.Box3().setFromObject(target);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z) || 1;
const vFov = THREE.MathUtils.degToRad(camera.fov);
const distance = maxDim / (2 * Math.tan(vFov / 2) * 0.72);
camera.position.set(center.x, center.y, center.z + distance);
camera.lookAt(center);
}
function addLights(scene: THREE.Scene) {
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);
}
function disposeScene(
scene: THREE.Scene,
renderer: THREE.WebGLRenderer,
extraTextures: THREE.Texture[],
) {
scene.traverse((object) => {
if (object instanceof THREE.Mesh) {
if (object.geometry) object.geometry.dispose();
const mats = Array.isArray(object.material)
? object.material
: [object.material];
mats.forEach((m) => {
if (m.map) m.map.dispose();
m.dispose();
});
}
});
extraTextures.forEach((t) => t.dispose());
renderer.dispose();
}
function renderSkinSnapshot(opts: {
src: string;
slim?: boolean;
capeUrl?: string | null;
width: number;
height: number;
}): Promise<string> {
return new Promise<string>((resolve, reject) => {
const canvas = document.createElement("canvas");
canvas.width = opts.width;
canvas.height = opts.height;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("no 2d context"));
return;
}
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
35,
opts.width / opts.height,
0.1,
1000,
);
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
preserveDrawingBuffer: true,
});
renderer.setPixelRatio(1);
renderer.setSize(opts.width, opts.height);
addLights(scene);
const playerGroup = new THREE.Group();
scene.add(playerGroup);
const extraTextures: THREE.Texture[] = [];
let active = true;
let skinDone = false;
let capeDone = !opts.capeUrl;
const finish = () => {
if (!active || !skinDone || !capeDone) return;
fitCamera(camera, playerGroup);
renderer.render(scene, camera);
ctx.drawImage(renderer.domElement, 0, 0);
const dataUrl = canvas.toDataURL("image/png");
disposeScene(scene, renderer, extraTextures);
active = false;
resolve(dataUrl);
};
const fail = (err: unknown) => {
if (!active) return;
active = false;
disposeScene(scene, renderer, extraTextures);
reject(err);
};
const loader = new THREE.TextureLoader();
loader.load(
opts.src,
(tex) => {
if (!active) return;
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestFilter;
tex.colorSpace = THREE.SRGBColorSpace;
buildPlayer(playerGroup, tex, opts.slim);
extraTextures.push(tex);
skinDone = true;
finish();
},
undefined,
fail,
);
if (opts.capeUrl) {
loader.load(
opts.capeUrl,
(tex) => {
if (!active) return;
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestFilter;
tex.colorSpace = THREE.SRGBColorSpace;
addCape(playerGroup, tex);
extraTextures.push(tex);
capeDone = true;
finish();
},
undefined,
fail,
);
}
});
}
export function getSkinModelSnapshot(opts: {
src: string;
slim?: boolean;
capeUrl?: string | null;
width: number;
height: number;
}): Promise<string> {
const key = `${opts.src}\u0000${opts.slim ? 1 : 0}\u0000${opts.capeUrl || ""}`;
const hit = snapshotCache.get(key);
if (hit) return Promise.resolve(hit);
const result = snapshotChain.then(() => renderSkinSnapshot(opts));
snapshotChain = result.then(
() => undefined,
() => undefined,
);
result.then((dataUrl) => snapshotCache.set(key, dataUrl)).catch(() => {});
return result;
}
const SkinModel3D = memo(function SkinModel3D({
src,
slim,
capeUrl,
animate,
className,
}: SkinModel3DProps) {
const mountRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = mountRef.current;
if (!el) return;
const width = el.clientWidth || 240;
const height = el.clientHeight || 320;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(width, height);
el.innerHTML = "";
el.appendChild(renderer.domElement);
addLights(scene);
const playerGroup = new THREE.Group();
scene.add(playerGroup);
const extraTextures: THREE.Texture[] = [];
let built = false;
let disposed = false;
let rotY = -0.35;
let rotX = 0.06;
let dragging = false;
let raf = 0;
let lastX = 0;
let lastY = 0;
const render = () => {
if (!disposed) renderer.render(scene, camera);
};
const onMouseDown = (e: MouseEvent) => {
dragging = true;
lastX = e.clientX;
lastY = e.clientY;
};
const onMouseMove = (e: MouseEvent) => {
if (!dragging || disposed) return;
rotY += (e.clientX - lastX) * 0.01;
rotX = clamp(rotX + (e.clientY - lastY) * 0.01, -0.6, 0.6);
lastX = e.clientX;
lastY = e.clientY;
};
const onMouseUp = () => {
dragging = false;
};
const onTouchStart = (e: TouchEvent) => {
const t = e.touches[0];
if (!t) return;
dragging = true;
lastX = t.clientX;
lastY = t.clientY;
};
const onTouchMove = (e: TouchEvent) => {
if (!dragging || disposed) return;
const t = e.touches[0];
if (!t) return;
rotY += (t.clientX - lastX) * 0.01;
rotX = clamp(rotX + (t.clientY - lastY) * 0.01, -0.6, 0.6);
lastX = t.clientX;
lastY = t.clientY;
};
const onTouchEnd = () => {
dragging = false;
};
const loop = () => {
raf = requestAnimationFrame(loop);
if (animate && !dragging) rotY += 0.004;
if (animate || dragging) {
playerGroup.rotation.y = rotY;
playerGroup.rotation.x = rotX;
render();
}
};
const handleResize = () => {
if (!mountRef.current || disposed) return;
const w = mountRef.current.clientWidth || width;
const h = mountRef.current.clientHeight || height;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
if (built) fitCamera(camera, playerGroup);
render();
};
const dispose = () => {
if (disposed) return;
disposed = true;
cancelAnimationFrame(raf);
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
window.removeEventListener("touchmove", onTouchMove);
window.removeEventListener("touchend", onTouchEnd);
window.removeEventListener("resize", handleResize);
disposeScene(scene, renderer, extraTextures);
};
const loader = new THREE.TextureLoader();
let skinLoaded = false;
let capeLoaded = !capeUrl;
const tryFinish = () => {
if (!skinLoaded || !capeLoaded || disposed) return;
built = true;
fitCamera(camera, playerGroup);
render();
loop();
};
loader.load(
src,
(tex) => {
if (disposed) return;
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestFilter;
tex.colorSpace = THREE.SRGBColorSpace;
buildPlayer(playerGroup, tex, slim);
extraTextures.push(tex);
skinLoaded = true;
tryFinish();
},
undefined,
() => dispose(),
);
if (capeUrl) {
loader.load(
capeUrl,
(tex) => {
if (disposed) return;
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestFilter;
tex.colorSpace = THREE.SRGBColorSpace;
addCape(playerGroup, tex);
extraTextures.push(tex);
capeLoaded = true;
tryFinish();
},
undefined,
() => dispose(),
);
}
renderer.domElement.addEventListener("mousedown", onMouseDown);
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
renderer.domElement.addEventListener("touchstart", onTouchStart, {
passive: true,
});
window.addEventListener("touchmove", onTouchMove, { passive: false });
window.addEventListener("touchend", onTouchEnd);
window.addEventListener("resize", handleResize);
return dispose;
}, [src, slim, capeUrl, animate]);
return <div ref={mountRef} className={className} />;
});
export default SkinModel3D;
+266 -271
View File
@@ -9,7 +9,7 @@ import {
useSkin,
useConfig,
} from "../../context/LauncherContext";
import SkinViewer from "../common/SkinViewer";
import SkinModel3D, { getSkinModelSnapshot } from "../common/SkinModel3D";
import CapePreview from "../common/CapePreview";
import { usePlatform } from "../../hooks/usePlatform";
@@ -71,7 +71,7 @@ const DEFAULT_SKINS: SavedSkin[] = [
url: "/Skins/PrismaChunk0.png",
isSlim: false,
},
{ id: "amy", name: "Amy", url: "/Skins/amy.png", isSlim: true }, //neo: :c
{ id: "amy", name: "Amy", url: "/Skins/amy.png", isSlim: true },
{ id: "avalilac", name: "AvaLilac", url: "/Skins/ava.png", isSlim: true },
{ id: "huckle", name: "Huckle", url: "/Skins/huckle.png", isSlim: true },
{
@@ -86,6 +86,18 @@ const DEFAULT_SKINS: SavedSkin[] = [
url: "/Skins/tranq.png",
isSlim: true,
},
{
id: "individul",
name: "individul",
url: "/Skins/individul.png",
isSlim: false,
},
{
id: "ency",
name: "ency_pt",
url: "/Skins/ency.png",
isSlim: true,
},
];
const DEFAULT_CAPES: SavedCape[] = [
@@ -93,57 +105,63 @@ const DEFAULT_CAPES: SavedCape[] = [
{ id: "unused2", name: "Unused Cape 2", url: "/Capes/Unused_Cape_2.png" },
];
const HeadPreview = memo(function HeadPreview({ src }: { src: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const SkinCardModel = memo(function SkinCardModel({
src,
slim,
}: {
src: string;
slim?: boolean;
}) {
const boxRef = useRef<HTMLDivElement>(null);
const [snapshot, setSnapshot] = useState<string | null>(null);
useEffect(() => {
const cvs = canvasRef.current;
if (!cvs) return;
const ctx = cvs.getContext("2d");
if (!ctx) return;
const img = new Image();
img.onload = () => {
ctx.clearRect(0, 0, cvs.width, cvs.height);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(img, 8, 8, 8, 8, 0, 0, cvs.width, cvs.height);
if (img.height !== 32) {
ctx.drawImage(img, 40, 8, 8, 8, 0, 0, cvs.width, cvs.height);
}
let active = true;
const el = boxRef.current;
if (!el) return;
const w = Math.max(48, Math.round(el.clientWidth * 2));
const h = Math.max(64, Math.round(el.clientHeight * 2));
getSkinModelSnapshot({ src, slim, width: w, height: h })
.then((dataUrl) => {
if (active) setSnapshot(dataUrl);
})
.catch(() => {});
return () => {
active = false;
};
img.src = src;
}, [src]);
}, [src, slim]);
return (
<canvas
ref={canvasRef}
width={64}
height={64}
className="absolute w-full h-full"
style={{ imageRendering: "pixelated" }}
/>
<div ref={boxRef} className="absolute inset-0">
{snapshot ? (
<img
src={snapshot}
alt=""
draggable={false}
className="absolute inset-0 w-full h-full object-contain drop-shadow-[0_6px_4px_rgba(0,0,0,0.6)] select-none"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-[#555] text-xs mc-text-shadow uppercase tracking-widest animate-pulse select-none">
...
</span>
</div>
)}
</div>
);
});
const SkinsView = memo(function SkinsView() {
const { t } = useTranslation();
const { setActiveView, setIsUiHidden } = useUI();
const { setActiveView } = useUI();
const { playPressSound, playBackSound } = useAudio();
const { isAndroid } = usePlatform();
const {
skinUrl,
setSkinUrl,
skinIsSlim,
setSkinIsSlim,
capeUrl,
setCapeUrl,
} = useSkin();
const config = useConfig();
const { skinUrl, setSkinUrl, setSkinIsSlim, capeUrl, setCapeUrl } = useSkin();
const [focusIndex, setFocusIndex] = useState<number | null>(null);
const [viewMode, setViewMode] = useState<"skin" | "cape">("skin");
const containerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const capeFileInputRef = useRef<HTMLInputElement>(null);
const [storedSkins, setStoredSkins] = useLocalStorage<SavedSkin[]>(
"lce-custom-skins",
[],
@@ -534,102 +552,102 @@ const SkinsView = memo(function SkinsView() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: useConfig().animationsEnabled ? 0.3 : 0 }}
className="flex flex-col items-center w-full max-w-3xl h-full outline-none"
transition={{ duration: config.animationsEnabled ? 0.3 : 0 }}
className="flex flex-col items-center w-full max-w-5xl h-full 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">
<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 opacity-80 font-bold">
{viewMode === "skin" ? t("skins.skinLibrary") : t("skins.capeLibrary")}
</h2>
<div className="w-full max-w-160 flex-1 min-h-0 mb-4 p-5 flex flex-col relative overflow-hidden">
<div className="w-full flex items-center gap-4 ml-5 pb-4 mb-4 min-h-10">
<button
data-index="0"
onMouseEnter={() => setFocusIndex(0)}
onClick={() => {
playPressSound();
if (viewMode === "skin") handleImportClick();
else capeFileInputRef.current?.click();
}}
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",
}}
>
{viewMode === "skin"
? t("skins.importSkin")
: t("skins.importCape")}
</button>
<button
data-index="1"
onMouseEnter={() => {
if (viewMode === "skin" && !isActiveDefault) setFocusIndex(1);
else if (viewMode === "cape" && !isCapeDeleteDisabled)
setFocusIndex(1);
}}
onClick={() => {
playPressSound();
if (viewMode === "skin") handleDeleteActive();
else handleDeleteActiveCape();
}}
className={`w-40 h-10 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none ${
(viewMode === "skin" && isActiveDefault) ||
(viewMode === "cape" && isCapeDeleteDisabled)
? "text-gray-400 opacity-80 cursor-not-allowed"
: focusIndex === 1
? "text-[#FFFF55]"
: "text-white"
}`}
style={{
backgroundImage:
(viewMode === "skin" && isActiveDefault) ||
(viewMode === "cape" && isCapeDeleteDisabled)
? "url('/images/Button_Background2.png')"
: focusIndex === 1
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
{viewMode === "skin"
? t("skins.deleteSkin")
: t("skins.deleteCape")}
</button>
{viewMode === "skin" && (
<div className="w-full max-w-5xl flex-1 min-h-0 mb-4 flex gap-4">
<div className="flex-1 min-w-0 flex flex-col gap-3">
<div className="flex items-center gap-3 flex-wrap">
<button
data-index="2"
onMouseEnter={() => setFocusIndex(2)}
data-index="0"
onMouseEnter={() => setFocusIndex(0)}
onClick={() => {
playPressSound();
setActiveView("skin-editor");
if (viewMode === "skin") handleImportClick();
else capeFileInputRef.current?.click();
}}
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 === 2 ? "text-[#FFFF55]" : "text-white"}`}
className={`w-44 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 === 2
focusIndex === 0
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
{t("skins.editSkin")}
{viewMode === "skin"
? t("skins.importSkin")
: t("skins.importCape")}
</button>
)}
<div className="flex-1"></div>
<div className="flex justify-end z-10">
<button
data-index="1"
onMouseEnter={() => {
if (viewMode === "skin" && !isActiveDefault) setFocusIndex(1);
else if (viewMode === "cape" && !isCapeDeleteDisabled)
setFocusIndex(1);
}}
onClick={() => {
playPressSound();
if (viewMode === "skin") handleDeleteActive();
else handleDeleteActiveCape();
}}
className={`w-44 h-10 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none ${
(viewMode === "skin" && isActiveDefault) ||
(viewMode === "cape" && isCapeDeleteDisabled)
? "text-gray-400 opacity-80 cursor-not-allowed"
: focusIndex === 1
? "text-[#FFFF55]"
: "text-white"
}`}
style={{
backgroundImage:
(viewMode === "skin" && isActiveDefault) ||
(viewMode === "cape" && isCapeDeleteDisabled)
? "url('/images/Button_Background2.png')"
: focusIndex === 1
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
{viewMode === "skin"
? t("skins.deleteSkin")
: t("skins.deleteCape")}
</button>
{viewMode === "skin" && (
<button
data-index="2"
onMouseEnter={() => setFocusIndex(2)}
onClick={() => {
playPressSound();
setActiveView("skin-editor");
}}
className={`w-44 h-10 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={{
backgroundImage:
focusIndex === 2
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
{t("skins.editSkin")}
</button>
)}
<div className="flex-1 min-w-4" />
<button
data-index={viewMode === "skin" ? 3 : 2}
onMouseEnter={() => setFocusIndex(viewMode === "skin" ? 3 : 2)}
@@ -637,7 +655,7 @@ const SkinsView = memo(function SkinsView() {
playPressSound();
setViewMode(viewMode === "skin" ? "cape" : "skin");
}}
className={`mc-sq-btn w-10 h-10 flex items-center justify-center outline-none border-none transition-all`}
className="mc-sq-btn w-10 h-10 flex items-center justify-center outline-none border-none transition-all"
style={{
backgroundImage:
focusIndex === (viewMode === "skin" ? 3 : 2)
@@ -660,173 +678,150 @@ const SkinsView = memo(function SkinsView() {
decoding="async"
/>
</button>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept=".png"
className="hidden"
/>
<input
type="file"
ref={capeFileInputRef}
onChange={handleCapeFileChange}
accept=".png"
className="hidden"
/>
</div>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept=".png"
className="hidden"
/>
<input
type="file"
ref={capeFileInputRef}
onChange={handleCapeFileChange}
accept=".png"
className="hidden"
/>
</div>
<div className="flex-1 overflow-y-auto pr-2 flex flex-wrap gap-x-4 gap-y-6 items-start content-start justify-center hidden-scrollbar">
{viewMode === "skin" ? (
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 flex items-center justify-center gap-1">
{isActive && (
<span className="text-[#FFFF55] text-xs mc-text-shadow uppercase tracking-widest">
{t("skins.active")}
</span>
)}
{skin.isSlim && (
<span className="bg-purple-500/50 border border-purple-500/80 text-white px-1 text-[10px] uppercase rounded">
{t("skins.slim")}
</span>
)}
</div>
<div className="mc-options-bg flex-1 min-h-0 overflow-y-auto p-4 hidden-scrollbar">
<div className="grid grid-cols-4 gap-3">
{viewMode === "skin" ? (
savedSkins.map((skin, i) => {
const idx = SKINS_START_INDEX + i;
const isActive = activeSkinId
? activeSkinId === skin.id
: skinUrl === skin.url;
const isFocused = focusIndex === idx;
const isHighlight = isActive || isFocused;
return (
<div
key={skin.id}
data-index={idx}
tabIndex={0}
onMouseEnter={() => setFocusIndex(idx)}
className="flex flex-col items-center gap-1.5 outline-none"
>
<div
onClick={() => handleSkinSelect(skin)}
className={`relative w-full bg-black/30 border-2 overflow-hidden transition-colors outline-none cursor-pointer flex items-center justify-center ${isHighlight ? "border-[#FFFF55]" : "border-[#373737] hover:border-[#A0A0A0]"}`}
style={{ aspectRatio: "3 / 4" }}
>
<SkinCardModel
src={skin.url}
slim={Boolean(skin.isSlim)}
/>
{isHighlight && (
<div className="pointer-events-none absolute" />
)}
</div>
<div className="w-full mc-textinput-outer">
<input
type="text"
value={skin.name}
maxLength={16}
onChange={(e) =>
handleNameChange(skin.id, e.target.value)
}
className={`mc-textinput w-full h-9 px-2 text-sm text-center truncate outline-none relative z-10 font-[var(--font-base)] ${isHighlight ? "text-[#FFFF55]" : "text-white"} ${isDefaultSkin(skin.id) ? "pointer-events-none" : ""}`}
onClick={(e) => e.stopPropagation()}
spellCheck={false}
readOnly={isDefaultSkin(skin.id)}
/>
</div>
</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]"}`}
>
<HeadPreview src={skin.url} />
</div>
<div className="w-full">
<input
type="text"
value={skin.name}
maxLength={16}
onChange={(e) =>
handleNameChange(skin.id, e.target.value)
}
className={`w-full h-10 px-3 text-base text-center truncate outline-none relative z-10 font-[var(--font-base)] ${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
data-index={SKINS_START_INDEX}
tabIndex={0}
onMouseEnter={() => setFocusIndex(SKINS_START_INDEX)}
className="flex flex-col items-center gap-1 w-32 outline-none"
>
<div className="h-4 flex items-center justify-center gap-1">
{isActiveCapeDefault && (
<span className="text-[#FFFF55] text-xs mc-text-shadow uppercase tracking-widest">
Active
</span>
)}
</div>
<div
onClick={() => {
playPressSound();
setCapeUrl(null);
setActiveCapeId(null);
}}
className={`w-16 h-16 bg-black/40 border-2 shadow-inner relative cursor-pointer overflow-hidden transition-colors outline-none flex items-center justify-center ${isActiveCapeDefault || focusIndex === SKINS_START_INDEX ? "border-[#FFFF55]" : "border-[#373737] hover:border-[#A0A0A0]"}`}
>
<span className="text-gray-500 text-2xl">X</span>
</div>
<span
className={`text-center outline-none border-none text-base mc-text-shadow w-full truncate transition-colors ${isActiveCapeDefault || focusIndex === SKINS_START_INDEX ? "text-[#FFFF55]" : "text-white"}`}
>
{t("skins.noCape")}
</span>
</div>
{savedCapes.map((cape, i) => {
const idx = SKINS_START_INDEX + 1 + i;
const isActive = activeCapeId
? activeCapeId === cape.id
: capeUrl === cape.url;
const isFocused = focusIndex === idx;
return (
<div
key={cape.id}
data-index={idx}
data-index={SKINS_START_INDEX}
tabIndex={0}
onMouseEnter={() => setFocusIndex(idx)}
className="flex flex-col items-center gap-1 w-32 outline-none"
onMouseEnter={() => setFocusIndex(SKINS_START_INDEX)}
className="flex flex-col items-center gap-1.5 outline-none"
>
<div className="h-4 flex items-center justify-center gap-1">
{isActive && (
<span className="text-[#FFFF55] text-xs mc-text-shadow uppercase tracking-widest">
Active
</span>
<div
onClick={() => {
playPressSound();
setCapeUrl(null);
setActiveCapeId(null);
}}
className={`relative w-full bg-black/30 border-2 overflow-hidden transition-colors outline-none cursor-pointer flex items-center justify-center ${isActiveCapeDefault || focusIndex === SKINS_START_INDEX ? "border-[#FFFF55]" : "border-[#373737] hover:border-[#A0A0A0]"}`}
style={{ aspectRatio: "3 / 4" }}
>
<span className="text-gray-500 text-4xl select-none">
X
</span>
{(isActiveCapeDefault ||
focusIndex === SKINS_START_INDEX) && (
<div className="pointer-events-none absolute" />
)}
</div>
<div
onClick={() => handleCapeSelect(cape)}
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]"}`}
<span
className={`text-center outline-none border-none text-sm mc-text-shadow w-full truncate transition-colors ${isActiveCapeDefault || focusIndex === SKINS_START_INDEX ? "text-[#FFFF55]" : "text-white"}`}
>
<CapePreview src={cape.url} />
</div>
<div className="mc-textinput-outer w-full">
<input
type="text"
value={cape.name}
maxLength={16}
onChange={(e) =>
handleCapeNameChange(cape.id, e.target.value)
}
className={`mc-textinput w-full h-10 px-3 text-base text-center truncate outline-none relative z-10 font-[var(--font-base)] ${isActive || isFocused ? "text-[#FFFF55]" : "text-white"} ${isDefaultCape(cape.id) ? "pointer-events-none" : ""}`}
onClick={(e) => e.stopPropagation()}
spellCheck={false}
readOnly={isDefaultCape(cape.id)}
/>
</div>
{t("skins.noCape")}
</span>
</div>
);
})}
</>
)}
{savedCapes.map((cape, i) => {
const idx = SKINS_START_INDEX + 1 + i;
const isActive = activeCapeId
? activeCapeId === cape.id
: capeUrl === cape.url;
const isFocused = focusIndex === idx;
const isHighlight = isActive || isFocused;
return (
<div
key={cape.id}
data-index={idx}
tabIndex={0}
onMouseEnter={() => setFocusIndex(idx)}
className="flex flex-col items-center gap-1.5 outline-none"
>
<div
onClick={() => handleCapeSelect(cape)}
className={`relative w-full bg-black/30 border-2 overflow-hidden transition-colors outline-none cursor-pointer ${isHighlight ? "border-[#FFFF55]" : "border-[#373737] hover:border-[#A0A0A0]"}`}
style={{ aspectRatio: "3 / 4" }}
>
<CapePreview src={cape.url} />
{isHighlight && (
<div className="pointer-events-none absolute inset-0 shadow-[inset_0_0_16px_rgba(255,255,85,0.4)]" />
)}
</div>
<div className="w-full mc-textinput-outer">
<input
type="text"
value={cape.name}
maxLength={16}
onChange={(e) =>
handleCapeNameChange(cape.id, e.target.value)
}
className={`mc-textinput w-full h-9 px-2 text-sm text-center truncate outline-none relative z-10 font-[var(--font-base)] ${isHighlight ? "text-[#FFFF55]" : "text-white"} ${isDefaultCape(cape.id) ? "pointer-events-none" : ""}`}
onClick={(e) => e.stopPropagation()}
spellCheck={false}
readOnly={isDefaultCape(cape.id)}
/>
</div>
</div>
);
})}
</>
)}
</div>
</div>
</div>
</div>
<div className="w-full max-w-160 shadow-2xl flex flex-col items-center">
<SkinViewer
username=""
setUsername={() => {}}
playPressSound={playPressSound}
skinUrl={skinUrl}
setSkinUrl={setSkinUrl}
capeUrl={capeUrl}
setActiveView={setActiveView}
setIsUiHidden={setIsUiHidden}
isFocusedSection={false}
onNavigateRight={() => {}}
hideControls
style={{ top: "45%" }}
slim={skinIsSlim}
/>
</div>
{!isAndroid && (
<button
data-index={BACK_BUTTON_INDEX}