Merge remote-tracking branch 'origin/dev' into feat/static-board-ssr
This commit is contained in:
295 files changed
+4142
-6042
No files matched your search
@@ -49,7 +49,6 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
withBorder
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
|
||||
@@ -106,9 +106,17 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
|
||||
<Menu withinPortal position="right-start" arrowPosition="center">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={offset} right={offset} style={{ zIndex: 10 }}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius={"xl"}
|
||||
pos="absolute"
|
||||
top={offset}
|
||||
right={offset}
|
||||
style={{ zIndex: 10 }}
|
||||
aria-label={tItem("menu.label.settings")}
|
||||
>
|
||||
<IconDotsVertical size={"1rem"} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
@@ -145,7 +145,7 @@ const WidgetItem = ({
|
||||
</Text>
|
||||
</Stack>
|
||||
<SupportedIntegrations mt="auto" integrations={item.supportedIntegrations} />
|
||||
<Button onClick={onSelect} variant="light" size="xs" radius="md" fullWidth>
|
||||
<Button onClick={onSelect} variant="light" size="xs" fullWidth>
|
||||
{t(`item.create.addToBoard`)}
|
||||
</Button>
|
||||
</Stack>
|
||||
@@ -170,7 +170,7 @@ const SupportedIntegrations = ({ integrations, mt }: SupportedIntegrationsProps)
|
||||
|
||||
return (
|
||||
<Center mt={mt}>
|
||||
<Tooltip.Group openDelay={300} closeDelay={100}>
|
||||
<Tooltip.Group closeDelay={100}>
|
||||
<Group gap={2}>
|
||||
{integrations.slice(0, countToShow).map((integration) => (
|
||||
<Tooltip key={integration} label={getIntegrationName(integration)} withArrow>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
@@ -10,30 +11,41 @@ import { CategoryMenu } from "./category/category-menu";
|
||||
import { GridStack } from "./gridstack/gridstack";
|
||||
import classes from "./item.module.css";
|
||||
|
||||
const localStorageKey = (sectionId: string) => `homarr-section-collapsed-${sectionId}`;
|
||||
|
||||
interface Props {
|
||||
section: CategorySection;
|
||||
}
|
||||
|
||||
export const BoardCategorySection = ({ section }: Props) => {
|
||||
const { status } = useSession();
|
||||
const { mutate } = clientApi.section.changeCollapsed.useMutation();
|
||||
const board = useRequiredBoard();
|
||||
const [opened, { toggle }] = useDisclosure(section.collapsed, {
|
||||
|
||||
const initialCollapsed =
|
||||
typeof window !== "undefined"
|
||||
? (localStorage.getItem(localStorageKey(section.id)) ?? String(section.collapsed)) === "true"
|
||||
: section.collapsed;
|
||||
|
||||
const [opened, { toggle }] = useDisclosure(initialCollapsed, {
|
||||
onOpen() {
|
||||
mutate({ sectionId: section.id, collapsed: true });
|
||||
if (status === "authenticated") {
|
||||
mutate({ sectionId: section.id, collapsed: true });
|
||||
} else if (status === "unauthenticated") {
|
||||
localStorage.setItem(localStorageKey(section.id), "true");
|
||||
}
|
||||
},
|
||||
onClose() {
|
||||
mutate({ sectionId: section.id, collapsed: false });
|
||||
if (status === "authenticated") {
|
||||
mutate({ sectionId: section.id, collapsed: false });
|
||||
} else if (status === "unauthenticated") {
|
||||
localStorage.setItem(localStorageKey(section.id), "false");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{ "--opacity": board.opacity / 100 }}
|
||||
radius={board.itemRadius}
|
||||
withBorder
|
||||
p={0}
|
||||
className={classes.itemCard}
|
||||
>
|
||||
<Card style={{ "--opacity": board.opacity / 100 }} radius={board.itemRadius} p={0} className={classes.itemCard}>
|
||||
<Stack>
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<UnstyledButton w="100%" p="sm" onClick={toggle}>
|
||||
@@ -44,7 +56,7 @@ export const BoardCategorySection = ({ section }: Props) => {
|
||||
</UnstyledButton>
|
||||
<CategoryMenu category={section} />
|
||||
</Group>
|
||||
<Collapse in={opened} p="sm" pt={0}>
|
||||
<Collapse expanded={opened} p="sm" pt={0}>
|
||||
<GridStack section={section} />
|
||||
</Collapse>
|
||||
</Stack>
|
||||
|
||||
@@ -30,9 +30,9 @@ export const CategoryMenu = ({ category }: Props) => {
|
||||
const t = useScopedI18n("section.category");
|
||||
|
||||
return (
|
||||
<Menu withArrow>
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<ActionIcon mr="sm" variant="transparent">
|
||||
<ActionIcon mr="sm" variant="transparent" aria-label={t("menu.label.settings")}>
|
||||
<IconDotsVertical size={20} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
@@ -28,7 +28,6 @@ export const BoardDynamicSection = ({ section }: Props) => {
|
||||
className={combineClasses(classes.itemCard, section.options.customCssClasses.join(" "))}
|
||||
w="100%"
|
||||
h="100%"
|
||||
withBorder
|
||||
styles={{
|
||||
root: {
|
||||
overflow: "visible",
|
||||
|
||||
@@ -43,9 +43,17 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
|
||||
<Menu withinPortal position="right-start" arrowPosition="center">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={4} right={4} style={{ zIndex: 10 }}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius={"xl"}
|
||||
pos="absolute"
|
||||
top={4}
|
||||
right={4}
|
||||
style={{ zIndex: 10 }}
|
||||
aria-label={tItem("menu.label.settings")}
|
||||
>
|
||||
<IconDotsVertical size={"1rem"} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RefObject } from "react";
|
||||
import type { Ref, RefObject } from "react";
|
||||
import { createRef, useCallback, useEffect, useRef } from "react";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { useElementSize, useMergedRef } from "@mantine/hooks";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
@@ -13,7 +13,7 @@ import { useSectionActions } from "../section-actions";
|
||||
import { initializeGridstack } from "./init-gridstack";
|
||||
|
||||
export interface UseGridstackRefs {
|
||||
wrapper: RefObject<HTMLDivElement | null>;
|
||||
wrapper: Ref<HTMLDivElement | null>;
|
||||
items: RefObject<Record<string, RefObject<GridItemHTMLElement | null>>>;
|
||||
gridstack: RefObject<GridStack | null>;
|
||||
}
|
||||
@@ -59,8 +59,9 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
|
||||
const { moveAndResizeItem, moveItemToSection } = useItemActions();
|
||||
const { moveAndResizeInnerSection, moveInnerSectionToSection } = useSectionActions();
|
||||
|
||||
// define reference for wrapper - is used to calculate the width of the wrapper
|
||||
const { ref: wrapperRef, width, height } = useElementSize<HTMLDivElement>();
|
||||
const wrapperElementRef = useRef<HTMLDivElement | null>(null);
|
||||
const { ref: measureWrapperRef, width, height } = useElementSize<HTMLDivElement>();
|
||||
const wrapperRef = useMergedRef(measureWrapperRef, wrapperElementRef);
|
||||
// references to the diffrent items contained in the gridstack
|
||||
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement | null>>>({});
|
||||
// reference of the gridstack object for modifications after initialization
|
||||
@@ -193,7 +194,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
|
||||
itemIds,
|
||||
refs: {
|
||||
items: itemRefs,
|
||||
wrapper: wrapperRef,
|
||||
wrapper: wrapperElementRef,
|
||||
gridstack: gridRef,
|
||||
},
|
||||
sectionColumnCount: columnCount,
|
||||
@@ -276,7 +277,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
|
||||
useCssVariableConfiguration({
|
||||
columnCount,
|
||||
gridRef,
|
||||
wrapperRef,
|
||||
wrapperRef: wrapperElementRef,
|
||||
width,
|
||||
height,
|
||||
isDynamic: section.kind === "dynamic",
|
||||
@@ -293,7 +294,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
|
||||
|
||||
interface UseCssVariableConfiguration {
|
||||
gridRef: UseGridstackRefs["gridstack"];
|
||||
wrapperRef: UseGridstackRefs["wrapper"];
|
||||
wrapperRef: RefObject<HTMLDivElement | null>;
|
||||
width: number;
|
||||
height: number;
|
||||
columnCount: number;
|
||||
|
||||
@@ -6,11 +6,20 @@ import { LanguageCombobox } from "./language-combobox";
|
||||
|
||||
interface CurrentLanguageComboboxProps {
|
||||
width?: string;
|
||||
withinPortal?: boolean;
|
||||
}
|
||||
|
||||
export const CurrentLanguageCombobox = ({ width }: CurrentLanguageComboboxProps) => {
|
||||
export const CurrentLanguageCombobox = ({ width, withinPortal }: CurrentLanguageComboboxProps) => {
|
||||
const currentLocale = useCurrentLocale();
|
||||
const { changeLocale, isPending } = useChangeLocale();
|
||||
|
||||
return <LanguageCombobox value={currentLocale} onChange={changeLocale} isPending={isPending} width={width} />;
|
||||
return (
|
||||
<LanguageCombobox
|
||||
value={currentLocale}
|
||||
onChange={changeLocale}
|
||||
isPending={isPending}
|
||||
width={width}
|
||||
withinPortal={withinPortal}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -16,9 +16,17 @@ interface LanguageComboboxProps {
|
||||
onChange: (value: SupportedLanguage) => void;
|
||||
isPending?: boolean;
|
||||
width?: string;
|
||||
withinPortal?: boolean;
|
||||
}
|
||||
|
||||
export const LanguageCombobox = ({ label, value, onChange, isPending, width }: LanguageComboboxProps) => {
|
||||
export const LanguageCombobox = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
isPending,
|
||||
width,
|
||||
withinPortal = false,
|
||||
}: LanguageComboboxProps) => {
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
});
|
||||
@@ -39,7 +47,7 @@ export const LanguageCombobox = ({ label, value, onChange, isPending, width }: L
|
||||
}, [combobox]);
|
||||
|
||||
return (
|
||||
<Combobox store={combobox} onOptionSubmit={handleOnOptionSubmit}>
|
||||
<Combobox store={combobox} withinPortal={withinPortal} onOptionSubmit={handleOnOptionSubmit}>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
component="button"
|
||||
|
||||
@@ -44,6 +44,7 @@ export const BoardBackgroundVideo = () => {
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "fixed",
|
||||
width: "100vw",
|
||||
|
||||
@@ -12,16 +12,7 @@ export const CommonNavLink = (props: ClientNavigationLink) =>
|
||||
const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
const pathname = usePathname();
|
||||
return props.external ? (
|
||||
<NavLink
|
||||
component="a"
|
||||
label={props.label}
|
||||
leftSection={props.icon}
|
||||
href={props.href}
|
||||
style={{
|
||||
borderRadius: 5,
|
||||
}}
|
||||
target="_blank"
|
||||
/>
|
||||
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
|
||||
) : (
|
||||
<NavLink
|
||||
component={Link}
|
||||
@@ -29,9 +20,6 @@ const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
leftSection={props.icon}
|
||||
href={props.href}
|
||||
active={pathname === props.href}
|
||||
style={{
|
||||
borderRadius: 5,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -40,14 +28,7 @@ const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
||||
const pathname = usePathname();
|
||||
const isActive = props.items.some((item) => item.href === pathname);
|
||||
return (
|
||||
<NavLink
|
||||
style={{
|
||||
borderRadius: 5,
|
||||
}}
|
||||
label={props.label}
|
||||
leftSection={props.icon}
|
||||
defaultOpened={isActive}
|
||||
>
|
||||
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
|
||||
{props.items.map((item) => (
|
||||
<NavLinkHref key={item.label} {...item} />
|
||||
))}
|
||||
|
||||
@@ -15,7 +15,7 @@ export const DangerZoneRoot = async ({ children }: DangerZoneRootProps) => {
|
||||
<Title c="red.8" order={2}>
|
||||
{t("common.dangerZone")}
|
||||
</Title>
|
||||
<Card withBorder style={{ borderColor: "var(--mantine-color-red-8)", borderWidth: 3 }}>
|
||||
<Card style={{ borderColor: "var(--mantine-color-red-8)", borderWidth: 3 }}>
|
||||
<Stack gap="sm">
|
||||
{Array.isArray(children)
|
||||
? children.map((child, index) => (
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Affix, Box } from "@mantine/core";
|
||||
|
||||
import { MANAGE_ACTION_BUTTON_MIN_WIDTH, MANAGE_AFFIX_POSITION } from "./manage-page.constants";
|
||||
|
||||
export const ManageMobilePrimaryAction = ({ children }: PropsWithChildren) => (
|
||||
<>
|
||||
<Box visibleFrom="md" miw={MANAGE_ACTION_BUTTON_MIN_WIDTH}>
|
||||
{children}
|
||||
</Box>
|
||||
<Affix hiddenFrom="md" position={MANAGE_AFFIX_POSITION}>
|
||||
{children}
|
||||
</Affix>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { MantineSize } from "@mantine/core";
|
||||
import { Group, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { ManageContainer } from "./manage-container";
|
||||
import { MANAGE_FLOATING_ACTION_BOTTOM_OFFSET } from "./manage-page.constants";
|
||||
|
||||
interface ManagePageLayoutProps {
|
||||
title: ReactNode;
|
||||
primaryAction?: ReactNode;
|
||||
toolbar?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
floatingPrimaryAction?: boolean;
|
||||
size?: MantineSize;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ManagePageLayout = ({
|
||||
title,
|
||||
primaryAction,
|
||||
toolbar,
|
||||
footer,
|
||||
floatingPrimaryAction,
|
||||
size,
|
||||
children,
|
||||
}: ManagePageLayoutProps) => {
|
||||
const titleNode = typeof title === "string" ? <Title>{title}</Title> : title;
|
||||
|
||||
return (
|
||||
<ManageContainer size={size}>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack pb={floatingPrimaryAction ? { base: MANAGE_FLOATING_ACTION_BOTTOM_OFFSET, md: 0 } : undefined}>
|
||||
<Group justify="space-between" align="center">
|
||||
{titleNode}
|
||||
{primaryAction}
|
||||
</Group>
|
||||
{toolbar}
|
||||
{children}
|
||||
{footer && <Group justify="end">{footer}</Group>}
|
||||
</Stack>
|
||||
</ManageContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export const MANAGE_AFFIX_POSITION = { bottom: 20, right: 20 } as const;
|
||||
|
||||
export const MANAGE_FLOATING_ACTION_BOTTOM_OFFSET = 48;
|
||||
|
||||
export const MANAGE_ACTION_BUTTON_MIN_WIDTH = 160;
|
||||
@@ -2,13 +2,15 @@ import { forwardRef } from "react";
|
||||
import type { ButtonProps } from "@mantine/core";
|
||||
import { Affix, Button, createPolymorphicComponent } from "@mantine/core";
|
||||
|
||||
import { MANAGE_ACTION_BUTTON_MIN_WIDTH, MANAGE_AFFIX_POSITION } from "./manage-page.constants";
|
||||
|
||||
type MobileAffixButtonProps = Omit<ButtonProps, "visibleFrom" | "hiddenFrom">;
|
||||
|
||||
export const MobileAffixButton = createPolymorphicComponent<"button", MobileAffixButtonProps>(
|
||||
forwardRef<HTMLButtonElement, MobileAffixButtonProps>((props, ref) => (
|
||||
<>
|
||||
<Button ref={ref} visibleFrom="md" {...props} />
|
||||
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
||||
<Button ref={ref} visibleFrom="md" miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} {...props} />
|
||||
<Affix hiddenFrom="md" position={MANAGE_AFFIX_POSITION}>
|
||||
<Button ref={ref} {...props} />
|
||||
</Affix>
|
||||
</>
|
||||
|
||||
@@ -14,13 +14,13 @@ interface NoResultsProps {
|
||||
|
||||
export const NoResults = ({ icon: Icon, title, action }: NoResultsProps) => {
|
||||
return (
|
||||
<Card withBorder bg="transparent">
|
||||
<Card bg="transparent" withBorder={false}>
|
||||
<Stack align="center" gap="sm">
|
||||
<Icon size="2rem" />
|
||||
<Text fw={500} size="lg">
|
||||
{title}
|
||||
</Text>
|
||||
{!action?.hidden && <Anchor href={action?.href}>{action?.label}</Anchor>}
|
||||
{action && !action.hidden && <Anchor href={action.href}>{action.label}</Anchor>}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -52,7 +52,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
|
||||
|
||||
return (
|
||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||
<Menu width={300} withArrow withinPortal keepMounted>
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
|
||||
Reference in new issue
Block a user