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;
+101 -106
View File
@@ -9,7 +9,7 @@ import {
useSkin, useSkin,
useConfig, useConfig,
} from "../../context/LauncherContext"; } from "../../context/LauncherContext";
import SkinViewer from "../common/SkinViewer"; import SkinModel3D, { getSkinModelSnapshot } from "../common/SkinModel3D";
import CapePreview from "../common/CapePreview"; import CapePreview from "../common/CapePreview";
import { usePlatform } from "../../hooks/usePlatform"; import { usePlatform } from "../../hooks/usePlatform";
@@ -71,7 +71,7 @@ const DEFAULT_SKINS: SavedSkin[] = [
url: "/Skins/PrismaChunk0.png", url: "/Skins/PrismaChunk0.png",
isSlim: false, 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: "avalilac", name: "AvaLilac", url: "/Skins/ava.png", isSlim: true },
{ id: "huckle", name: "Huckle", url: "/Skins/huckle.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", url: "/Skins/tranq.png",
isSlim: true, 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[] = [ const DEFAULT_CAPES: SavedCape[] = [
@@ -93,57 +105,63 @@ const DEFAULT_CAPES: SavedCape[] = [
{ id: "unused2", name: "Unused Cape 2", url: "/Capes/Unused_Cape_2.png" }, { id: "unused2", name: "Unused Cape 2", url: "/Capes/Unused_Cape_2.png" },
]; ];
const HeadPreview = memo(function HeadPreview({ src }: { src: string }) { const SkinCardModel = memo(function SkinCardModel({
const canvasRef = useRef<HTMLCanvasElement>(null); src,
slim,
}: {
src: string;
slim?: boolean;
}) {
const boxRef = useRef<HTMLDivElement>(null);
const [snapshot, setSnapshot] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const cvs = canvasRef.current; let active = true;
if (!cvs) return; const el = boxRef.current;
const ctx = cvs.getContext("2d"); if (!el) return;
if (!ctx) return; const w = Math.max(48, Math.round(el.clientWidth * 2));
const img = new Image(); const h = Math.max(64, Math.round(el.clientHeight * 2));
img.onload = () => { getSkinModelSnapshot({ src, slim, width: w, height: h })
ctx.clearRect(0, 0, cvs.width, cvs.height); .then((dataUrl) => {
ctx.imageSmoothingEnabled = false; if (active) setSnapshot(dataUrl);
ctx.drawImage(img, 8, 8, 8, 8, 0, 0, cvs.width, cvs.height); })
if (img.height !== 32) { .catch(() => {});
ctx.drawImage(img, 40, 8, 8, 8, 0, 0, cvs.width, cvs.height); return () => {
} active = false;
}; };
img.src = src; }, [src, slim]);
}, [src]);
return ( return (
<canvas <div ref={boxRef} className="absolute inset-0">
ref={canvasRef} {snapshot ? (
width={64} <img
height={64} src={snapshot}
className="absolute w-full h-full" alt=""
style={{ imageRendering: "pixelated" }} 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 SkinsView = memo(function SkinsView() {
const { t } = useTranslation(); const { t } = useTranslation();
const { setActiveView, setIsUiHidden } = useUI(); const { setActiveView } = useUI();
const { playPressSound, playBackSound } = useAudio(); const { playPressSound, playBackSound } = useAudio();
const { isAndroid } = usePlatform(); const { isAndroid } = usePlatform();
const { const config = useConfig();
skinUrl, const { skinUrl, setSkinUrl, setSkinIsSlim, capeUrl, setCapeUrl } = useSkin();
setSkinUrl,
skinIsSlim,
setSkinIsSlim,
capeUrl,
setCapeUrl,
} = useSkin();
const [focusIndex, setFocusIndex] = useState<number | null>(null); const [focusIndex, setFocusIndex] = useState<number | null>(null);
const [viewMode, setViewMode] = useState<"skin" | "cape">("skin"); const [viewMode, setViewMode] = useState<"skin" | "cape">("skin");
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const capeFileInputRef = useRef<HTMLInputElement>(null); const capeFileInputRef = useRef<HTMLInputElement>(null);
const [storedSkins, setStoredSkins] = useLocalStorage<SavedSkin[]>( const [storedSkins, setStoredSkins] = useLocalStorage<SavedSkin[]>(
"lce-custom-skins", "lce-custom-skins",
[], [],
@@ -534,15 +552,16 @@ const SkinsView = memo(function SkinsView() {
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: useConfig().animationsEnabled ? 0.3 : 0 }} transition={{ duration: config.animationsEnabled ? 0.3 : 0 }}
className="flex flex-col items-center w-full max-w-3xl h-full outline-none" 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")} {viewMode === "skin" ? t("skins.skinLibrary") : t("skins.capeLibrary")}
</h2> </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 max-w-5xl flex-1 min-h-0 mb-4 flex gap-4">
<div className="w-full flex items-center gap-4 ml-5 pb-4 mb-4 min-h-10"> <div className="flex-1 min-w-0 flex flex-col gap-3">
<div className="flex items-center gap-3 flex-wrap">
<button <button
data-index="0" data-index="0"
onMouseEnter={() => setFocusIndex(0)} onMouseEnter={() => setFocusIndex(0)}
@@ -551,7 +570,7 @@ const SkinsView = memo(function SkinsView() {
if (viewMode === "skin") handleImportClick(); if (viewMode === "skin") handleImportClick();
else capeFileInputRef.current?.click(); 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"}`} 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={{ style={{
backgroundImage: backgroundImage:
focusIndex === 0 focusIndex === 0
@@ -578,7 +597,7 @@ const SkinsView = memo(function SkinsView() {
if (viewMode === "skin") handleDeleteActive(); if (viewMode === "skin") handleDeleteActive();
else handleDeleteActiveCape(); else handleDeleteActiveCape();
}} }}
className={`w-40 h-10 flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none ${ 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 === "skin" && isActiveDefault) ||
(viewMode === "cape" && isCapeDeleteDisabled) (viewMode === "cape" && isCapeDeleteDisabled)
? "text-gray-400 opacity-80 cursor-not-allowed" ? "text-gray-400 opacity-80 cursor-not-allowed"
@@ -611,7 +630,7 @@ const SkinsView = memo(function SkinsView() {
playPressSound(); playPressSound();
setActiveView("skin-editor"); setActiveView("skin-editor");
}} }}
className={`w-40 h-10 flex items-center className={`w-44 h-10 flex items-center
justify-center transition-colors text-2xl justify-center transition-colors text-2xl
mc-text-shadow outline-none border-none hover:text-[#FFFF55] mc-text-shadow outline-none border-none hover:text-[#FFFF55]
${focusIndex === 2 ? "text-[#FFFF55]" : "text-white"}`} ${focusIndex === 2 ? "text-[#FFFF55]" : "text-white"}`}
@@ -628,8 +647,7 @@ const SkinsView = memo(function SkinsView() {
</button> </button>
)} )}
<div className="flex-1"></div> <div className="flex-1 min-w-4" />
<div className="flex justify-end z-10">
<button <button
data-index={viewMode === "skin" ? 3 : 2} data-index={viewMode === "skin" ? 3 : 2}
onMouseEnter={() => setFocusIndex(viewMode === "skin" ? 3 : 2)} onMouseEnter={() => setFocusIndex(viewMode === "skin" ? 3 : 2)}
@@ -637,7 +655,7 @@ const SkinsView = memo(function SkinsView() {
playPressSound(); playPressSound();
setViewMode(viewMode === "skin" ? "cape" : "skin"); 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={{ style={{
backgroundImage: backgroundImage:
focusIndex === (viewMode === "skin" ? 3 : 2) focusIndex === (viewMode === "skin" ? 3 : 2)
@@ -660,8 +678,6 @@ const SkinsView = memo(function SkinsView() {
decoding="async" decoding="async"
/> />
</button> </button>
</div>
<input <input
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
@@ -678,7 +694,8 @@ const SkinsView = memo(function SkinsView() {
/> />
</div> </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"> <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" ? ( {viewMode === "skin" ? (
savedSkins.map((skin, i) => { savedSkins.map((skin, i) => {
const idx = SKINS_START_INDEX + i; const idx = SKINS_START_INDEX + i;
@@ -686,33 +703,29 @@ const SkinsView = memo(function SkinsView() {
? activeSkinId === skin.id ? activeSkinId === skin.id
: skinUrl === skin.url; : skinUrl === skin.url;
const isFocused = focusIndex === idx; const isFocused = focusIndex === idx;
const isHighlight = isActive || isFocused;
return ( return (
<div <div
key={skin.id} key={skin.id}
data-index={idx} data-index={idx}
tabIndex={0} tabIndex={0}
onMouseEnter={() => setFocusIndex(idx)} onMouseEnter={() => setFocusIndex(idx)}
className="flex flex-col items-center gap-1 w-32 outline-none" 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">
{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 <div
onClick={() => handleSkinSelect(skin)} 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]"}`} 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" }}
> >
<HeadPreview src={skin.url} /> <SkinCardModel
src={skin.url}
slim={Boolean(skin.isSlim)}
/>
{isHighlight && (
<div className="pointer-events-none absolute" />
)}
</div> </div>
<div className="w-full"> <div className="w-full mc-textinput-outer">
<input <input
type="text" type="text"
value={skin.name} value={skin.name}
@@ -720,7 +733,7 @@ const SkinsView = memo(function SkinsView() {
onChange={(e) => onChange={(e) =>
handleNameChange(skin.id, e.target.value) 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" : ""}`} 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()} onClick={(e) => e.stopPropagation()}
spellCheck={false} spellCheck={false}
readOnly={isDefaultSkin(skin.id)} readOnly={isDefaultSkin(skin.id)}
@@ -735,27 +748,27 @@ const SkinsView = memo(function SkinsView() {
data-index={SKINS_START_INDEX} data-index={SKINS_START_INDEX}
tabIndex={0} tabIndex={0}
onMouseEnter={() => setFocusIndex(SKINS_START_INDEX)} onMouseEnter={() => setFocusIndex(SKINS_START_INDEX)}
className="flex flex-col items-center gap-1 w-32 outline-none" className="flex flex-col items-center gap-1.5 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 <div
onClick={() => { onClick={() => {
playPressSound(); playPressSound();
setCapeUrl(null); setCapeUrl(null);
setActiveCapeId(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]"}`} 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-2xl">X</span> <span className="text-gray-500 text-4xl select-none">
X
</span>
{(isActiveCapeDefault ||
focusIndex === SKINS_START_INDEX) && (
<div className="pointer-events-none absolute" />
)}
</div> </div>
<span <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"}`} 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"}`}
> >
{t("skins.noCape")} {t("skins.noCape")}
</span> </span>
@@ -766,28 +779,26 @@ const SkinsView = memo(function SkinsView() {
? activeCapeId === cape.id ? activeCapeId === cape.id
: capeUrl === cape.url; : capeUrl === cape.url;
const isFocused = focusIndex === idx; const isFocused = focusIndex === idx;
const isHighlight = isActive || isFocused;
return ( return (
<div <div
key={cape.id} key={cape.id}
data-index={idx} data-index={idx}
tabIndex={0} tabIndex={0}
onMouseEnter={() => setFocusIndex(idx)} onMouseEnter={() => setFocusIndex(idx)}
className="flex flex-col items-center gap-1 w-32 outline-none" 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>
<div <div
onClick={() => handleCapeSelect(cape)} 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]"}`} 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} /> <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>
<div className="mc-textinput-outer w-full"> <div className="w-full mc-textinput-outer">
<input <input
type="text" type="text"
value={cape.name} value={cape.name}
@@ -795,7 +806,7 @@ const SkinsView = memo(function SkinsView() {
onChange={(e) => onChange={(e) =>
handleCapeNameChange(cape.id, e.target.value) 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" : ""}`} 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()} onClick={(e) => e.stopPropagation()}
spellCheck={false} spellCheck={false}
readOnly={isDefaultCape(cape.id)} readOnly={isDefaultCape(cape.id)}
@@ -808,23 +819,7 @@ const SkinsView = memo(function SkinsView() {
)} )}
</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> </div>
{!isAndroid && ( {!isAndroid && (