Files
homarr/apps/nextjs/src/components/board/board-switcher.tsx
T

363 lines
12 KiB
TypeScript

"use client";
import type { KeyboardEvent, ReactNode, RefObject } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Badge,
Card,
Center,
Group,
Loader,
Modal,
ScrollArea,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Tooltip,
UnstyledButton,
useMatches,
VisuallyHidden,
} from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import { IconDeviceMobile, IconHomeFilled, IconLayoutBoard, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useOptionalBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import { Link, UserAvatar } from "@homarr/ui";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
import classes from "./board-switcher.module.css";
export const boardSwitcherHotkey = "shift+c";
interface BoardSwitcherProps {
children: (controls: { open: () => void; preload: () => void; hotkey: string }) => ReactNode;
}
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
const t = useI18n("board.action.switcher");
const manageBoardsT = useI18n("management.page.board");
const currentBoard = useOptionalBoard();
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const responsiveColumnCount = useMatches({ base: 1, sm: 2, lg: 3, xl: 4 });
const optionRefs = useRef<Array<HTMLAnchorElement | null>>([]);
const utils = clientApi.useUtils();
const {
data: boards = [],
isPending,
isError,
} = clientApi.board.getManageOverview.useQuery(
{ fullPreview: true },
{
enabled: isOpen,
},
);
const switcherBoards = useMemo(
() => [
...boards.filter((board) => board.id !== currentBoard?.id),
...boards.filter((board) => board.id === currentBoard?.id),
],
[boards, currentBoard?.id],
);
const filteredBoards = useMemo(() => {
const normalizedSearch = search.trim().toLocaleLowerCase();
if (normalizedSearch.length === 0) return switcherBoards;
return switcherBoards.filter((board) => board.name.toLocaleLowerCase().includes(normalizedSearch));
}, [search, switcherBoards]);
const columnCount = Math.max(1, Math.min(filteredBoards.length, responsiveColumnCount));
const modalColumnCount = Math.max(1, Math.min(switcherBoards.length, responsiveColumnCount));
const openSwitcher = useCallback(() => {
setIsOpen(true);
}, []);
const closeSwitcher = useCallback(() => {
setIsOpen(false);
setSearch("");
setActiveIndex(0);
}, []);
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]);
useEffect(() => {
if (!isOpen || filteredBoards.length === 0) return;
const normalizedIndex = Math.min(activeIndex, filteredBoards.length - 1);
if (normalizedIndex !== activeIndex) {
setActiveIndex(normalizedIndex);
return;
}
optionRefs.current[normalizedIndex]?.scrollIntoView({ block: "nearest", inline: "nearest" });
}, [activeIndex, filteredBoards.length, isOpen]);
const handleModalKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const isSearchInput =
event.target instanceof HTMLInputElement && event.target.dataset.boardSwitcherSearch !== undefined;
if (!isSearchInput && event.key === "Backspace") {
event.preventDefault();
setSearch((current) => current.slice(0, -1));
setActiveIndex(0);
return;
}
if (!isSearchInput && event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
event.preventDefault();
setSearch((current) => `${current}${event.key}`);
setActiveIndex(0);
return;
}
if (filteredBoards.length === 0) return;
if (event.key === "Enter") {
const target = event.target;
if (target instanceof Element && target.closest("[data-board-switcher-option]")) return;
event.preventDefault();
optionRefs.current[activeIndex]?.click();
return;
}
let nextIndex = activeIndex;
if (event.key === "ArrowRight") {
nextIndex = (activeIndex + 1) % filteredBoards.length;
} else if (event.key === "ArrowLeft") {
nextIndex = (activeIndex - 1 + filteredBoards.length) % filteredBoards.length;
} else if (event.key === "ArrowDown") {
const nextRowIndex = activeIndex + columnCount;
nextIndex = nextRowIndex;
if (nextRowIndex >= filteredBoards.length) nextIndex = activeIndex % columnCount;
} else if (event.key === "ArrowUp") {
const previousRowIndex = activeIndex - columnCount;
if (previousRowIndex >= 0) {
nextIndex = previousRowIndex;
} else {
const lastRowStart = Math.floor((filteredBoards.length - 1) / columnCount) * columnCount;
const lastIndexInColumn = lastRowStart + activeIndex;
nextIndex = lastIndexInColumn;
if (lastIndexInColumn >= filteredBoards.length) nextIndex -= columnCount;
}
} else {
return;
}
event.preventDefault();
setActiveIndex(nextIndex);
requestAnimationFrame(() => optionRefs.current[nextIndex]?.focus({ preventScroll: true }));
};
const results = getBoardSwitcherResults({
availableBoardCount: switcherBoards.length,
filteredBoards,
isError,
isPending,
search,
t,
activeIndex,
closeSwitcher,
setActiveIndex,
optionRefs,
columnCount,
manageBoardsT,
});
return (
<>
{children({ open: openSwitcher, preload: preloadBoards, hotkey: boardSwitcherHotkey })}
<Modal
opened={isOpen}
onClose={closeSwitcher}
withCloseButton={false}
yOffset="15vh"
aria-label={t("title")}
size={`${modalColumnCount * 15 + Math.max(0, modalColumnCount - 1) * 0.75}rem`}
overlayProps={{ backgroundOpacity: 0, blur: 2 }}
transitionProps={{ transition: "fade", duration: 100, timingFunction: "ease" }}
classNames={{ content: classes.modalContent, body: classes.modalBody }}
>
<Stack
tabIndex={-1}
gap="xs"
onKeyDown={handleModalKeyDown}
className={classes.content}
aria-describedby="board-switcher-instructions"
>
<VisuallyHidden id="board-switcher-instructions">{t("keyboard.instructions")}</VisuallyHidden>
<TextInput
data-autofocus
data-board-switcher-search
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
setActiveIndex(0);
}}
leftSection={<IconSearch size="1rem" stroke={1.6} />}
placeholder={t("title")}
aria-label={t("title")}
aria-controls="board-switcher-results"
radius="xl"
classNames={{ input: classes.searchInput }}
/>
<ScrollArea.Autosize mah="min(80vh, 42rem)" type="never">
{results}
</ScrollArea.Autosize>
</Stack>
</Modal>
</>
);
};
interface BoardSwitcherResultsProps {
availableBoardCount: number;
filteredBoards: RouterOutputs["board"]["getManageOverview"];
isError: boolean;
isPending: boolean;
search: string;
t: ReturnType<typeof useI18n<"board.action.switcher">>;
activeIndex: number;
closeSwitcher: () => void;
setActiveIndex: (index: number) => void;
optionRefs: RefObject<Array<HTMLAnchorElement | null>>;
columnCount: number;
manageBoardsT: ReturnType<typeof useI18n<"management.page.board">>;
}
const getBoardSwitcherResults = ({
availableBoardCount,
filteredBoards,
isError,
isPending,
search,
t,
activeIndex,
closeSwitcher,
setActiveIndex,
optionRefs,
columnCount,
manageBoardsT,
}: BoardSwitcherResultsProps) => {
if (isPending) {
return (
<Center py="xl">
<Loader size="sm" />
</Center>
);
}
if (isError) {
return (
<Center py="xl">
<Text c="dimmed" size="sm">
{t("error")}
</Text>
</Center>
);
}
if (availableBoardCount === 0) {
return (
<Center py="xl">
<Stack align="center" gap={4}>
<ThemeIcon variant="light" color="gray" size="xl" radius="xl">
<IconLayoutBoard stroke={1.5} />
</ThemeIcon>
<Text fw={600}>{t("empty.title")}</Text>
<Text c="dimmed" size="sm" ta="center">
{t("empty.description")}
</Text>
</Stack>
</Center>
);
}
if (filteredBoards.length === 0) {
return (
<Center py="xl">
<Text c="dimmed" size="sm">
{t("search.empty", { search })}
</Text>
</Center>
);
}
return (
<SimpleGrid id="board-switcher-results" aria-label={t("results")} cols={columnCount} spacing="xs" p={2}>
{filteredBoards.map((board, index) => (
<UnstyledButton
key={board.id}
ref={(node) => {
optionRefs.current[index] = node;
}}
className={classes.option}
w="100%"
maw={240}
mx="auto"
component={Link}
href={`/boards/${encodeURIComponent(board.name)}`}
data-active={index === activeIndex}
data-board-switcher-option
onClick={closeSwitcher}
onFocus={() => setActiveIndex(index)}
onPointerEnter={() => setActiveIndex(index)}
>
<Card padding={0} withBorder radius="lg" className={classes.card}>
<Card.Section>
<BoardLayoutThumbnail
preview={board.preview}
label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })}
fitFullLayout
/>
</Card.Section>
<Card.Section withBorder px="sm" py={6}>
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text fw={600} size="sm" truncate>
{board.name}
</Text>
<Group gap={4} wrap="nowrap">
{board.isHome && (
<Tooltip label={manageBoardsT("action.setHomeBoard.badge.tooltip")}>
<Badge
className={classes.metadataBadge}
color="yellow"
variant="light"
aria-label={manageBoardsT("action.setHomeBoard.badge.label")}
>
<IconHomeFilled size="0.7rem" />
</Badge>
</Tooltip>
)}
{board.isMobileHome && (
<Tooltip label={manageBoardsT("action.setMobileHomeBoard.badge.tooltip")}>
<Badge
className={classes.metadataBadge}
color="yellow"
variant="light"
aria-label={manageBoardsT("action.setMobileHomeBoard.badge.label")}
>
<IconDeviceMobile size="0.7rem" />
</Badge>
</Tooltip>
)}
{board.creator && (
<Tooltip label={board.creator.name ?? manageBoardsT("preview.unknownCreator")}>
<UserAvatar user={board.creator} size="xs" />
</Tooltip>
)}
</Group>
</Group>
</Card.Section>
</Card>
</UnstyledButton>
))}
</SimpleGrid>
);
};