merge: stack widget modernization on dnd kit
This commit is contained in:
940 files changed
+104475
-33649
No files matched your search
@@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
|
||||
|
||||
import { Spotlight } from "@homarr/spotlight";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { ClientBurger } from "./header/burger";
|
||||
@@ -9,6 +8,7 @@ import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
||||
import { TourTarget } from "./header/tour-target";
|
||||
import { UserButton } from "./header/user";
|
||||
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
|
||||
import { LazySpotlight } from "./header/lazy-spotlight";
|
||||
|
||||
interface Props {
|
||||
logo?: ReactNode;
|
||||
@@ -23,6 +23,7 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
||||
zIndex="var(--homarr-z-index-board-header)"
|
||||
style={{ overflowX: "hidden" }}
|
||||
data-advanced-focus-background
|
||||
data-app-shell-header
|
||||
>
|
||||
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
|
||||
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
|
||||
@@ -42,7 +43,7 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
||||
</TourTarget>
|
||||
</Group>
|
||||
</Group>
|
||||
<Spotlight />
|
||||
<LazySpotlight />
|
||||
</AppShellHeader>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ForwardedRef, ReactNode } from "react";
|
||||
import type { FocusEventHandler, ForwardedRef, PointerEventHandler, ReactNode } from "react";
|
||||
import { forwardRef } from "react";
|
||||
import type { ActionIconProps } from "@mantine/core";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
@@ -8,12 +8,17 @@ import { Link } from "@homarr/ui";
|
||||
type HeaderButtonProps = (
|
||||
| {
|
||||
onClick?: () => void;
|
||||
onFocus?: FocusEventHandler<HTMLButtonElement>;
|
||||
onPointerEnter?: PointerEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
| {
|
||||
href: string;
|
||||
}
|
||||
) & {
|
||||
children: ReactNode;
|
||||
onFocus?: () => void;
|
||||
onPointerDown?: () => void;
|
||||
onPointerEnter?: () => void;
|
||||
} & Partial<ActionIconProps>;
|
||||
|
||||
const headerButtonActionIconProps: ActionIconProps = {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Center, Loader, Paper } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { hasPendingSpotlightOpen, openSpotlight, spotlightOpenEvent } from "@homarr/spotlight/open";
|
||||
|
||||
const loadSpotlight = () => import("@homarr/spotlight/component");
|
||||
const SpotlightLoading = () => (
|
||||
<Center
|
||||
data-homarr-dev-benchmark-spotlight-feedback
|
||||
pos="fixed"
|
||||
inset={0}
|
||||
role="status"
|
||||
aria-label="Loading search"
|
||||
style={{ zIndex: 1000, background: "rgb(0 0 0 / 20%)", backdropFilter: "blur(2px)" }}
|
||||
>
|
||||
<Paper withBorder radius="md" p="xl" shadow="xl">
|
||||
<Loader />
|
||||
</Paper>
|
||||
</Center>
|
||||
);
|
||||
const Spotlight = dynamic(() => loadSpotlight().then(({ Spotlight: Component }) => Component), {
|
||||
ssr: false,
|
||||
loading: SpotlightLoading,
|
||||
});
|
||||
|
||||
export const preloadSpotlight = () => void loadSpotlight().catch(() => undefined);
|
||||
|
||||
export const LazySpotlight = () => {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isPreloaded, setIsPreloaded] = useState(false);
|
||||
|
||||
useHotkeys(
|
||||
isMounted
|
||||
? []
|
||||
: [
|
||||
[
|
||||
hotkeys.openSpotlight,
|
||||
() => {
|
||||
setIsMounted(true);
|
||||
openSpotlight();
|
||||
},
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
const mount = () => setIsMounted(true);
|
||||
window.addEventListener(spotlightOpenEvent, mount);
|
||||
if (hasPendingSpotlightOpen()) mount();
|
||||
|
||||
const preload = () => {
|
||||
void loadSpotlight().then(
|
||||
() => {
|
||||
if (isActive) setIsPreloaded(true);
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
};
|
||||
let idleId: number | undefined;
|
||||
const delayId = window.setTimeout(() => {
|
||||
idleId = window.requestIdleCallback?.(preload, { timeout: 1_000 });
|
||||
if (idleId === undefined) preload();
|
||||
}, 1_000);
|
||||
return () => {
|
||||
isActive = false;
|
||||
window.removeEventListener(spotlightOpenEvent, mount);
|
||||
window.clearTimeout(delayId);
|
||||
if (idleId !== undefined) window.cancelIdleCallback?.(idleId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isMounted || isPreloaded ? (
|
||||
<>
|
||||
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
|
||||
{isMounted && (
|
||||
<>
|
||||
<span hidden data-homarr-dev-benchmark-spotlight-mounted />
|
||||
<Spotlight />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : null;
|
||||
};
|
||||
@@ -3,23 +3,28 @@
|
||||
import { TextInput, UnstyledButton } from "@mantine/core";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import { openSpotlight } from "@homarr/spotlight";
|
||||
import { openSpotlight } from "@homarr/spotlight/open";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { HeaderButton } from "./button";
|
||||
import classes from "./search.module.css";
|
||||
import { preloadSpotlight } from "./lazy-spotlight";
|
||||
|
||||
export const DesktopSearchInput = () => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
data-homarr-dev-benchmark-interaction="search"
|
||||
component={UnstyledButton}
|
||||
className={classes.desktopSearch}
|
||||
w={400}
|
||||
size="sm"
|
||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||
onClick={openSpotlight}
|
||||
onFocus={preloadSpotlight}
|
||||
onPointerDown={preloadSpotlight}
|
||||
onPointerEnter={preloadSpotlight}
|
||||
radius="xl"
|
||||
>
|
||||
{`${t("search.placeholder")}...`}
|
||||
@@ -29,7 +34,14 @@ export const DesktopSearchInput = () => {
|
||||
|
||||
export const MobileSearchButton = () => {
|
||||
return (
|
||||
<HeaderButton onClick={openSpotlight} className={classes.mobileSearch}>
|
||||
<HeaderButton
|
||||
data-homarr-dev-benchmark-interaction="search"
|
||||
onClick={openSpotlight}
|
||||
onFocus={preloadSpotlight}
|
||||
onPointerDown={preloadSpotlight}
|
||||
onPointerEnter={preloadSpotlight}
|
||||
className={classes.mobileSearch}
|
||||
>
|
||||
<IconSearch size={20} stroke={1.5} />
|
||||
</HeaderButton>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { TourTarget, TourTargetsProvider } from "./tour-target";
|
||||
|
||||
vi.mock("@gfazioli/mantine-onboarding-tour", () => ({
|
||||
OnboardingTour: {
|
||||
Target: ({ children }: { children: ReactNode }) => children,
|
||||
},
|
||||
}));
|
||||
|
||||
const mountedRoots: Array<ReturnType<typeof createRoot>> = [];
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
for (const root of mountedRoots.splice(0)) root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
const renderTargetAsync = async (enabled: boolean) => {
|
||||
const host = document.createElement("div");
|
||||
const root = createRoot(host);
|
||||
mountedRoots.push(root);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(
|
||||
MantineProvider,
|
||||
null,
|
||||
createElement(TourTargetsProvider, {
|
||||
enabled,
|
||||
children: createElement(TourTarget, { id: "example", children: createElement("button", null, "Target") }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
return host;
|
||||
};
|
||||
|
||||
describe("TourTarget", () => {
|
||||
it("keeps a stable route-transition marker while a tour is active", async () => {
|
||||
const host = await renderTargetAsync(true);
|
||||
expect(host.querySelector('[data-tour-target="example"] button')?.textContent).toBe("Target");
|
||||
});
|
||||
|
||||
it("adds no wrapper for users without an active tour", async () => {
|
||||
const host = await renderTargetAsync(false);
|
||||
expect(host.querySelector("[data-tour-target]")).toBeNull();
|
||||
expect(host.querySelector("button")?.parentElement).toBe(host);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import { createContext, Suspense, use, useContext } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => (
|
||||
<OnboardingTour.Target id={id}>
|
||||
<Box data-tour-target={id}>{children}</Box>
|
||||
</OnboardingTour.Target>
|
||||
const TourTargetsEnabledContext = createContext(false);
|
||||
let tourLibraryPromise: Promise<typeof import("@gfazioli/mantine-onboarding-tour")> | undefined;
|
||||
|
||||
const loadTourLibrary = () => {
|
||||
if (tourLibraryPromise) return tourLibraryPromise;
|
||||
|
||||
const currentPromise = import("@gfazioli/mantine-onboarding-tour");
|
||||
tourLibraryPromise = currentPromise;
|
||||
void currentPromise.catch(() => {
|
||||
if (tourLibraryPromise === currentPromise) tourLibraryPromise = undefined;
|
||||
});
|
||||
return currentPromise;
|
||||
};
|
||||
|
||||
export const TourTargetsProvider = ({ enabled = true, children }: PropsWithChildren<{ enabled?: boolean }>) => (
|
||||
<TourTargetsEnabledContext value={enabled}>{children}</TourTargetsEnabledContext>
|
||||
);
|
||||
|
||||
const LoadedTourTarget = ({ id, children }: { id: string; children: ReactNode }) => {
|
||||
const { OnboardingTour } = use(loadTourLibrary());
|
||||
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
|
||||
};
|
||||
|
||||
export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => {
|
||||
const isEnabled = useContext(TourTargetsEnabledContext);
|
||||
if (!isEnabled) return children;
|
||||
|
||||
const target = <Box data-tour-target={id}>{children}</Box>;
|
||||
|
||||
return (
|
||||
<Suspense fallback={target}>
|
||||
<LoadedTourTarget id={id}>{target}</LoadedTourTarget>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Suspense, use } from "react";
|
||||
import { Indicator, Menu, Text } from "@mantine/core";
|
||||
import { IconBellRinging } from "@tabler/icons-react";
|
||||
|
||||
@@ -9,67 +8,28 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface UpdateIndicatorProps extends PropsWithChildren {
|
||||
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]> | undefined;
|
||||
availableUpdates: RouterOutputs["updateChecker"]["getAvailableUpdates"] | undefined;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export const UpdateIndicator = ({ children, availableUpdatesPromise, disabled }: UpdateIndicatorProps) => {
|
||||
if (disabled || availableUpdatesPromise === undefined) {
|
||||
export const UpdateIndicator = ({ children, availableUpdates, disabled }: UpdateIndicatorProps) => {
|
||||
if (disabled || availableUpdates === undefined) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={children}>
|
||||
<InnerUpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={disabled}>
|
||||
{children}
|
||||
</InnerUpdateIndicator>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
interface InnerUpdateIndicatorProps extends PropsWithChildren {
|
||||
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const InnerUpdateIndicator = ({ children, disabled, availableUpdatesPromise }: InnerUpdateIndicatorProps) => {
|
||||
const availableUpdates = use(availableUpdatesPromise);
|
||||
|
||||
return (
|
||||
<Indicator
|
||||
disabled={!availableUpdates || availableUpdates.length === 0 || disabled}
|
||||
size={15}
|
||||
processing
|
||||
withBorder
|
||||
>
|
||||
<Indicator disabled={availableUpdates.length === 0} size={15} processing withBorder>
|
||||
{children}
|
||||
</Indicator>
|
||||
);
|
||||
};
|
||||
|
||||
interface AvailableUpdatesMenuItemProps {
|
||||
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]> | undefined;
|
||||
availableUpdates: RouterOutputs["updateChecker"]["getAvailableUpdates"] | undefined;
|
||||
}
|
||||
|
||||
export const AvailableUpdatesMenuItem = ({ availableUpdatesPromise }: AvailableUpdatesMenuItemProps) => {
|
||||
if (availableUpdatesPromise === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<InnerAvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
interface InnerAvailableUpdatesMenuItemProps {
|
||||
availableUpdatesPromise: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
|
||||
}
|
||||
|
||||
const InnerAvailableUpdatesMenuItem = ({ availableUpdatesPromise }: InnerAvailableUpdatesMenuItemProps) => {
|
||||
export const AvailableUpdatesMenuItem = ({ availableUpdates }: AvailableUpdatesMenuItemProps) => {
|
||||
const t = useScopedI18n("common.userAvatar.menu");
|
||||
const availableUpdates = use(availableUpdatesPromise);
|
||||
if (availableUpdates === undefined || availableUpdates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { Suspense, use, useEffect, useState } from "react";
|
||||
import { Box, Loader, UnstyledButton, useMantineColorScheme } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
|
||||
import { UpdateIndicator } from "./update";
|
||||
|
||||
type UserAvatarMenuModule = typeof import("~/components/user-avatar-menu");
|
||||
type UserAvatarMenuProps = ComponentProps<UserAvatarMenuModule["UserAvatarMenu"]>;
|
||||
|
||||
let userAvatarMenuPromise: Promise<UserAvatarMenuModule> | undefined;
|
||||
const loadUserAvatarMenu = () => {
|
||||
if (userAvatarMenuPromise) return userAvatarMenuPromise;
|
||||
|
||||
const promise = import("~/components/user-avatar-menu");
|
||||
userAvatarMenuPromise = promise;
|
||||
void promise.catch(() => {
|
||||
if (userAvatarMenuPromise === promise) userAvatarMenuPromise = undefined;
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
const preloadUserAvatarMenu = () => void loadUserAvatarMenu().catch(() => undefined);
|
||||
|
||||
const LazyUserAvatarMenu = (props: UserAvatarMenuProps) => {
|
||||
const { UserAvatarMenu } = use(loadUserAvatarMenu());
|
||||
return <UserAvatarMenu {...props} />;
|
||||
};
|
||||
|
||||
interface UserButtonClientProps {
|
||||
avatar: ReactNode;
|
||||
isAdmin: boolean;
|
||||
isDockerEnabled: boolean;
|
||||
}
|
||||
|
||||
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
|
||||
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
|
||||
const [isMenuMounted, setIsMenuMounted] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const session = useSession();
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
||||
const isCurrentSessionAdmin =
|
||||
isAdmin && session.status === "authenticated" && session.data.user.permissions.includes("admin");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCurrentSessionAdmin) {
|
||||
setCanCheckForUpdates(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(() => setCanCheckForUpdates(true), 2_000);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [isCurrentSessionAdmin]);
|
||||
|
||||
const { data: availableUpdates } = clientApi.updateChecker.getAvailableUpdates.useQuery(undefined, {
|
||||
enabled: canCheckForUpdates,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
staleTime: 60 * 60 * 1_000,
|
||||
});
|
||||
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
|
||||
const openMenu = () => {
|
||||
setIsMenuMounted(true);
|
||||
setIsMenuOpen(true);
|
||||
};
|
||||
const renderButton = (onClick: () => void, isLoading = false) => (
|
||||
<UnstyledButton
|
||||
onClick={onClick}
|
||||
onFocus={preloadUserAvatarMenu}
|
||||
onMouseEnter={preloadUserAvatarMenu}
|
||||
onPointerDown={preloadUserAvatarMenu}
|
||||
aria-busy={isLoading || undefined}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
|
||||
<Box pos="relative" style={{ display: "inline-flex" }}>
|
||||
<Box opacity={isLoading ? 0.35 : 1}>{avatar}</Box>
|
||||
{isLoading && (
|
||||
<Loader
|
||||
aria-label="Loading user menu"
|
||||
size={18}
|
||||
pos="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
style={{ transform: "translate(-50%, -50%)" }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</UpdateIndicator>
|
||||
</UnstyledButton>
|
||||
);
|
||||
const button = renderButton(openMenu);
|
||||
const loadingButton = renderButton(() => undefined, true);
|
||||
|
||||
if (!isMenuMounted) return button;
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallbackRender={({ resetErrorBoundary }) =>
|
||||
renderButton(() => {
|
||||
resetErrorBoundary();
|
||||
openMenu();
|
||||
})
|
||||
}
|
||||
>
|
||||
<Suspense fallback={loadingButton}>
|
||||
<LazyUserAvatarMenu
|
||||
availableUpdates={visibleUpdates}
|
||||
isDockerEnabled={isDockerEnabled}
|
||||
opened={isMenuOpen}
|
||||
onOpenChange={setIsMenuOpen}
|
||||
>
|
||||
{button}
|
||||
</LazyUserAvatarMenu>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
@@ -1,28 +1,14 @@
|
||||
import { Suspense } from "react";
|
||||
import { UnstyledButton } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/docker/env";
|
||||
|
||||
import { CurrentUserAvatar } from "~/components/user-avatar";
|
||||
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
||||
import { UpdateIndicator } from "./update";
|
||||
import { UserButtonClient } from "./user-client";
|
||||
|
||||
export const UserButton = async () => {
|
||||
const session = await auth();
|
||||
const isAdmin = session?.user.permissions.includes("admin");
|
||||
const isAdmin = Boolean(session?.user.permissions.includes("admin"));
|
||||
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
|
||||
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
|
||||
return (
|
||||
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise} isDockerEnabled={isDockerEnabled}>
|
||||
<UnstyledButton>
|
||||
<Suspense fallback={<CurrentUserAvatar size="md" />}>
|
||||
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
|
||||
<CurrentUserAvatar size="md" />
|
||||
</UpdateIndicator>
|
||||
</Suspense>
|
||||
</UnstyledButton>
|
||||
</UserAvatarMenu>
|
||||
<UserButtonClient avatar={<CurrentUserAvatar size="md" />} isAdmin={isAdmin} isDockerEnabled={isDockerEnabled} />
|
||||
);
|
||||
};
|
||||
@@ -1,20 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { NavLink } from "@mantine/core";
|
||||
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { TourTarget } from "./header/tour-target";
|
||||
|
||||
export const CommonNavLink = (props: ClientNavigationLink) =>
|
||||
"href" in props ? <NavLinkHref {...props} /> : <NavLinkWithItems {...props} />;
|
||||
|
||||
const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => {
|
||||
if (!id) return <>{children}</>;
|
||||
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
|
||||
};
|
||||
const withOptionalTourTarget = (id: string | undefined, children: ReactElement) =>
|
||||
id ? <TourTarget id={id}>{children}</TourTarget> : children;
|
||||
|
||||
const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
@@ -43,7 +42,7 @@ const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
active={isClient && pathMatches(pathname, props.href)}
|
||||
/>
|
||||
);
|
||||
return <TourTarget id={tourId}>{link}</TourTarget>;
|
||||
return withOptionalTourTarget(tourId, link);
|
||||
};
|
||||
|
||||
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
||||
@@ -56,7 +55,7 @@ const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
||||
))}
|
||||
</NavLink>
|
||||
);
|
||||
return <TourTarget id={props["data-onboarding-tour-id"]}>{nav}</TourTarget>;
|
||||
return withOptionalTourTarget(props["data-onboarding-tour-id"], nav);
|
||||
};
|
||||
|
||||
interface CommonNavigationLinkProps {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { boardViewportWidthCookieName } from "@homarr/boards/layout-selection";
|
||||
|
||||
export const ViewportHint = () => {
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const persist = () => {
|
||||
const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
|
||||
document.cookie = `${boardViewportWidthCookieName}=${Math.round(viewportWidth)}; Path=/; Max-Age=31536000; SameSite=Lax`;
|
||||
};
|
||||
const handleResize = () => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(persist, 150);
|
||||
};
|
||||
|
||||
persist();
|
||||
window.addEventListener("resize", handleResize, { passive: true });
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
Reference in new issue
Block a user