setIsNavCollapsed(true)}
+ className="blur"
+ style={{
+ zIndex: maxZIndex - 2,
+ }}
+ />
+ );
+}
diff --git a/src/features/Navigation/NavCollapseToggle.tsx b/src/features/Navigation/NavCollapseToggle.tsx
new file mode 100644
index 00000000..253b785f
--- /dev/null
+++ b/src/features/Navigation/NavCollapseToggle.tsx
@@ -0,0 +1,53 @@
+import { IconButton } from "@radix-ui/themes";
+import clsx from "clsx";
+import styles from "./navigation.module.css";
+import ReadMore from "@material-design-icons/svg/filled/read_more.svg?react";
+import { largeNavToggleHeight, navToggleHeight } from "../../consts";
+import { useSlotsNavigation } from "../../hooks/useSlotsNavigation";
+
+interface NavCollapseToggleProps {
+ isFloating?: boolean;
+ isLarge?: boolean;
+}
+
+export default function NavCollapseToggle({
+ isFloating,
+ isLarge,
+}: NavCollapseToggleProps) {
+ const { showNav, setIsNavCollapsed, showOnlyEpochBar } = useSlotsNavigation();
+
+ const buttonSize = `${isLarge ? largeNavToggleHeight : navToggleHeight}px`;
+
+ if (showOnlyEpochBar) {
+ // Don't allow collapsing when only the epoch bar is shown
+ return (
+
+ );
+ }
+
+ return (
+
setIsNavCollapsed((prev) => !prev)}
+ className={clsx(styles.toggleButton, {
+ [styles.floating]: isFloating,
+ })}
+ style={{
+ height: buttonSize,
+ width: buttonSize,
+ }}
+ >
+
+
+ );
+}
diff --git a/src/features/Navigation/NavFilterToggles.tsx b/src/features/Navigation/NavFilterToggles.tsx
new file mode 100644
index 00000000..2ab5bdc3
--- /dev/null
+++ b/src/features/Navigation/NavFilterToggles.tsx
@@ -0,0 +1,49 @@
+import { Flex, Text } from "@radix-ui/themes";
+import { ToggleGroup } from "radix-ui";
+import { useCallback } from "react";
+
+import { useAtom } from "jotai";
+import { SlotNavFilter, slotNavFilterAtom } from "../../atoms";
+import styles from "./navigation.module.css";
+import { navToggleHeight } from "../../consts";
+
+export default function NavFilterToggles() {
+ const [navFilter, setNavFilter] = useAtom(slotNavFilterAtom);
+
+ const onValueChange = useCallback(
+ (value: SlotNavFilter) => {
+ if (!value) return;
+
+ setNavFilter(value);
+ },
+ [setNavFilter],
+ );
+
+ return (
+
+
+
+ All Slots
+
+
+
+ My Slots
+
+
+
+ );
+}
diff --git a/src/features/Navigation/ResetLive.tsx b/src/features/Navigation/ResetLive.tsx
new file mode 100644
index 00000000..63d6edf9
--- /dev/null
+++ b/src/features/Navigation/ResetLive.tsx
@@ -0,0 +1,29 @@
+import { useAtomValue, useSetAtom } from "jotai";
+import { slotOverrideAtom, statusAtom } from "../../atoms";
+import styles from "./resetLive.module.css";
+import { Button, Text } from "@radix-ui/themes";
+import { ArrowDownIcon, ArrowUpIcon } from "@radix-ui/react-icons";
+
+export default function ResetLive() {
+ const setSlotOverride = useSetAtom(slotOverrideAtom);
+ const status = useAtomValue(statusAtom);
+
+ if (status === "Live") return null;
+
+ return (
+
+
+
+ );
+}
diff --git a/src/features/Navigation/SlotsList.tsx b/src/features/Navigation/SlotsList.tsx
new file mode 100644
index 00000000..cf948b06
--- /dev/null
+++ b/src/features/Navigation/SlotsList.tsx
@@ -0,0 +1,371 @@
+import { useAtomValue, useSetAtom } from "jotai";
+import {
+ autoScrollAtom,
+ currentLeaderSlotAtom,
+ epochAtom,
+ leaderSlotsAtom,
+ SlotNavFilter,
+ slotNavFilterAtom,
+ slotOverrideAtom,
+} from "../../atoms";
+import { Box, Flex, Text } from "@radix-ui/themes";
+import type { RefObject } from "react";
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import styles from "./slotsList.module.css";
+import { slotsListPinnedSlotOffset, slotsPerLeader } from "../../consts";
+import { throttle } from "lodash";
+import SlotsRenderer, { SlotsPlaceholder } from "./SlotsRenderer";
+import type { ScrollSeekConfiguration, VirtuosoHandle } from "react-virtuoso";
+import { Virtuoso } from "react-virtuoso";
+import { baseSelectedSlotAtom } from "../Overview/SlotPerformance/atoms";
+import ResetLive from "./ResetLive";
+import type { DebouncedState } from "use-debounce";
+import { useDebouncedCallback } from "use-debounce";
+import { useCurrentRoute } from "../../hooks/useCurrentRoute";
+import { getSlotGroupLeader } from "../../utils";
+import clsx from "clsx";
+
+const computeItemKey = (slot: number) => slot;
+
+// Add one future slot to prevent current leader transition from flickering
+const increaseViewportBy = { top: 24, bottom: 0 };
+
+interface SlotsListProps {
+ width: number;
+ height: number;
+}
+
+export default function SlotsList({ width, height }: SlotsListProps) {
+ const currentRoute = useCurrentRoute();
+ const navFilter = useAtomValue(slotNavFilterAtom);
+ const epoch = useAtomValue(epochAtom);
+ const isSelectionInitialized =
+ useAtomValue(baseSelectedSlotAtom).isInitialized;
+
+ if (!epoch || (currentRoute === "Slot Details" && !isSelectionInitialized)) {
+ return null;
+ }
+
+ return navFilter === SlotNavFilter.MySlots ? (
+
+ ) : (
+
+ );
+}
+
+interface InnerSlotsListProps {
+ width: number;
+ height: number;
+ slotGroupsDescending: number[];
+ getSlotAtIndex: (index: number) => number;
+ getIndexForSlot: (slot: number) => number;
+}
+function InnerSlotsList({
+ width,
+ height,
+ slotGroupsDescending,
+ getSlotAtIndex,
+ getIndexForSlot,
+}: InnerSlotsListProps) {
+ const listContainerRef = useRef
(null);
+ const listRef = useRef(null);
+ const visibleStartIndexRef = useRef(null);
+
+ const [hideList, setHideList] = useState(true);
+ const [totalListHeight, setTotalListHeight] = useState(0);
+
+ useEffect(() => {
+ // initially hide list to
+ const timeout = setTimeout(() => {
+ setHideList(false);
+ }, 100);
+
+ return () => clearTimeout(timeout);
+ }, []);
+
+ const setSlotOverride = useSetAtom(slotOverrideAtom);
+ const slotsCount = slotGroupsDescending.length;
+
+ const debouncedScroll = useDebouncedCallback(() => {}, 100);
+
+ const { rangeChanged, scrollSeekConfiguration } = useMemo(() => {
+ const rangeChangedFn = ({ startIndex }: { startIndex: number }) => {
+ // account for increaseViewportBy
+ visibleStartIndexRef.current = startIndex + 1;
+ };
+
+ const config: ScrollSeekConfiguration = {
+ enter: (velocity) => Math.abs(velocity) > 1500,
+ exit: (velocity) => Math.abs(velocity) < 500,
+ change: (_, range) => rangeChangedFn(range),
+ };
+ return { rangeChanged: rangeChangedFn, scrollSeekConfiguration: config };
+ }, [visibleStartIndexRef]);
+
+ // Setup user scroll handling
+ useEffect(() => {
+ if (!listContainerRef.current) return;
+ const container = listContainerRef.current;
+
+ const handleSlotOverride = throttle(
+ () => {
+ if (visibleStartIndexRef.current === null) return;
+
+ debouncedScroll();
+
+ const slotIndex = Math.min(
+ visibleStartIndexRef.current + slotsListPinnedSlotOffset,
+ slotsCount - 1,
+ );
+
+ const slot = getSlotAtIndex(slotIndex);
+ setSlotOverride(slot);
+ },
+ 50,
+ { leading: true, trailing: true },
+ );
+
+ const handleScroll = () => {
+ handleSlotOverride();
+ };
+
+ container.addEventListener("wheel", handleScroll);
+ container.addEventListener("touchmove", handleScroll);
+
+ return () => {
+ container.removeEventListener("wheel", handleScroll);
+ container.removeEventListener("touchmove", handleScroll);
+ };
+ }, [
+ getSlotAtIndex,
+ debouncedScroll,
+ setSlotOverride,
+ slotsCount,
+ visibleStartIndexRef,
+ ]);
+
+ return (
+
+
+
+
+
+ }
+ rangeChanged={rangeChanged}
+ components={{ ScrollSeekPlaceholder: MScrollSeekPlaceHolder }}
+ scrollSeekConfiguration={scrollSeekConfiguration}
+ totalListHeightChanged={(height) => setTotalListHeight(height)}
+ />
+
+ );
+}
+
+// Render nothing when scrolling quickly to improve performance
+const MScrollSeekPlaceHolder = memo(function ScrollSeekPlaceholder() {
+ return null;
+});
+
+interface RTAutoScrollProps {
+ listRef: RefObject;
+ getIndexForSlot: (slot: number) => number;
+}
+function RTAutoScroll({ listRef, getIndexForSlot }: RTAutoScrollProps) {
+ const currentLeaderSlot = useAtomValue(currentLeaderSlotAtom);
+ const autoScroll = useAtomValue(autoScrollAtom);
+
+ useEffect(() => {
+ if (!autoScroll || currentLeaderSlot === undefined || !listRef.current)
+ return;
+
+ // scroll to new current leader slot
+ const slotIndex = getIndexForSlot(currentLeaderSlot);
+ const visibleStartIndex = slotIndex - slotsListPinnedSlotOffset;
+
+ listRef.current.scrollToIndex({
+ index: visibleStartIndex > 0 ? visibleStartIndex : 0,
+ align: "start",
+ });
+ }, [autoScroll, currentLeaderSlot, getIndexForSlot, listRef]);
+
+ return null;
+}
+
+interface SlotOverrideScrollProps {
+ listRef: RefObject;
+ getIndexForSlot: (slot: number) => number;
+ debouncedScroll: DebouncedState<() => void>;
+}
+function SlotOverrideScroll({
+ listRef,
+ getIndexForSlot,
+ debouncedScroll,
+}: SlotOverrideScrollProps) {
+ const rafIdRef = useRef(null);
+ const slotOverride = useAtomValue(slotOverrideAtom);
+
+ useEffect(() => {
+ if (
+ slotOverride === undefined ||
+ !listRef.current ||
+ debouncedScroll.isPending()
+ )
+ return;
+
+ const targetIndex = Math.max(
+ 0,
+ getIndexForSlot(slotOverride) - slotsListPinnedSlotOffset,
+ );
+
+ const prevRafId = rafIdRef.current;
+ rafIdRef.current = requestAnimationFrame(() => {
+ if (prevRafId !== null) {
+ cancelAnimationFrame(prevRafId);
+ }
+
+ listRef.current?.scrollToIndex({
+ index: targetIndex,
+ align: "start",
+ });
+ });
+
+ return () => {
+ if (rafIdRef.current !== null) {
+ cancelAnimationFrame(rafIdRef.current);
+ rafIdRef.current = null;
+ }
+ };
+ }, [getIndexForSlot, slotOverride, listRef, debouncedScroll]);
+
+ return null;
+}
+
+function AllSlotsList({ width, height }: SlotsListProps) {
+ const epoch = useAtomValue(epochAtom);
+
+ const slotGroupsDescending = useMemo(() => {
+ if (!epoch) return [];
+
+ const numSlotsInEpoch = epoch.end_slot - epoch.start_slot + 1;
+ return Array.from(
+ { length: Math.ceil(numSlotsInEpoch / slotsPerLeader) },
+ (_, i) => epoch.end_slot - i * slotsPerLeader - (slotsPerLeader - 1),
+ );
+ }, [epoch]);
+
+ const getSlotAtIndex = useCallback(
+ (index: number) => slotGroupsDescending[index],
+ [slotGroupsDescending],
+ );
+
+ const getIndexForSlot = useCallback(
+ (slot: number) => {
+ if (!epoch || slot < epoch.start_slot || slot > epoch.end_slot) return -1;
+ return Math.trunc((epoch.end_slot - slot) / slotsPerLeader);
+ },
+ [epoch],
+ );
+
+ return (
+
+ );
+}
+
+function MySlotsList({ width, height }: SlotsListProps) {
+ const mySlots = useAtomValue(leaderSlotsAtom);
+
+ const slotGroupsDescending = useMemo(
+ () => mySlots?.toReversed() ?? [],
+ [mySlots],
+ );
+
+ const slotToIndexMapping = useMemo(() => {
+ return slotGroupsDescending.reduce<{ [slot: number]: number }>(
+ (acc, slot, index) => {
+ acc[slot] = index;
+ return acc;
+ },
+ {},
+ );
+ }, [slotGroupsDescending]);
+
+ const getSlotAtIndex = useCallback(
+ (index: number) => slotGroupsDescending[index],
+ [slotGroupsDescending],
+ );
+
+ // Get the slot index, or if unavailable, the closest past index
+ const getClosestIndexForSlot = useCallback(
+ (slot: number) => {
+ if (!slotGroupsDescending.length) return 0;
+ if (slot >= slotGroupsDescending[0]) return 0;
+ if (slot <= slotGroupsDescending[slotGroupsDescending.length - 1])
+ return slotGroupsDescending.length - 1;
+
+ return (
+ slotToIndexMapping[getSlotGroupLeader(slot)] ??
+ slotGroupsDescending.findIndex((s) => s <= slot)
+ );
+ },
+ [slotGroupsDescending, slotToIndexMapping],
+ );
+
+ if (!mySlots) return null;
+
+ if (mySlots.length === 0) {
+ return (
+
+
+ No Slots
+
+ Available
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/features/Navigation/SlotsRenderer.tsx b/src/features/Navigation/SlotsRenderer.tsx
new file mode 100644
index 00000000..dc1207f4
--- /dev/null
+++ b/src/features/Navigation/SlotsRenderer.tsx
@@ -0,0 +1,413 @@
+import { atom, useAtomValue } from "jotai";
+import {
+ currentLeaderSlotAtom,
+ currentSlotAtom,
+ firstProcessedSlotAtom,
+ leaderSlotsAtom,
+ nextLeaderSlotAtom,
+ slotDurationAtom,
+} from "../../atoms";
+import { Box, Flex, Progress, Text } from "@radix-ui/themes";
+import { useSlotQueryPublish } from "../../hooks/useSlotQuery";
+import type React from "react";
+import { memo, useMemo } from "react";
+import styles from "./slotsRenderer.module.css";
+import PeerIcon from "../../components/PeerIcon";
+import { slotsPerLeader } from "../../consts";
+import { useSlotInfo } from "../../hooks/useSlotInfo";
+import clsx from "clsx";
+import { Link } from "@tanstack/react-router";
+import { getSlotGroupLeader } from "../../utils";
+import { selectedSlotAtom } from "../Overview/SlotPerformance/atoms";
+import {
+ slotStatusBlue,
+ slotStatusDullTeal,
+ slotStatusGreen,
+ slotStatusRed,
+ slotStatusTeal,
+} from "../../colors";
+import SlotClient from "../../components/SlotClient";
+import { useIsLeaderGroupSkipped } from "../../hooks/useIsLeaderGroupSkipped";
+import { isScrollingAtom } from "./atoms";
+import useNextSlot from "../../hooks/useNextSlot";
+
+export default function SlotsRenderer(props: { leaderSlotForGroup: number }) {
+ const isScrolling = useAtomValue(isScrollingAtom);
+
+ if (isScrolling) return ;
+
+ return ;
+}
+
+const getStatusAtom = atom((get) => {
+ const currentLeaderSlot = get(currentLeaderSlotAtom);
+ const firstProcessedSlot = get(firstProcessedSlotAtom);
+ const leaderSlots = get(leaderSlotsAtom);
+
+ if (
+ !leaderSlots ||
+ currentLeaderSlot === undefined ||
+ firstProcessedSlot === undefined
+ )
+ return;
+
+ const nextLeaderSlot = get(nextLeaderSlotAtom);
+
+ return function getStatus(slot: number) {
+ return {
+ isCurrentSlotGroup:
+ currentLeaderSlot <= slot && slot < currentLeaderSlot + slotsPerLeader,
+ isFutureSlotGroup: currentLeaderSlot + slotsPerLeader <= slot,
+ isProcessedSlotGroup:
+ firstProcessedSlot <= slot && slot <= currentLeaderSlot,
+ isYourNextLeaderGroup:
+ nextLeaderSlot &&
+ nextLeaderSlot <= slot &&
+ slot < nextLeaderSlot + slotsPerLeader,
+ };
+ };
+});
+
+const MSlotsRenderer = memo(function SlotsRenderer({
+ leaderSlotForGroup,
+}: {
+ leaderSlotForGroup: number;
+}) {
+ const getStatus = useAtomValue(getStatusAtom);
+ const status = getStatus?.(leaderSlotForGroup);
+ if (!status) return ;
+
+ const { isFutureSlotGroup, isCurrentSlotGroup, isYourNextLeaderGroup } =
+ status;
+
+ return (
+
+ {isCurrentSlotGroup ? (
+
+ ) : isYourNextLeaderGroup ? (
+
+ ) : isFutureSlotGroup ? (
+
+ ) : (
+
+ )}
+
+ );
+});
+
+function YourNextLeaderSlotGroup({ firstSlot }: { firstSlot: number }) {
+ const { progressSinceLastLeader, nextSlotText } = useNextSlot({
+ showNowIfCurrent: false,
+ durationOptions: {
+ showOnlyTwoSignificantUnits: true,
+ },
+ });
+
+ return (
+
+
+
+
+
+ {nextSlotText}
+
+
+
+
+
+
+ );
+}
+
+interface SlotGroupProps {
+ firstSlot: number;
+}
+
+function FutureSlotGroup({ firstSlot }: SlotGroupProps) {
+ const { isLeader: isYou } = useSlotInfo(firstSlot);
+ return (
+
+
+
+
+ );
+}
+
+function CurrentLeaderSlotGroup({ firstSlot }: { firstSlot: number }) {
+ const { isLeader: isYou } = useSlotInfo(firstSlot);
+ const hasSkipped = useIsLeaderGroupSkipped(firstSlot);
+ const currentSlot = useAtomValue(currentSlotAtom);
+ return (
+
+
+
+
+
+ {currentSlot}
+
+
+
+
+
+ );
+}
+
+function PastSlotGroup({ firstSlot }: SlotGroupProps) {
+ const { isLeader: isYou } = useSlotInfo(firstSlot);
+ const getStatus = useAtomValue(getStatusAtom);
+ const status = getStatus?.(firstSlot);
+ const hasSkipped = useIsLeaderGroupSkipped(firstSlot);
+
+ if (!status) return;
+ const { isProcessedSlotGroup } = status;
+
+ return isYou && isProcessedSlotGroup ? (
+
+ ) : (
+
+
+
+
+ );
+}
+
+function YourProcessedSlotGroup({ firstSlot }: { firstSlot: number }) {
+ const selectedSlot = useAtomValue(selectedSlotAtom);
+ const hasSkipped = useIsLeaderGroupSkipped(firstSlot);
+
+ const isSelected =
+ selectedSlot !== undefined &&
+ getSlotGroupLeader(selectedSlot) === firstSlot;
+
+ return (
+
+
+
+
+
+
+ );
+}
+
+function SlotContent({ firstSlot }: SlotGroupProps) {
+ return (
+
+
+
+
+ {firstSlot}
+
+
+ );
+}
+
+export function SlotsPlaceholder({
+ width,
+ height,
+ totalListHeight,
+}: {
+ width: number;
+ height: number;
+ totalListHeight: number;
+}) {
+ const items = useMemo(() => Math.ceil(height / 46), [height]);
+ if (totalListHeight < height) return;
+
+ return (
+
+ {Array.from({ length: items }, (_, index) => (
+
+ ))}
+
+ );
+}
+
+export const MScrollPlaceholderItem = memo(function ScrollPlaceholderItem() {
+ return (
+
+
+
+ );
+});
+
+function SlotIconName({
+ slot,
+ iconSize = 15,
+}: {
+ slot: number;
+ iconSize?: number;
+}) {
+ const { peer, isLeader, name } = useSlotInfo(slot);
+ return (
+
+
+ {name}
+
+ );
+}
+
+interface SlotStatusesProps {
+ firstSlot: number;
+ isCurrentSlot?: boolean;
+ isPastSlot?: boolean;
+}
+
+function SlotStatuses({
+ firstSlot,
+ isCurrentSlot = false,
+ isPastSlot = false,
+}: SlotStatusesProps) {
+ return (
+
+ {Array.from({ length: slotsPerLeader }).map((_, slotIdx) => {
+ const slot = firstSlot + (slotsPerLeader - 1) - slotIdx;
+
+ if (isCurrentSlot) {
+ return ;
+ }
+
+ if (isPastSlot) {
+ return ;
+ }
+
+ return ;
+ })}
+
+ );
+}
+
+function SlotStatus({
+ borderColor,
+ backgroundColor,
+ slotDuration,
+}: {
+ borderColor?: string;
+ backgroundColor?: string;
+ slotDuration?: number;
+}) {
+ return (
+
+ {slotDuration && (
+
+ )}
+
+ );
+}
+
+function CurrentSlotStatus({ slot }: { slot: number }) {
+ const currentSlot = useAtomValue(currentSlotAtom);
+ const queryPublish = useSlotQueryPublish(slot);
+ const slotDuration = useAtomValue(slotDurationAtom);
+
+ const isCurrent = useMemo(() => slot === currentSlot, [slot, currentSlot]);
+ const colorStyle = useMemo(() => {
+ if (isCurrent) return { borderColor: slotStatusBlue };
+ if (!queryPublish.publish) return {};
+ if (queryPublish.publish.skipped) return { backgroundColor: slotStatusRed };
+ switch (queryPublish.publish.level) {
+ case "incomplete":
+ return {};
+ case "completed":
+ return { borderColor: slotStatusGreen };
+ case "optimistically_confirmed":
+ return { backgroundColor: slotStatusGreen };
+ case "finalized":
+ case "rooted":
+ return { backgroundColor: slotStatusTeal };
+ }
+ }, [isCurrent, queryPublish.publish]);
+
+ return (
+
+ );
+}
+
+function PastSlotStatus({ slot }: { slot: number }) {
+ const queryPublish = useSlotQueryPublish(slot);
+ const selectedSlot = useAtomValue(selectedSlotAtom);
+ const backgroundColor = useMemo(() => {
+ if (!queryPublish.publish) return;
+ if (queryPublish.publish.skipped) return slotStatusRed;
+ switch (queryPublish.publish.level) {
+ case "incomplete":
+ return;
+ case "completed":
+ return slotStatusGreen;
+ case "optimistically_confirmed":
+ return slotStatusGreen;
+ case "finalized":
+ case "rooted":
+ if (
+ selectedSlot !== undefined &&
+ getSlotGroupLeader(slot) === getSlotGroupLeader(selectedSlot)
+ ) {
+ return slotStatusTeal;
+ }
+ return slotStatusDullTeal;
+ }
+ }, [queryPublish.publish, selectedSlot, slot]);
+
+ return ;
+}
diff --git a/src/features/Navigation/Status.tsx b/src/features/Navigation/Status.tsx
new file mode 100644
index 00000000..a0f1cce2
--- /dev/null
+++ b/src/features/Navigation/Status.tsx
@@ -0,0 +1,77 @@
+import { useAtomValue } from "jotai";
+import type { Status } from "../../atoms";
+import { statusAtom } from "../../atoms";
+import { useMemo } from "react";
+import historyIcon from "../../assets/history.svg";
+import futureIcon from "../../assets/future.svg";
+import { Flex, Text, Tooltip } from "@radix-ui/themes";
+import styles from "./status.module.css";
+import clsx from "clsx";
+
+const statusToLabel: Record = {
+ Live: "RT",
+ Past: "PT",
+ Current: "CT",
+ Future: "FT",
+};
+
+export function StatusIndicator() {
+ const status = useAtomValue(statusAtom);
+
+ const text = useMemo(() => {
+ if (!status) return null;
+ return status === "Live" ? (
+
+ {statusToLabel[status]}
+
+ ) : (
+
+ {statusToLabel[status]}
+
+ );
+ }, [status]);
+
+ const icon = useMemo(() => {
+ if (!status) return null;
+ return (
+
+ {status === "Live" ? (
+
+ ) : (
+
+ )}
+
+ );
+ }, [status]);
+
+ if (!status) return null;
+
+ return (
+
+ {text}
+ {icon}
+
+ );
+}
diff --git a/src/features/Navigation/atoms.ts b/src/features/Navigation/atoms.ts
new file mode 100644
index 00000000..942ae2cd
--- /dev/null
+++ b/src/features/Navigation/atoms.ts
@@ -0,0 +1,3 @@
+import { atom } from "jotai";
+
+export const isScrollingAtom = atom(false);
diff --git a/src/features/EpochBar/epochSlider.module.css b/src/features/Navigation/epochSlider.module.css
similarity index 53%
rename from src/features/EpochBar/epochSlider.module.css
rename to src/features/Navigation/epochSlider.module.css
index d22ac335..0b282305 100644
--- a/src/features/EpochBar/epochSlider.module.css
+++ b/src/features/Navigation/epochSlider.module.css
@@ -1,25 +1,19 @@
-@import "@radix-ui/colors/black-alpha.css";
-@import "@radix-ui/colors/violet.css";
-
-.container {
- /* width: "100%"; */
- position: relative;
- flex-grow: 1;
-}
-
.epoch-progress {
- height: 100%;
+ width: 100%;
background: var(--epoch-slider-progress-color);
- border-top-left-radius: 9999px;
- border-bottom-left-radius: 9999px;
+ position: absolute;
+ bottom: 0;
+}
+
+.clickable {
+ cursor: pointer;
}
.leader-slot {
- height: 100%;
- background: var(--my-slots-color);
- width: 5px;
+ width: 100%;
+ background: #2a7edf;
+ height: 5px;
position: absolute;
- top: 0px;
opacity: 0.5;
&:hover {
filter: brightness(1.5);
@@ -30,40 +24,39 @@
}
.skipped-slot {
- height: 100%;
- background: var(--epoch-skipped-slot-color);
- width: 3px;
+ width: 100%;
+ background: #ff5353;
+ height: 3px;
position: absolute;
- top: 0px;
&:hover {
filter: brightness(1.5);
}
}
.skipped-slot-icon {
- width: 12px;
+ height: 10px;
position: absolute;
- top: -15px;
+ left: 11px;
&:hover {
filter: brightness(1.5);
}
}
.first-processed-slot {
- height: 100%;
- background: var(--header-color);
- width: 3px;
+ width: 100%;
+ background: #bdf3ff;
+ height: 3px;
position: absolute;
- top: 0px;
+ right: 0px;
&:hover {
filter: brightness(1.5);
}
}
.first-processed-slot-icon {
- width: 12px;
+ height: 10px;
position: absolute;
- top: -15px;
+ left: 11px;
&:hover {
filter: brightness(1.5);
}
@@ -71,37 +64,55 @@
.slider-root {
position: relative;
+ flex-grow: 1;
+ width: 10px;
display: flex;
+ flex-direction: column;
align-items: center;
user-select: none;
touch-action: none;
}
.slider-track {
- background: var(--dropdown-background-color);
- position: relative;
+ background: #24262b;
flex-grow: 1;
- border-radius: 9999px;
- height: 10px;
- /* To round leader slots markers at beginning/end of epoch slider */
- overflow: hidden;
+ width: 100%;
}
.slider-thumb {
display: block;
- width: 10px;
- height: 20px;
- box-shadow: 0 2px 8px var(--black-a7);
- background: var(--gray-7);
- border: 1px solid var(--gray-12);
+ position: relative;
+ height: 10px;
+ width: 20px;
+ background: rgba(100, 101, 101, 0.5);
+ border: 1px solid #a4a4a4;
border-radius: 2px;
- opacity: 0.5;
+ cursor: grab;
+
+ &.collapsed {
+ border-left-width: 0;
+ transition: border-width 0s linear 0.2s;
+ }
}
.slider-thumb:hover {
- background: var(--gray-3);
+ background: rgba(100, 101, 101, 0.3);
}
.slider-thumb:focus {
outline: none;
box-shadow: 0 0 0 2px var(--gray-a8);
}
+
+.hide {
+ opacity: 0;
+ display: none;
+ transition:
+ opacity 0.5s ease-out 1s,
+ display 0s 1.5s;
+ transition-behavior: allow-discrete;
+}
+
+.show {
+ opacity: 1;
+ display: block;
+}
diff --git a/src/features/Navigation/index.tsx b/src/features/Navigation/index.tsx
new file mode 100644
index 00000000..6647805c
--- /dev/null
+++ b/src/features/Navigation/index.tsx
@@ -0,0 +1,109 @@
+import { Flex } from "@radix-ui/themes";
+import { useMemo } from "react";
+
+import SlotsList from "./SlotsList";
+
+import {
+ clusterIndicatorHeight,
+ headerHeight,
+ logoRightSpacing,
+ logoWidth,
+ narrowNavMedia,
+ slotsNavSpacing,
+ navToggleHeight,
+ maxZIndex,
+ slotsListWidth,
+ epochThumbPadding,
+ slotNavWidth,
+ slotNavWithoutListWidth,
+} from "../../consts";
+import { StatusIndicator } from "./Status";
+import AutoSizer from "react-virtualized-auto-sizer";
+import NavFilterToggles from "./NavFilterToggles";
+import EpochSlider from "./EpochSlider";
+import clsx from "clsx";
+import styles from "./navigation.module.css";
+import NavCollapseToggle from "./NavCollapseToggle";
+import { useMedia } from "react-use";
+import { useSlotsNavigation } from "../../hooks/useSlotsNavigation";
+
+const top = clusterIndicatorHeight + headerHeight;
+
+/**
+ * On narrow screens, container width is 0
+ * On collapse, content width shrinks to 0
+ */
+export default function Navigation() {
+ const isNarrow = useMedia(narrowNavMedia);
+
+ const { showNav, occupyRowWidth, showOnlyEpochBar } = useSlotsNavigation();
+
+ // padding to make sure epoch thumb is visible,
+ // as it is positioned slightly outside of the container
+ const thumbPadding = showNav ? epochThumbPadding : 0;
+
+ const width = useMemo(() => {
+ return showOnlyEpochBar ? slotNavWithoutListWidth : slotNavWidth;
+ }, [showOnlyEpochBar]);
+
+ return (
+
+
+
+ {isNarrow && (
+
+
+
+ )}
+
+
+
+
+
+ {!showOnlyEpochBar && (
+
+
+
+
+ {({ height, width }) => (
+
+ )}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/features/Navigation/navigation.module.css b/src/features/Navigation/navigation.module.css
new file mode 100644
index 00000000..be78bb13
--- /dev/null
+++ b/src/features/Navigation/navigation.module.css
@@ -0,0 +1,85 @@
+.nav-filter-toggle-group {
+ display: flex;
+ flex-wrap: nowrap;
+ width: 100%;
+
+ button {
+ cursor: pointer;
+ flex-grow: 1;
+ height: 21px;
+ border: none;
+ padding: 3px 5px;
+ color: var(--nav-button-inactive-text-color);
+ background-color: rgba(255, 255, 255, 0.1);
+ &:first-child {
+ border-top-left-radius: 5px;
+ border-bottom-left-radius: 5px;
+ }
+
+ &:last-child {
+ border-top-right-radius: 5px;
+ border-bottom-right-radius: 5px;
+ }
+
+ &[data-state="on"] {
+ background-color: var(--slot-nav-filter-background-color);
+ color: var(--nav-button-text-color);
+ }
+
+ &:hover {
+ filter: brightness(1.2);
+ }
+
+ span {
+ cursor: inherit;
+ font-size: 12px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: normal;
+ }
+ }
+}
+
+.toggle-button-size {
+ height: 15px;
+ width: 15px;
+
+ &.lg {
+ height: 18px;
+ width: 18px;
+ }
+}
+
+.toggle-button {
+ border-radius: 5px;
+ background-color: var(--epoch-slider-progress-color);
+
+ &:hover {
+ filter: brightness(1.2);
+ }
+
+ &.floating {
+ box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.75);
+ }
+
+ svg {
+ fill: var(--nav-button-text-color);
+ height: 15px;
+ width: 15px;
+
+ &.lg {
+ height: 18px;
+ width: 18px;
+ }
+
+ &.mirror {
+ transform: scaleX(-1);
+ }
+ }
+}
+
+.slot-nav-container {
+ transition: width 0.3s;
+ box-sizing: border-box;
+ background: var(--slot-nav-background-color);
+}
diff --git a/src/features/Navigation/resetLive.module.css b/src/features/Navigation/resetLive.module.css
new file mode 100644
index 00000000..6489c341
--- /dev/null
+++ b/src/features/Navigation/resetLive.module.css
@@ -0,0 +1,17 @@
+.container {
+ display: flex;
+ justify-content: center;
+
+ .button {
+ position: absolute;
+ width: 100px;
+ height: 18px;
+ padding: 2px 4px 2px 6px;
+ align-items: center;
+ border-radius: 40px;
+ background: #174e45;
+ box-shadow: 0 4px 4px 0 rgba(28, 82, 73, 0.4);
+ font-size: 12px;
+ font-weight: 600;
+ }
+}
diff --git a/src/features/Navigation/scrollbar.module.css b/src/features/Navigation/scrollbar.module.css
new file mode 100644
index 00000000..b76b0dcf
--- /dev/null
+++ b/src/features/Navigation/scrollbar.module.css
@@ -0,0 +1,11 @@
+.icon {
+ position: absolute;
+ line-height: 0;
+ &:hover {
+ filter: brightness(1.5);
+ }
+}
+
+&.icon:hover {
+ filter: brightness(1.5);
+}
diff --git a/src/features/Navigation/slotsList.module.css b/src/features/Navigation/slotsList.module.css
new file mode 100644
index 00000000..79035284
--- /dev/null
+++ b/src/features/Navigation/slotsList.module.css
@@ -0,0 +1,12 @@
+.slots-list {
+ scrollbar-width: none;
+ &.hidden {
+ visibility: hidden;
+ }
+}
+
+.no-slots-text {
+ font-size: 12px;
+ color: var(--regular-text-color);
+ text-align: center;
+}
diff --git a/src/features/Navigation/slotsRenderer.module.css b/src/features/Navigation/slotsRenderer.module.css
new file mode 100644
index 00000000..4d3a51d3
--- /dev/null
+++ b/src/features/Navigation/slotsRenderer.module.css
@@ -0,0 +1,198 @@
+.slot-group-container {
+ padding-bottom: 5px;
+ background: var(--slot-nav-background-color);
+}
+
+.slot-group {
+ column-gap: 4px;
+ row-gap: 3px;
+ border-radius: 5px;
+ background: var(--slots-list-slot-background-color);
+}
+
+.left-column {
+ flex-grow: 1;
+ min-width: 0;
+ gap: 4px;
+}
+
+.future {
+ padding: 3px;
+ background: var(--slots-list-future-slot-background-color);
+ color: var(--slots-list-future-slot-color);
+ img {
+ filter: grayscale(100%);
+ }
+
+ &.you {
+ border: solid var(--slots-list-not-processed-my-slots-border-color);
+ border-width: 2px 1px 1px 1px;
+ padding: 2px 3px 3px 3px;
+ background: var(--slots-list-my-slot-background-color);
+ }
+}
+
+.current {
+ padding: 2px;
+ border: 1px solid var(--container-border-color);
+ background-color: var(--container-background-color);
+ color: var(--slots-list-slot-color);
+ box-shadow: 0 0 16px 0 var(--slots-list-current-slot-box-shadow-color) inset;
+
+ .slot-name {
+ font-size: 18px;
+ }
+
+ &.skipped {
+ background: var(--slots-list-skipped-background-color);
+ }
+
+ &.you {
+ border-width: 3px 1px 1px 1px;
+ border-color: var(--slots-list-my-slots-selected-border-color);
+ }
+}
+
+.current-slot-row {
+ background-color: var(--slots-list-current-slot-number-background-color);
+ border-radius: 5px;
+ padding: 3px 5px;
+}
+
+.past {
+ padding: 3px;
+ color: var(--slots-list-past-slot-color);
+
+ &.skipped {
+ background: var(--slots-list-skipped-background-color);
+ }
+
+ &.you {
+ background: var(--slots-list-my-slot-background-color);
+
+ &.processed {
+ text-decoration: none;
+ padding: 2px;
+ border: solid var(--slots-list-my-slots-border-color);
+ border-width: 3px 1px 1px 1px;
+ background: var(--slots-list-my-slot-background-color);
+ color: var(--slots-list-past-slot-color);
+
+ &:hover,
+ &:active {
+ border-color: var(--slots-list-my-slots-selected-border-color);
+ }
+
+ &.selected {
+ background: var(--slots-list-selected-background-color);
+ border-color: var(--slots-list-my-slots-selected-border-color);
+ }
+
+ &.skipped,
+ &.selected.skipped {
+ background: var(--slots-list-skipped-selected-background-color);
+ }
+ }
+ }
+}
+
+.slot-name {
+ font-size: 12px;
+ font-weight: 400;
+}
+
+.ellipsis {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.progress-bar {
+ width: 100%;
+ height: 2px;
+ div {
+ background-color: var(--slots-list-next-leader-progress-bar-color);
+ }
+}
+
+.slot-item-content {
+ align-items: center;
+ gap: 4px;
+ font-size: 10px;
+ font-weight: 400;
+ color: var(--slots-list-past-slot-number-color);
+}
+
+.placeholder {
+ height: 42px;
+}
+
+.slot-statuses {
+ .slot-status {
+ width: 4px;
+ height: 6px;
+ background: var(--slot-status-gray);
+ border: 1px solid transparent;
+ border-radius: 2px;
+ align-items: flex-end;
+
+ .slot-status-progress {
+ width: 100%;
+ height: 0;
+ animation: fillProgress var(--slot-duration) ease-in-out forwards;
+ background-color: var(--slot-status-blue);
+ }
+ }
+
+ &.tall {
+ gap: 3px;
+ .slot-status {
+ flex-grow: 1;
+ }
+ }
+
+ &.short .slot-status {
+ height: 3px;
+ border-radius: 1px;
+ }
+}
+
+@keyframes fillProgress {
+ from {
+ height: 0;
+ }
+ to {
+ height: 100%;
+ }
+}
+
+.scroll-placeholder-item {
+ position: relative;
+ overflow: hidden;
+ height: 100%;
+}
+
+.scroll-placeholder-item::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: -150%;
+ width: 200%;
+ height: 100%;
+ background: linear-gradient(
+ to right,
+ rgba(255, 255, 255, 0) 0%,
+ rgba(255, 246, 246, 0.05) 50%,
+ rgba(255, 255, 255, 0) 100%
+ );
+ animation: shimmer 1.5s infinite;
+}
+
+@keyframes shimmer {
+ from {
+ left: -100%;
+ }
+ to {
+ left: 100%;
+ }
+}
diff --git a/src/features/Navigation/status.module.css b/src/features/Navigation/status.module.css
new file mode 100644
index 00000000..2e482a6d
--- /dev/null
+++ b/src/features/Navigation/status.module.css
@@ -0,0 +1,27 @@
+.status-indicator {
+ font-size: 12px;
+ font-weight: 400;
+}
+
+.status-indicator-live {
+ color: var(--green-live);
+}
+
+.status-indicator-not-live {
+ color: #3cb4ff;
+}
+
+.status-reset {
+ background-color: transparent;
+ color: #3cb4ff;
+ width: unset;
+ height: unset;
+ padding: 0;
+}
+
+.dot-icon {
+ width: 4px;
+ height: 4px;
+ border-radius: 50%;
+ background-color: var(--green-live);
+}
diff --git a/src/features/Overview/EpochCard/epochCard.module.css b/src/features/Overview/EpochCard/epochCard.module.css
new file mode 100644
index 00000000..9183ad0a
--- /dev/null
+++ b/src/features/Overview/EpochCard/epochCard.module.css
@@ -0,0 +1,21 @@
+.progress {
+ max-height: 11px;
+ min-width: 140px;
+ background: var(--dropdown-background-color);
+
+ div {
+ background: var(--progress-background-color);
+ }
+}
+
+.stat-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ width: 100%;
+
+ > div {
+ flex: 1 1 auto;
+ min-width: 180px;
+ }
+}
diff --git a/src/features/Overview/EpochCard/index.tsx b/src/features/Overview/EpochCard/index.tsx
new file mode 100644
index 00000000..95ee4587
--- /dev/null
+++ b/src/features/Overview/EpochCard/index.tsx
@@ -0,0 +1,83 @@
+import { Flex, Progress, Box } from "@radix-ui/themes";
+import CardHeader from "../../../components/CardHeader";
+import Card from "../../../components/Card";
+import CardStat from "../../../components/CardStat";
+import { useAtomValue } from "jotai";
+import styles from "./epochCard.module.css";
+import { currentSlotAtom, epochAtom, slotDurationAtom } from "../../../atoms";
+import { headerColor } from "../../../colors";
+import { useMemo } from "react";
+import { getDurationText } from "../../../utils";
+import { Duration } from "luxon";
+
+export default function EpochCard() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function CurrentSlotText() {
+ const epoch = useAtomValue(epochAtom);
+
+ return (
+
+
+
+ );
+}
+
+function NextEpochTimeText() {
+ const slot = useAtomValue(currentSlotAtom);
+ const epoch = useAtomValue(epochAtom);
+ const slotDuration = useAtomValue(slotDurationAtom);
+
+ const nextEpochText = useMemo(() => {
+ if (epoch === undefined || slot === undefined) return "";
+
+ const endDiffMs = (epoch.end_slot - slot) * slotDuration;
+
+ const durationLeft = Duration.fromMillis(endDiffMs).rescale();
+ return getDurationText(durationLeft);
+ }, [epoch, slot, slotDuration]);
+
+ const progressSinceLastEpoch = useMemo(() => {
+ if (epoch === undefined || slot === undefined) return 0;
+ const currentSlotDiff = slot - epoch.start_slot;
+ const epochDiff = epoch.end_slot - epoch.start_slot;
+ const progress = (currentSlotDiff / epochDiff) * 100;
+ if (progress < 0 || progress > 100) return 0;
+ return progress;
+ }, [epoch, slot]);
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/features/Overview/SlotPerformance/ComputeUnitsCard/index.tsx b/src/features/Overview/SlotPerformance/ComputeUnitsCard/index.tsx
index e265fd0b..524616a6 100644
--- a/src/features/Overview/SlotPerformance/ComputeUnitsCard/index.tsx
+++ b/src/features/Overview/SlotPerformance/ComputeUnitsCard/index.tsx
@@ -35,7 +35,7 @@ export default function ComputeUnitsCard() {
return (
<>
-
+
@@ -52,11 +52,11 @@ export default function ComputeUnitsCard() {
bankTileCount={bankTileCount}
onCreate={handleCreate}
/>
+