feat(VersionsView): initial redesign

This commit is contained in:
neoapps-dev
2026-09-14 22:35:14 +03:00
parent 7fca7f8de9
commit fac55a66a6
6 changed files with 259 additions and 97 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 B

+20 -8
View File
@@ -10,7 +10,11 @@ interface DownloadOverlayProps {
editions: Edition[]; editions: Edition[];
} }
export const DownloadOverlay = memo(function DownloadOverlay({ downloadProgress, downloadingIds, editions }: DownloadOverlayProps) { export const DownloadOverlay = memo(function DownloadOverlay({
downloadProgress,
downloadingIds,
editions,
}: DownloadOverlayProps) {
const { t } = useTranslation(); const { t } = useTranslation();
if (downloadingIds.length === 0) return null; if (downloadingIds.length === 0) return null;
@@ -24,14 +28,14 @@ export const DownloadOverlay = memo(function DownloadOverlay({ downloadProgress,
style={{ imageRendering: "pixelated" }} style={{ imageRendering: "pixelated" }}
> >
<div className="px-3 pt-2.5 pb-2"> <div className="px-3 pt-2.5 pb-2">
<span className="text-xl text-[#333333] mc-text-shadow"> <span className="text-xl text-[#333333]">{t("download.title")}</span>
{t("download.title")}
</span>
</div> </div>
<div className="flex flex-col gap-1.5 py-2 max-h-[260px] overflow-y-auto custom-scrollbar mc-options-fg"> <div className="flex flex-col gap-1.5 py-2 max-h-[260px] overflow-y-auto custom-scrollbar mc-options-fg">
{downloadingIds.map((id) => { {downloadingIds.map((id) => {
const pct = downloadProgress[id] ?? 0; const pct = downloadProgress[id] ?? 0;
const edition = editions.find((e) => e.instanceId === id || e.id === id); const edition = editions.find(
(e) => e.instanceId === id || e.id === id,
);
const name = const name =
edition?.name || edition?.name ||
(id.startsWith("runner_") (id.startsWith("runner_")
@@ -43,7 +47,8 @@ export const DownloadOverlay = memo(function DownloadOverlay({ downloadProgress,
<div className="flex-1 flex flex-row justify-between"> <div className="flex-1 flex flex-row justify-between">
<div className="flex-1 flex flex-row"> <div className="flex-1 flex flex-row">
{edition?.logo ? ( {edition?.logo ? (
edition.logo.startsWith("http") || edition.logo.startsWith("/images") ? ( edition.logo.startsWith("http") ||
edition.logo.startsWith("/images") ? (
<img <img
src={edition.logo} src={edition.logo}
alt="" alt=""
@@ -59,7 +64,8 @@ export const DownloadOverlay = memo(function DownloadOverlay({ downloadProgress,
/> />
) )
) : edition?.titleImage ? ( ) : edition?.titleImage ? (
edition.titleImage.startsWith("http") || edition.titleImage.startsWith("/images") ? ( edition.titleImage.startsWith("http") ||
edition.titleImage.startsWith("/images") ? (
<img <img
src={edition.titleImage} src={edition.titleImage}
alt="" alt=""
@@ -76,7 +82,13 @@ export const DownloadOverlay = memo(function DownloadOverlay({ downloadProgress,
) )
) : ( ) : (
<div className="w-6 h-6 flex items-center justify-center border border-[#555] bg-black/40 shrink-0"> <div className="w-6 h-6 flex items-center justify-center border border-[#555] bg-black/40 shrink-0">
<svg className="w-3 h-3 text-[#FFFF55]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"> <svg
className="w-3 h-3 text-[#FFFF55]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" /> <polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" /> <line x1="12" y1="15" x2="12" y2="3" />
+159 -49
View File
@@ -1,4 +1,11 @@
import { useState, useEffect, useRef, memo } from "react"; import {
useState,
useEffect,
useLayoutEffect,
useRef,
useCallback,
memo,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { TauriService, PlaytimeResponse } from "../../services/TauriService"; import { TauriService, PlaytimeResponse } from "../../services/TauriService";
@@ -54,6 +61,9 @@ const DeleteConfirmButton = memo(function DeleteConfirmButton({
); );
}); });
const ROW_ESTIMATE = 52;
const ROW_GAP = 4;
function formatPlaytime(seconds: number): string { function formatPlaytime(seconds: number): string {
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60); const m = Math.floor((seconds % 3600) / 60);
@@ -66,7 +76,6 @@ const VersionsView = memo(function VersionsView() {
const { t } = useTranslation(); const { t } = useTranslation();
const { setActiveView } = useUI(); const { setActiveView } = useUI();
const { const {
profile: selectedProfile,
setProfile: setSelectedProfile, setProfile: setSelectedProfile,
animationsEnabled, animationsEnabled,
instanceLaunchArgs, instanceLaunchArgs,
@@ -83,7 +92,6 @@ const VersionsView = memo(function VersionsView() {
addCustomEdition: onAddEdition, addCustomEdition: onAddEdition,
updateCustomEdition: onUpdateEdition, updateCustomEdition: onUpdateEdition,
downloadingIds, downloadingIds,
downloadProgress,
updatesAvailable, updatesAvailable,
addToSteam, addToSteam,
cycleBranch, cycleBranch,
@@ -138,7 +146,106 @@ const VersionsView = memo(function VersionsView() {
const [argsSchemas, setArgsSchemas] = useState<Record<string, boolean>>({}); const [argsSchemas, setArgsSchemas] = useState<Record<string, boolean>>({});
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const [menuDir, setMenuDir] = useState<"down" | "up">("down");
const ITEM_COUNT = visibleEditions.length + 3; const ITEM_COUNT = visibleEditions.length + 3;
const [visibleRange, setVisibleRange] = useState<{
start: number;
end: number;
}>({
start: 0,
end: visibleEditions.length ? visibleEditions.length - 1 : 0,
});
const itemHeightsRef = useRef<number[]>([]);
const observersRef = useRef<Map<number, ResizeObserver>>(new Map());
const visibleEditionsRef = useRef(visibleEditions);
visibleEditionsRef.current = visibleEditions;
const focusIndexRef = useRef(focusIndex);
const openMenuIdRef = useRef(openMenuId);
const updateVisibleRange = useCallback(() => {
const container = listRef.current;
const editions = visibleEditionsRef.current;
if (!container) return;
const scrollTop = container.scrollTop;
const viewportHeight = container.clientHeight;
const heights = itemHeightsRef.current;
const n = editions.length;
let acc = 0;
let start = n;
let end = -1;
for (let i = 0; i < n; i++) {
const itemTop = acc;
const itemBottom = acc + (heights[i] ?? ROW_ESTIMATE);
if (
itemTop >= scrollTop &&
itemBottom <= scrollTop + viewportHeight
) {
if (start === n) start = i;
end = i;
}
acc += (heights[i] ?? ROW_ESTIMATE) + ROW_GAP;
}
if (start === n) start = 0;
if (end < 0) end = n - 1;
const fi = focusIndexRef.current;
if (fi >= 0 && fi < n) {
start = Math.min(start, fi);
end = Math.max(end, fi);
}
const menuId = openMenuIdRef.current;
if (menuId) {
const mi = editions.findIndex((e) => e.id === menuId);
if (mi >= 0) {
start = Math.min(start, mi);
end = Math.max(end, mi);
}
}
setVisibleRange((prev) =>
prev.start === start && prev.end === end ? prev : { start, end },
);
}, []);
const setRowRef = (index: number) => (el: HTMLDivElement | null) => {
if (el) {
let ro = observersRef.current.get(index);
if (!ro) {
ro = new ResizeObserver(() => {
itemHeightsRef.current[index] = el.offsetHeight;
updateVisibleRange();
});
observersRef.current.set(index, ro);
}
ro.observe(el);
} else {
const ro = observersRef.current.get(index);
if (ro) {
ro.disconnect();
observersRef.current.delete(index);
}
}
};
useEffect(() => {
focusIndexRef.current = focusIndex;
}, [focusIndex]);
useEffect(() => {
openMenuIdRef.current = openMenuId;
}, [openMenuId]);
useEffect(() => {
const container = listRef.current;
if (!container) return;
const ro = new ResizeObserver(() => updateVisibleRange());
ro.observe(container);
updateVisibleRange();
return () => {
ro.disconnect();
observersRef.current.forEach((obs) => obs.disconnect());
observersRef.current.clear();
};
}, [updateVisibleRange]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (document.activeElement?.tagName === "INPUT") return; if (document.activeElement?.tagName === "INPUT") return;
@@ -263,6 +370,23 @@ const VersionsView = memo(function VersionsView() {
} }
}, [focusIndex]); }, [focusIndex]);
useLayoutEffect(() => {
if (!openMenuId) return;
const container = listRef.current;
const menu = menuRef.current;
if (!container || !menu) return;
const row = menu.closest("[data-index]") as HTMLElement | null;
if (!row) return;
const containerRect = container.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
const menuHeight = menu.offsetHeight;
const spaceBelow = containerRect.bottom - rowRect.bottom;
const spaceAbove = rowRect.top - containerRect.top;
setMenuDir(
spaceBelow < menuHeight && spaceAbove > spaceBelow ? "up" : "down",
);
}, [openMenuId]);
useEffect(() => { useEffect(() => {
const fetchPlaytimes = async () => { const fetchPlaytimes = async () => {
const map: Record<string, PlaytimeResponse> = {}; const map: Record<string, PlaytimeResponse> = {};
@@ -341,51 +465,49 @@ 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"
> >
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 pb-2 w-[40%] max-w-[200px] text-center tracking-widest uppercase font-bold">
{t("versions.title")}
</h2>
<div className="w-full min-w-[480px] p-6 mb-4 mc-options-bg"> <div className="w-full min-w-[480px] p-6 mb-4 mc-options-bg">
<div <div
ref={listRef} ref={listRef}
className="w-full max-h-[45vh] overflow-y-auto py-2 custom-scrollbar" onScroll={updateVisibleRange}
className="w-full max-h-[48vh] overflow-y-auto snap-y snap-mandatory mc-versionrecess hidden-scrollbar"
> >
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1 hidden-scrollbar">
{visibleEditions.map((edition: Edition, i: number) => { {visibleEditions.map((edition: Edition, i: number) => {
const isInstalled = installedVersions.includes( if (i < visibleRange.start || i > visibleRange.end) {
edition.instanceId,
);
const hasAnyInstall = installedVersions.length > 0;
const isSelected =
hasAnyInstall && selectedProfile === edition.instanceId;
const isFocused = focusIndex === i;
const isCustom = edition.id.startsWith("custom_");
const isDownloading = downloadingIds.includes(edition.instanceId);
const isComingSoon = edition.comingSoon;
return ( return (
<div <div
key={edition.id} key={edition.id}
data-index={i} data-index={i}
className={`w-[calc(100%-16px)] mx-2 flex items-center gap-3 p-2 rounded-sm ${ style={{
isSelected && !isComingSoon ? "bg-[#404040]/50" : "" height: itemHeightsRef.current[i] ?? ROW_ESTIMATE,
} ${isFocused && !isComingSoon ? "ring-2 ring-white" : ""} ${ }}
isComingSoon ? "opacity-50 cursor-not-allowed" : "" className="w-[calc(100%-20px)] shrink-0 snap-start"
} relative ${openMenuId === edition.id ? "z-50" : "z-0"}`} />
);
}
const isInstalled = installedVersions.includes(
edition.instanceId,
);
const isFocused = focusIndex === i;
const isCustom = edition.id.startsWith("custom_");
const isDownloading = downloadingIds.includes(edition.instanceId);
const isComingSoon = edition.comingSoon;
return (
<div
key={edition.id}
data-index={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"}`}
onMouseEnter={() => !isComingSoon && setFocusIndex(i)} onMouseEnter={() => !isComingSoon && setFocusIndex(i)}
> >
<div className="w-6 flex items-center justify-center flex-shrink-0"> <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">
{isDownloading ? ( {edition.logo ? (
<span className="text-xs text-gray-400 font-bold">
{Math.floor(downloadProgress[edition.instanceId] || 0)}%
</span>
) : edition.logo ? (
edition.logo.startsWith("http") || edition.logo.startsWith("http") ||
edition.logo.startsWith("/images") ? ( edition.logo.startsWith("/images") ? (
<img <img
src={edition.logo} src={edition.logo}
alt="" alt=""
className={`w-6 h-6 object-contain ${isComingSoon ? "opacity-40 grayscale" : ""}`} className={`w-10 h-10 object-contain ${isComingSoon ? "opacity-40 grayscale" : ""}`}
style={{ imageRendering: "pixelated" }} style={{ imageRendering: "pixelated" }}
/> />
) : ( ) : (
@@ -405,17 +527,11 @@ const VersionsView = memo(function VersionsView() {
onClick={() => onClick={() =>
!isComingSoon && handleEditionClick(edition, i) !isComingSoon && handleEditionClick(edition, i)
} }
className={`flex-1 text-left min-w-0 outline-none rounded cursor-pointer ${ className={`flex-1 text-left min-w-0 outline-none rounded cursor-pointer ${isComingSoon ? "cursor-not-allowed" : ""}`}
focusIndex === i && focusBtn === 0 && !isComingSoon
? "ring-2 ring-white"
: ""
} ${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 ${ className={"text-xl tracking-wide truncate text-white"}
isSelected ? "text-white" : "text-black"
}`}
style={{ textShadow: "none" }} style={{ textShadow: "none" }}
> >
{edition.name} {edition.name}
@@ -472,13 +588,6 @@ const VersionsView = memo(function VersionsView() {
</span> </span>
)} )}
</div> </div>
<p
className={`text-base font-medium leading-tight ${
isSelected ? "text-[#DDDDDD]" : "text-[#666666]"
}`}
>
{edition.desc}
</p>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0 relative"> <div className="flex items-center gap-2 flex-shrink-0 relative">
@@ -568,7 +677,8 @@ const VersionsView = memo(function VersionsView() {
{openMenuId === edition.id && ( {openMenuId === edition.id && (
<div <div
className="absolute right-0 top-11 w-48 bg-[#1a1a1a] border-2 border-[#555] z-[100] shadow-2xl p-0.5 animate-in fade-in zoom-in duration-75" ref={menuRef}
className={`absolute right-0 ${menuDir === "up" ? "bottom-full mb-2" : "top-11"} w-48 bg-[#1a1a1a] border-2 border-[#555] z-[100] shadow-2xl p-0.5 animate-in fade-in zoom-in duration-75`}
style={{ style={{
imageRendering: "pixelated", imageRendering: "pixelated",
}} }}
+42 -2
View File
@@ -72,8 +72,48 @@
image-rendering: pixelated; image-rendering: pixelated;
} }
.mc-options-fg { /*neo: tysm https://leanrada.com/9-slicer/ */ .mc-options-fg {
border-image: url("/images/empty.png") 30 30 30 30 fill / 30px 30px 30px 30px; /*neo: tysm https://leanrada.com/9-slicer/ */
border-image: url("/images/empty.png") 30 30 30 30 fill / 30px 30px 30px
30px;
border-width: 12px;
border-style: solid;
border-color: transparent;
image-rendering: pixelated;
}
.mc-button-ninesliced {
/*neo: tysm https://leanrada.com/9-slicer/ */
border-image: url("/images/Button_Background.png") 13 13 13 13 fill / 13px
13px 13px 13px;
border-width: 12px;
border-style: solid;
border-color: transparent;
image-rendering: pixelated;
}
.mc-button-ninesliced-selected {
/*neo: tysm https://leanrada.com/9-slicer/ */
border-image: url("/images/button_highlighted.png") 13 13 13 13 fill / 13px
13px 13px 13px;
border-width: 12px;
border-style: solid;
border-color: transparent;
image-rendering: pixelated;
}
.mc-versionrecess {
/*neo: tysm https://leanrada.com/9-slicer/ */
border-image: url("/images/versionrecess.png") 12 30 30 12 fill / 12px 30px 30px 12px;
border-width: 12px;
border-style: solid;
border-color: transparent;
image-rendering: pixelated;
}
.mc-help {
/*neo: tysm https://leanrada.com/9-slicer/ */
border-image: url("/images/helpbg.png") 5 15 17 6 fill / 5px 15px 17px 6px;
border-width: 12px; border-width: 12px;
border-style: solid; border-style: solid;
border-color: transparent; border-color: transparent;