feat(versionsview): full redesign

This commit is contained in:
neoapps-dev
2026-09-15 21:27:44 +03:00
parent 3bc69e11d3
commit 69c688fac4
6 changed files with 194 additions and 175 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

+124 -116
View File
@@ -63,15 +63,6 @@ const DeleteConfirmButton = memo(function DeleteConfirmButton({
const ROW_ESTIMATE = 52; const ROW_ESTIMATE = 52;
const ROW_GAP = 4; const ROW_GAP = 4;
function formatPlaytime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m`;
return seconds > 0 ? `${seconds}s` : "";
}
const VersionsView = memo(function VersionsView() { const VersionsView = memo(function VersionsView() {
const { t } = useTranslation(); const { t } = useTranslation();
const { setActiveView } = useUI(); const { setActiveView } = useUI();
@@ -123,11 +114,11 @@ const VersionsView = memo(function VersionsView() {
} | null>(null); } | null>(null);
const [isCustomizeModalOpen, setIsCustomizeModalOpen] = useState(false); const [isCustomizeModalOpen, setIsCustomizeModalOpen] = useState(false);
const [customizeTarget, setCustomizeTarget] = useState<Edition | null>(null); const [customizeTarget, setCustomizeTarget] = useState<Edition | null>(null);
const [playtimeMap, setPlaytimeMap] = useState< const [_playtimeMap, setPlaytimeMap] = useState<
Record<string, PlaytimeResponse> Record<string, PlaytimeResponse>
>({}); >({});
const [initialPath, setInitialPath] = useState<string>(""); const [initialPath, setInitialPath] = useState<string>("");
const [hoveredBtn, setHoveredBtn] = useState<{ const [_hoveredBtn, setHoveredBtn] = useState<{
row: number; row: number;
btn: string; btn: string;
} | null>(null); } | null>(null);
@@ -144,8 +135,13 @@ const VersionsView = memo(function VersionsView() {
name: string; name: string;
} | null>(null); } | null>(null);
const [argsSchemas, setArgsSchemas] = useState<Record<string, boolean>>({}); const [argsSchemas, setArgsSchemas] = useState<Record<string, boolean>>({});
const [thumb, setThumb] = useState({ height: 0, top: 0 });
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLDivElement>(null);
const dragOffsetRef = useRef(0);
const draggingRef = useRef(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const [menuDir, setMenuDir] = useState<"down" | "up">("down"); const [menuDir, setMenuDir] = useState<"down" | "up">("down");
const ITEM_COUNT = visibleEditions.length + 3; const ITEM_COUNT = visibleEditions.length + 3;
@@ -162,6 +158,63 @@ const VersionsView = memo(function VersionsView() {
visibleEditionsRef.current = visibleEditions; visibleEditionsRef.current = visibleEditions;
const focusIndexRef = useRef(focusIndex); const focusIndexRef = useRef(focusIndex);
const openMenuIdRef = useRef(openMenuId); const openMenuIdRef = useRef(openMenuId);
const updateThumb = useCallback(() => {
const container = listRef.current;
const track = trackRef.current;
if (!container || !track) return;
const scrollHeight = container.scrollHeight;
const clientHeight = container.clientHeight;
const scrollable = scrollHeight - clientHeight;
if (scrollable <= 0) {
setThumb((prev) =>
prev.height === 0 && prev.top === 0 ? prev : { height: 0, top: 0 },
);
return;
}
const trackHeight = track.clientHeight;
const HANDLE = 40;
const STEP = HANDLE + 4;
const maxTop = trackHeight - HANDLE;
const maxIndex = Math.max(1, Math.floor(maxTop / STEP));
const index = Math.min(
maxIndex,
Math.round((container.scrollTop / scrollable) * maxIndex),
);
const top = index * STEP;
setThumb((prev) =>
prev.top === top && prev.height === HANDLE
? prev
: { height: HANDLE, top },
);
}, []);
const handleThumbPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
draggingRef.current = true;
dragOffsetRef.current =
e.clientY - e.currentTarget.getBoundingClientRect().top;
e.currentTarget.setPointerCapture(e.pointerId);
};
const handleThumbPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!draggingRef.current) return;
const container = listRef.current;
const track = trackRef.current;
if (!container || !track) return;
const trackRect = track.getBoundingClientRect();
const trackTop = trackRect.top + track.clientTop;
const y = e.clientY - trackTop - dragOffsetRef.current;
const trackHeight = track.clientHeight;
const maxTop = Math.max(0, trackHeight - thumb.height);
const scrollable = container.scrollHeight - container.clientHeight;
if (scrollable <= 0) return;
const clampedY = Math.min(Math.max(y, 0), maxTop);
container.scrollTop = (clampedY / maxTop) * scrollable;
};
const handleThumbPointerUp = () => {
draggingRef.current = false;
};
const updateVisibleRange = useCallback(() => { const updateVisibleRange = useCallback(() => {
const container = listRef.current; const container = listRef.current;
@@ -176,11 +229,8 @@ const VersionsView = memo(function VersionsView() {
let end = -1; let end = -1;
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const itemTop = acc; const itemTop = acc;
const itemBottom = acc + (heights[i] ?? ROW_ESTIMATE); const itemNeo = acc + (heights[i] ?? ROW_ESTIMATE); //neo: me fr fr :3
if ( if (itemTop >= scrollTop && itemNeo <= scrollTop + viewportHeight) {
itemTop >= scrollTop &&
itemBottom <= scrollTop + viewportHeight
) {
if (start === n) start = i; if (start === n) start = i;
end = i; end = i;
} }
@@ -204,7 +254,8 @@ const VersionsView = memo(function VersionsView() {
setVisibleRange((prev) => setVisibleRange((prev) =>
prev.start === start && prev.end === end ? prev : { start, end }, prev.start === start && prev.end === end ? prev : { start, end },
); );
}, []); updateThumb();
}, [updateThumb]);
const setRowRef = (index: number) => (el: HTMLDivElement | null) => { const setRowRef = (index: number) => (el: HTMLDivElement | null) => {
if (el) { if (el) {
@@ -428,11 +479,8 @@ const VersionsView = memo(function VersionsView() {
}, [installedVersions]); }, [installedVersions]);
const handleEditionClick = (edition: Edition, index: number) => { const handleEditionClick = (edition: Edition, index: number) => {
const isInstalled = installedVersions.includes(edition.instanceId);
if (isInstalled) {
playPressSound(); playPressSound();
setSelectedProfile(edition.instanceId); setSelectedProfile(edition.instanceId);
}
setFocusIndex(index); setFocusIndex(index);
}; };
@@ -465,11 +513,11 @@ const VersionsView = memo(function VersionsView() {
transition={{ duration: animationsEnabled ? 0.25 : 0 }} transition={{ duration: animationsEnabled ? 0.25 : 0 }}
className="flex flex-col items-center w-full max-w-5xl outline-none" className="flex flex-col items-center w-full max-w-5xl outline-none"
> >
<div className="w-full min-w-[480px] p-6 mb-4 mc-options-bg"> <div className="w-full min-w-120 p-1 mb-3 mc-options-bg relative flex items-stretch">
<div <div
ref={listRef} ref={listRef}
onScroll={updateVisibleRange} onScroll={updateVisibleRange}
className="w-full max-h-[48vh] overflow-y-auto snap-y snap-mandatory mc-versionrecess hidden-scrollbar" className="flex-1 min-w-0 h-85 max-h-85 overflow-y-auto snap-y snap-mandatory mc-versionrecess hidden-scrollbar"
> >
<div className="flex flex-col gap-1 hidden-scrollbar"> <div className="flex flex-col gap-1 hidden-scrollbar">
{visibleEditions.map((edition: Edition, i: number) => { {visibleEditions.map((edition: Edition, i: number) => {
@@ -499,6 +547,10 @@ const VersionsView = memo(function VersionsView() {
ref={setRowRef(i)} ref={setRowRef(i)}
className={`w-[calc(100%-20px)] snap-start flex items-center gap-3 ${!isFocused ? "mc-button-ninesliced" : "mc-button-ninesliced-selected"} ${isComingSoon ? "opacity-50 cursor-not-allowed" : ""} relative ${openMenuId === edition.id ? "z-50" : "z-0"}`} className={`w-[calc(100%-20px)] snap-start flex items-center gap-3 ${!isFocused ? "mc-button-ninesliced" : "mc-button-ninesliced-selected"} ${isComingSoon ? "opacity-50 cursor-not-allowed" : ""} relative ${openMenuId === edition.id ? "z-50" : "z-0"}`}
onMouseEnter={() => !isComingSoon && setFocusIndex(i)} onMouseEnter={() => !isComingSoon && setFocusIndex(i)}
onMouseLeave={() => setFocusIndex(-1)} //neo: you see this, smartcmd?
onClick={() =>
!isComingSoon && handleEditionClick(edition, i)
}
> >
<div className="w-12 h-12 flex items-center justify-center shrink-0 bg-[url(/images/empty.png)] bg-cover bg-center bg-no-repeat"> <div className="w-12 h-12 flex items-center justify-center shrink-0 bg-[url(/images/empty.png)] bg-cover bg-center bg-no-repeat">
{edition.logo ? ( {edition.logo ? (
@@ -524,55 +576,15 @@ const VersionsView = memo(function VersionsView() {
</div> </div>
<div <div
onClick={() =>
!isComingSoon && handleEditionClick(edition, i)
}
className={`flex-1 text-left min-w-0 outline-none rounded cursor-pointer ${isComingSoon ? "cursor-not-allowed" : ""}`} className={`flex-1 text-left min-w-0 outline-none rounded cursor-pointer ${isComingSoon ? "cursor-not-allowed" : ""}`}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className={"text-xl tracking-wide truncate text-white"} className={`text-xl tracking-wide truncate ${!isFocused ? "text-white" : "text-[#ffff00]"}`}
style={{ textShadow: "none" }} style={{ textShadow: "none" }}
> >
{edition.name} {edition.name}
</span> </span>
{isInstalled && (
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
setPlaytimeTarget({
id: edition.instanceId,
name: edition.name,
});
setIsPlaytimeModalOpen(true);
}}
className="flex items-center gap-1.5 px-2 py-1 bg-black/60 border border-[#555] hover:border-[#FFFF55] group transition-colors flex-shrink-0"
title={t("versions.viewPlaytime")}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="#AAAAAA"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="group-hover:stroke-[#FFFF55] transition-colors"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span className="text-xs text-[#AAAAAA] group-hover:text-[#FFFF55] leading-none transition-colors">
{playtimeMap[edition.instanceId]
? formatPlaytime(
playtimeMap[edition.instanceId].totalSeconds,
)
: ""}
</span>
</button>
)}
{edition.category && {edition.category &&
edition.category.map((cat: string) => ( edition.category.map((cat: string) => (
<span <span
@@ -605,17 +617,10 @@ const VersionsView = memo(function VersionsView() {
setHoveredBtn({ row: i, btn: "main" }) setHoveredBtn({ row: i, btn: "main" })
} }
onMouseLeave={() => setHoveredBtn(null)} onMouseLeave={() => setHoveredBtn(null)}
className={`w-9 h-9 flex items-center justify-center ${ className={
isDownloading ? "opacity-50" : "" "w-10 h-10 flex items-center justify-center bg-[url(/images/empty.png)] bg-cover bg-no-repeat bg-center"
}`} }
style={{ style={{
backgroundImage:
(hoveredBtn?.row === i &&
hoveredBtn?.btn === "main") ||
(focusIndex === i && focusBtn === 0)
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated", imageRendering: "pixelated",
}} }}
> >
@@ -626,7 +631,7 @@ const VersionsView = memo(function VersionsView() {
: "/images/Download_Icon.png" : "/images/Download_Icon.png"
} }
alt="" alt=""
className="w-5 h-5 object-contain" className="w-8 h-8 object-contain"
style={{ style={{
imageRendering: "pixelated", imageRendering: "pixelated",
filter: isDownloading filter: isDownloading
@@ -648,31 +653,20 @@ const VersionsView = memo(function VersionsView() {
setHoveredBtn({ row: i, btn: "menu" }) setHoveredBtn({ row: i, btn: "menu" })
} }
onMouseLeave={() => setHoveredBtn(null)} onMouseLeave={() => setHoveredBtn(null)}
className="w-9 h-9 flex flex-col items-center justify-center gap-1 transition-colors relative" className="w-10 h-10 flex flex-col items-center justify-center gap-1 transition-colors relative bg-[url(/images/empty.png)] bg-cover bg-no-repeat bg-center"
style={{ style={{
backgroundImage:
(hoveredBtn?.row === i &&
hoveredBtn?.btn === "menu") ||
(focusIndex === i &&
(focusBtn === 0 || focusBtn === 1))
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated", imageRendering: "pixelated",
filter: updatesAvailable?.[edition.instanceId]
? "drop-shadow(0 0 4px rgba(255,255,0,0.8))"
: "none",
}} }}
> >
<div <div className={"w-8 h-8"}>
className={`w-1.5 h-1.5 ${updatesAvailable?.[edition.instanceId] ? "bg-[#ffff55]" : "bg-white"}`} <img
/> src={
<div updatesAvailable?.[edition.instanceId]
className={`w-1.5 h-1.5 ${updatesAvailable?.[edition.instanceId] ? "bg-[#ffff55]" : "bg-white"}`} ? "/images/Update_Icon.png"
/> : "/images/gear.png"
<div }
className={`w-1.5 h-1.5 ${updatesAvailable?.[edition.instanceId] ? "bg-[#ffff55]" : "bg-white"}`}
/> />
</div>
</button> </button>
{openMenuId === edition.id && ( {openMenuId === edition.id && (
@@ -1104,8 +1098,33 @@ const VersionsView = memo(function VersionsView() {
</div> </div>
); );
})} })}
</div>
</div>
<div className="w-full flex items-center justify-center gap-4 p-2 mt-1"> <div
ref={trackRef}
className="w-13 h-85 max-h-85 shrink-0 relative select-none mc-versionrecess"
>
{thumb.height > 0 && (
<div
ref={thumbRef}
onPointerDown={handleThumbPointerDown}
onPointerMove={handleThumbPointerMove}
onPointerUp={handleThumbPointerUp}
onPointerCancel={handleThumbPointerUp}
className="absolute left-1/2 -translate-x-[30px] w-10 h-10 mc-options-bg cursor-pointer"
style={{ top: thumb.top - 12 }}
>
{
//neo: oh my goodness this took me a lot to figure out, anyway its just a transparent <hr> so the div isnt empty so that the border-image renders
}
<hr style={{ color: "transparent" }} />
</div>
)}
</div>
{!isAndroid && (
<div className="absolute left-3 bottom-6 z-10 flex items-center gap-1 pb-1 pr-1 w-fit mc-help">
<button <button
onClick={() => { onClick={() => {
playPressSound(); playPressSound();
@@ -1114,15 +1133,8 @@ const VersionsView = memo(function VersionsView() {
}} }}
onMouseEnter={() => setFocusIndex(visibleEditions.length)} onMouseEnter={() => setFocusIndex(visibleEditions.length)}
onMouseLeave={() => setHoveredBtn(null)} onMouseLeave={() => setHoveredBtn(null)}
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]" className="w-8 h-8 flex items-center justify-center text-[#333333] mc-optionbutton"
style={{ style={{
backgroundImage:
(hoveredBtn?.row === visibleEditions.length &&
hoveredBtn?.btn === "add") ||
focusIndex === visibleEditions.length
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated", imageRendering: "pixelated",
}} }}
> >
@@ -1139,7 +1151,6 @@ const VersionsView = memo(function VersionsView() {
</svg> </svg>
</button> </button>
{!isAndroid && (
<button <button
onClick={() => { onClick={() => {
playPressSound(); playPressSound();
@@ -1148,29 +1159,26 @@ const VersionsView = memo(function VersionsView() {
onMouseEnter={() => setFocusIndex(visibleEditions.length + 1)} onMouseEnter={() => setFocusIndex(visibleEditions.length + 1)}
onMouseLeave={() => setHoveredBtn(null)} onMouseLeave={() => setHoveredBtn(null)}
title={t("modals.customTu.importTitle")} title={t("modals.customTu.importTitle")}
className="w-8 h-8 flex items-center justify-center text-[#3a3a3a]" className="w-8 h-8 flex items-center justify-center text-[#333333] mc-optionbutton"
style={{ style={{
backgroundImage:
(hoveredBtn?.row === visibleEditions.length &&
hoveredBtn?.btn === "folder_import") ||
focusIndex === visibleEditions.length + 1
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated", imageRendering: "pixelated",
}} }}
> >
<img <img
src="/images/Folder_Icon.png" src="/images/Folder_Icon.png"
alt={t("modals.customTu.importTitle")} alt={t("modals.customTu.importTitle")}
className="w-5 h-5 object-contain" className="w-5 h-5 object-contain invert"
style={{ imageRendering: "pixelated" }} style={{ imageRendering: "pixelated" }}
/> />
</button> </button>
</div>
)} )}
</div> </div>
</div>
</div> <div className="mc-help text-xl text-[#FFFFFF] max-h-24 min-h-24 overflow-hidden px-4 w-280">
{visibleEditions[focusIndex]?.desc ??
visibleEditions.find((e) => e.id == useConfig().profile)?.desc ??
""}
</div> </div>
{!isAndroid && ( {!isAndroid && (
@@ -1182,7 +1190,7 @@ const VersionsView = memo(function VersionsView() {
playBackSound(); playBackSound();
setActiveView("main"); setActiveView("main");
}} }}
className="w-48 h-10 flex items-center justify-center text-xl mc-text-shadow outline-none border-none text-white" className="w-48 h-10 flex items-center justify-center text-xl mc-text-shadow text-white"
style={{ style={{
backgroundImage: backgroundImage:
focusIndex === visibleEditions.length + 2 focusIndex === visibleEditions.length + 2
+10 -2
View File
@@ -82,6 +82,13 @@
image-rendering: pixelated; image-rendering: pixelated;
} }
.mc-optionbutton {
appearance: none;
background: url("/images/options_background.png") center / 100% 100%
no-repeat;
image-rendering: pixelated;
}
.mc-button-ninesliced { .mc-button-ninesliced {
/*neo: tysm https://leanrada.com/9-slicer/ */ /*neo: tysm https://leanrada.com/9-slicer/ */
border-image: url("/images/Button_Background.png") 13 13 13 13 fill / 13px border-image: url("/images/Button_Background.png") 13 13 13 13 fill / 13px
@@ -104,7 +111,8 @@
.mc-versionrecess { .mc-versionrecess {
/*neo: tysm https://leanrada.com/9-slicer/ */ /*neo: tysm https://leanrada.com/9-slicer/ */
border-image: url("/images/versionrecess.png") 12 30 30 12 fill / 12px 30px 30px 12px; border-image: url("/images/versionrecess.png") 12 30 30 12 fill / 12px 30px
30px 12px;
border-width: 12px; border-width: 12px;
border-style: solid; border-style: solid;
border-color: transparent; border-color: transparent;
@@ -113,7 +121,7 @@
.mc-help { .mc-help {
/*neo: tysm https://leanrada.com/9-slicer/ */ /*neo: tysm https://leanrada.com/9-slicer/ */
border-image: url("/images/helpbg.png") 5 15 17 6 fill / 5px 15px 17px 6px; border-image: url("/images/helpbg.png") 7 16 16 7 fill / 7px 16px 16px 7px;
border-width: 12px; border-width: 12px;
border-style: solid; border-style: solid;
border-color: transparent; border-color: transparent;
+4 -1
View File
@@ -309,6 +309,7 @@ export default function App() {
"model-editor", "model-editor",
"swf-editor", "swf-editor",
"goldmapper", "goldmapper",
"versions", //neo: didnt expect that but oh well
]); ]);
useEffect(() => { useEffect(() => {
const handleContextMenu = (e: MouseEvent) => e.preventDefault(); const handleContextMenu = (e: MouseEvent) => e.preventDefault();
@@ -776,7 +777,9 @@ export default function App() {
style={{ fontWeight: "normal" }} style={{ fontWeight: "normal" }}
> >
<div className="flex-1 text-left whitespace-nowrap"> <div className="flex-1 text-left whitespace-nowrap">
{t("app.version", { version: `${pkg.version} (${__BUILD_DATE__})` })} {t("app.version", {
version: `${pkg.version} (${__BUILD_DATE__})`,
})}
</div> </div>
<div className="flex-1 text-right whitespace-nowrap"> <div className="flex-1 text-right whitespace-nowrap">
{connected && t("app.controllerConnected")} {connected && t("app.controllerConnected")}