diff --git a/public/images/helpbg.png b/public/images/helpbg.png new file mode 100644 index 0000000..9fa27aa Binary files /dev/null and b/public/images/helpbg.png differ diff --git a/public/images/neoLegacy.png b/public/images/neoLegacy.png index 7ec2425..add9fd9 100644 Binary files a/public/images/neoLegacy.png and b/public/images/neoLegacy.png differ diff --git a/public/images/versionrecess.png b/public/images/versionrecess.png new file mode 100644 index 0000000..4fa0de3 Binary files /dev/null and b/public/images/versionrecess.png differ diff --git a/src/components/layout/DownloadOverlay.tsx b/src/components/layout/DownloadOverlay.tsx index 4848a90..2130e7f 100644 --- a/src/components/layout/DownloadOverlay.tsx +++ b/src/components/layout/DownloadOverlay.tsx @@ -10,7 +10,11 @@ interface DownloadOverlayProps { 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(); if (downloadingIds.length === 0) return null; @@ -24,14 +28,14 @@ export const DownloadOverlay = memo(function DownloadOverlay({ downloadProgress, style={{ imageRendering: "pixelated" }} >
- - {t("download.title")} - + {t("download.title")}
{downloadingIds.map((id) => { 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 = edition?.name || (id.startsWith("runner_") @@ -43,53 +47,61 @@ export const DownloadOverlay = memo(function DownloadOverlay({ downloadProgress,
{edition?.logo ? ( - edition.logo.startsWith("http") || edition.logo.startsWith("/images") ? ( - + edition.logo.startsWith("http") || + edition.logo.startsWith("/images") ? ( + + ) : ( + + ) + ) : edition?.titleImage ? ( + edition.titleImage.startsWith("http") || + edition.titleImage.startsWith("/images") ? ( + + ) : ( + + ) ) : ( - - ) - ) : edition?.titleImage ? ( - edition.titleImage.startsWith("http") || edition.titleImage.startsWith("/images") ? ( - - ) : ( - - ) - ) : ( -
- - - - - -
- )} - - {name} - -
- - {Math.floor(pct)}% - +
+ + + + + +
+ )} + + {name} + +
+ + {Math.floor(pct)}% +
diff --git a/src/components/views/VersionsView.tsx b/src/components/views/VersionsView.tsx index 4be1c52..94f8ead 100644 --- a/src/components/views/VersionsView.tsx +++ b/src/components/views/VersionsView.tsx @@ -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 { motion } from "framer-motion"; 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 { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); @@ -66,7 +76,6 @@ const VersionsView = memo(function VersionsView() { const { t } = useTranslation(); const { setActiveView } = useUI(); const { - profile: selectedProfile, setProfile: setSelectedProfile, animationsEnabled, instanceLaunchArgs, @@ -83,7 +92,6 @@ const VersionsView = memo(function VersionsView() { addCustomEdition: onAddEdition, updateCustomEdition: onUpdateEdition, downloadingIds, - downloadProgress, updatesAvailable, addToSteam, cycleBranch, @@ -138,7 +146,106 @@ const VersionsView = memo(function VersionsView() { const [argsSchemas, setArgsSchemas] = useState>({}); const containerRef = useRef(null); const listRef = useRef(null); + const menuRef = useRef(null); + const [menuDir, setMenuDir] = useState<"down" | "up">("down"); 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([]); + const observersRef = useRef>(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(() => { const handleKeyDown = (e: KeyboardEvent) => { if (document.activeElement?.tagName === "INPUT") return; @@ -263,6 +370,23 @@ const VersionsView = memo(function VersionsView() { } }, [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(() => { const fetchPlaytimes = async () => { const map: Record = {}; @@ -341,51 +465,49 @@ const VersionsView = memo(function VersionsView() { transition={{ duration: animationsEnabled ? 0.25 : 0 }} className="flex flex-col items-center w-full max-w-5xl outline-none" > -

- {t("versions.title")} -

-
-
-
+
+
{visibleEditions.map((edition: Edition, i: number) => { + if (i < visibleRange.start || i > visibleRange.end) { + return ( +
+ ); + } const isInstalled = installedVersions.includes( 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 (
!isComingSoon && setFocusIndex(i)} > -
- {isDownloading ? ( - - {Math.floor(downloadProgress[edition.instanceId] || 0)}% - - ) : edition.logo ? ( +
+ {edition.logo ? ( edition.logo.startsWith("http") || edition.logo.startsWith("/images") ? ( ) : ( @@ -405,17 +527,11 @@ const VersionsView = memo(function VersionsView() { onClick={() => !isComingSoon && handleEditionClick(edition, i) } - className={`flex-1 text-left min-w-0 outline-none rounded cursor-pointer ${ - focusIndex === i && focusBtn === 0 && !isComingSoon - ? "ring-2 ring-white" - : "" - } ${isComingSoon ? "cursor-not-allowed" : ""}`} + className={`flex-1 text-left min-w-0 outline-none rounded cursor-pointer ${isComingSoon ? "cursor-not-allowed" : ""}`} >
{edition.name} @@ -472,13 +588,6 @@ const VersionsView = memo(function VersionsView() { )}
-

- {edition.desc} -

@@ -568,7 +677,8 @@ const VersionsView = memo(function VersionsView() { {openMenuId === edition.id && (