Compare commits

...
7 changed files with 138 additions and 79 deletions

No files matched your search

+4 -3
View File
@@ -27,9 +27,10 @@ opening it. Containers include their visible widgets and app icons instead of ap
Names of boards must be unique because this will be used in the URL to load the desired board. Names of boards must be unique because this will be used in the URL to load the desired board.
Press `Shift+C`, or select **Switch board** from the user menu, to open the board switcher. It shows Press `Shift+C`, or select **Switch board** from the user menu, to open the board switcher. It shows structural
structural previews of the other boards you can access. Start typing to filter by name, use the arrow keys to move previews of every board you can access, with your current board at the end. Home-board markers and creator avatars
through the results, and press `Enter` to open the selected board. match the board management view. Use the search field at the top, or simply start typing, to filter by name. Use the
arrow keys to move through the results and press `Enter` to open the selected board.
## Create a new board ## Create a new board
@@ -1,5 +1,6 @@
.canvas { .canvas {
min-height: 8rem; width: 100%;
min-width: 0;
aspect-ratio: 16 / 7; aspect-ratio: 16 / 7;
overflow: hidden; overflow: hidden;
color: var(--mantine-color-dimmed); color: var(--mantine-color-dimmed);
@@ -83,9 +84,3 @@
gap: rem(1px); gap: rem(1px);
overflow: hidden; overflow: hidden;
} }
@media (max-width: $mantine-breakpoint-xs) {
.canvas {
min-height: 7rem;
}
}
@@ -11,11 +11,12 @@ import classes from "./board-layout-thumbnail.module.css";
interface BoardLayoutThumbnailProps { interface BoardLayoutThumbnailProps {
preview: BoardPreviewData | null; preview: BoardPreviewData | null;
label: string; label: string;
fitFullLayout?: boolean;
} }
const maxThumbnailRows = 12; const maxThumbnailRows = 12;
export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailProps) => { export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }: BoardLayoutThumbnailProps) => {
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0); const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
if (!preview || !layout) { if (!preview || !layout) {
return ( return (
@@ -26,6 +27,7 @@ export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailPro
} }
const elements = projectBoardLayout(preview, layout, layout); const elements = projectBoardLayout(preview, layout, layout);
const thumbnailRowLimit = fitFullLayout ? Number.POSITIVE_INFINITY : maxThumbnailRows;
const roots = preview.sections const roots = preview.sections
.filter((section) => section.kind === "empty") .filter((section) => section.kind === "empty")
.toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id)); .toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
@@ -65,12 +67,12 @@ export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailPro
element.xOffset >= 0 && element.xOffset >= 0 &&
element.xOffset < columnCount && element.xOffset < columnCount &&
element.yOffset >= 0 && element.yOffset >= 0 &&
element.yOffset < maxThumbnailRows && element.yOffset < thumbnailRowLimit &&
element.width > 0 && element.width > 0 &&
element.height > 0, element.height > 0,
); );
const rowCount = Math.min( const rowCount = Math.min(
maxThumbnailRows, thumbnailRowLimit,
Math.max(4, ...laneElements.map((element) => element.yOffset + element.height)), Math.max(4, ...laneElements.map((element) => element.yOffset + element.height)),
); );
@@ -1,7 +1,26 @@
.modalContent {
overflow: hidden;
border: rem(1px) solid color-mix(in srgb, var(--mantine-color-default-border) 70%, transparent);
border-radius: rem(24px);
background: color-mix(in srgb, var(--mantine-color-body) 58%, transparent);
box-shadow: 0 rem(18px) rem(56px) rgb(0 0 0 / 22%);
backdrop-filter: blur(rem(20px)) saturate(140%);
}
.modalBody {
padding: var(--mantine-spacing-sm);
}
.content { .content {
outline: none; outline: none;
} }
.searchInput {
border-color: color-mix(in srgb, var(--mantine-color-default-border) 72%, transparent);
background: color-mix(in srgb, var(--mantine-color-body) 54%, transparent);
backdrop-filter: blur(rem(8px));
}
.option { .option {
min-width: 0; min-width: 0;
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
@@ -11,6 +30,12 @@
.card { .card {
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
box-shadow: var(--mantine-shadow-sm);
}
.metadataBadge {
width: rem(22px);
padding-inline: 0;
} }
.option:hover .card, .option:hover .card,
@@ -3,29 +3,31 @@
import type { KeyboardEvent, ReactNode, RefObject } from "react"; import type { KeyboardEvent, ReactNode, RefObject } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
Badge,
Card, Card,
Center, Center,
Group, Group,
Kbd,
Loader, Loader,
Modal, Modal,
ScrollArea, ScrollArea,
SimpleGrid, SimpleGrid,
Stack, Stack,
Text, Text,
TextInput,
ThemeIcon, ThemeIcon,
Tooltip,
UnstyledButton, UnstyledButton,
useMatches, useMatches,
VisuallyHidden, VisuallyHidden,
} from "@mantine/core"; } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks"; import { useHotkeys } from "@mantine/hooks";
import { IconArrowDown, IconArrowRight, IconLayoutBoard, IconReplace } from "@tabler/icons-react"; import { IconDeviceMobile, IconHomeFilled, IconLayoutBoard, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useOptionalBoard } from "@homarr/boards/context"; import { useOptionalBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui"; import { Link, UserAvatar } from "@homarr/ui";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail"; import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
@@ -39,6 +41,7 @@ interface BoardSwitcherProps {
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
const t = useI18n("board.action.switcher"); const t = useI18n("board.action.switcher");
const manageBoardsT = useI18n("management.page.board");
const currentBoard = useOptionalBoard(); const currentBoard = useOptionalBoard();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -50,20 +53,27 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
data: boards = [], data: boards = [],
isPending, isPending,
isError, isError,
} = clientApi.board.getManageOverview.useQuery(undefined, { } = clientApi.board.getManageOverview.useQuery(
enabled: isOpen, { fullPreview: true },
}); {
enabled: isOpen,
},
);
const availableBoards = useMemo( const switcherBoards = useMemo(
() => boards.filter((board) => board.id !== currentBoard?.id), () => [
...boards.filter((board) => board.id !== currentBoard?.id),
...boards.filter((board) => board.id === currentBoard?.id),
],
[boards, currentBoard?.id], [boards, currentBoard?.id],
); );
const filteredBoards = useMemo(() => { const filteredBoards = useMemo(() => {
const normalizedSearch = search.trim().toLocaleLowerCase(); const normalizedSearch = search.trim().toLocaleLowerCase();
if (normalizedSearch.length === 0) return availableBoards; if (normalizedSearch.length === 0) return switcherBoards;
return availableBoards.filter((board) => board.name.toLocaleLowerCase().includes(normalizedSearch)); return switcherBoards.filter((board) => board.name.toLocaleLowerCase().includes(normalizedSearch));
}, [availableBoards, search]); }, [search, switcherBoards]);
const columnCount = Math.max(1, Math.min(filteredBoards.length, responsiveColumnCount)); const columnCount = Math.max(1, Math.min(filteredBoards.length, responsiveColumnCount));
const modalColumnCount = Math.max(1, Math.min(switcherBoards.length, responsiveColumnCount));
const openSwitcher = useCallback(() => { const openSwitcher = useCallback(() => {
setIsOpen(true); setIsOpen(true);
@@ -73,7 +83,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
setSearch(""); setSearch("");
setActiveIndex(0); setActiveIndex(0);
}, []); }, []);
const preloadBoards = () => void utils.board.getManageOverview.prefetch(); const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]); useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]);
@@ -88,14 +98,17 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
}, [activeIndex, filteredBoards.length, isOpen]); }, [activeIndex, filteredBoards.length, isOpen]);
const handleModalKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { const handleModalKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Backspace") { const isSearchInput =
event.target instanceof HTMLInputElement && event.target.dataset.boardSwitcherSearch !== undefined;
if (!isSearchInput && event.key === "Backspace") {
event.preventDefault(); event.preventDefault();
setSearch((current) => current.slice(0, -1)); setSearch((current) => current.slice(0, -1));
setActiveIndex(0); setActiveIndex(0);
return; return;
} }
if (event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) { if (!isSearchInput && event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
event.preventDefault(); event.preventDefault();
setSearch((current) => `${current}${event.key}`); setSearch((current) => `${current}${event.key}`);
setActiveIndex(0); setActiveIndex(0);
@@ -141,7 +154,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
}; };
const results = getBoardSwitcherResults({ const results = getBoardSwitcherResults({
availableBoardCount: availableBoards.length, availableBoardCount: switcherBoards.length,
filteredBoards, filteredBoards,
isError, isError,
isPending, isPending,
@@ -152,6 +165,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
setActiveIndex, setActiveIndex,
optionRefs, optionRefs,
columnCount, columnCount,
manageBoardsT,
}); });
return ( return (
@@ -161,54 +175,40 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
<Modal <Modal
opened={isOpen} opened={isOpen}
onClose={closeSwitcher} onClose={closeSwitcher}
title={ withCloseButton={false}
<Group gap="sm" wrap="nowrap"> yOffset="15vh"
<ThemeIcon variant="light" radius="md"> aria-label={t("title")}
<IconReplace size={18} stroke={1.5} /> size={`${modalColumnCount * 15 + Math.max(0, modalColumnCount - 1) * 0.75}rem`}
</ThemeIcon> overlayProps={{ backgroundOpacity: 0, blur: 2 }}
<Text fw={700}>{t("title")}</Text>
</Group>
}
size={1280}
yOffset={32}
overlayProps={{ backgroundOpacity: 0.18, blur: 2 }}
transitionProps={{ transition: "fade", duration: 100, timingFunction: "ease" }} transitionProps={{ transition: "fade", duration: 100, timingFunction: "ease" }}
classNames={{ content: classes.modalContent, body: classes.modalBody }}
> >
<Stack <Stack
data-autofocus
tabIndex={-1} tabIndex={-1}
gap="md" gap="xs"
onKeyDown={handleModalKeyDown} onKeyDown={handleModalKeyDown}
className={classes.content} className={classes.content}
aria-describedby="board-switcher-instructions" aria-describedby="board-switcher-instructions"
> >
<VisuallyHidden id="board-switcher-instructions">{t("keyboard.instructions")}</VisuallyHidden> <VisuallyHidden id="board-switcher-instructions">{t("keyboard.instructions")}</VisuallyHidden>
<ScrollArea.Autosize mah={640} type="auto" offsetScrollbars> <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} {results}
</ScrollArea.Autosize> </ScrollArea.Autosize>
<Group justify={currentBoard ? "space-between" : "flex-end"} gap="xs" visibleFrom="sm">
{currentBoard && (
<Text size="xs" c="dimmed">
{t("currentHidden", { name: currentBoard.name })}
</Text>
)}
<Group gap="xs">
<Kbd size="xs">
<IconArrowRight size={12} />
</Kbd>
<Kbd size="xs">
<IconArrowDown size={12} />
</Kbd>
<Text size="xs" c="dimmed">
{t("keyboard.navigate")}
</Text>
<Kbd size="xs">Enter</Kbd>
<Text size="xs" c="dimmed">
{t("keyboard.open")}
</Text>
</Group>
</Group>
</Stack> </Stack>
</Modal> </Modal>
</> </>
@@ -227,6 +227,7 @@ interface BoardSwitcherResultsProps {
setActiveIndex: (index: number) => void; setActiveIndex: (index: number) => void;
optionRefs: RefObject<Array<HTMLAnchorElement | null>>; optionRefs: RefObject<Array<HTMLAnchorElement | null>>;
columnCount: number; columnCount: number;
manageBoardsT: ReturnType<typeof useI18n<"management.page.board">>;
} }
const getBoardSwitcherResults = ({ const getBoardSwitcherResults = ({
@@ -241,6 +242,7 @@ const getBoardSwitcherResults = ({
setActiveIndex, setActiveIndex,
optionRefs, optionRefs,
columnCount, columnCount,
manageBoardsT,
}: BoardSwitcherResultsProps) => { }: BoardSwitcherResultsProps) => {
if (isPending) { if (isPending) {
return ( return (
@@ -287,7 +289,7 @@ const getBoardSwitcherResults = ({
} }
return ( return (
<SimpleGrid id="board-switcher-results" aria-label={t("results")} cols={columnCount} spacing="md" p={4}> <SimpleGrid id="board-switcher-results" aria-label={t("results")} cols={columnCount} spacing="xs" p={2}>
{filteredBoards.map((board, index) => ( {filteredBoards.map((board, index) => (
<UnstyledButton <UnstyledButton
key={board.id} key={board.id}
@@ -296,7 +298,7 @@ const getBoardSwitcherResults = ({
}} }}
className={classes.option} className={classes.option}
w="100%" w="100%"
maw={320} maw={240}
mx="auto" mx="auto"
component={Link} component={Link}
href={`/boards/${encodeURIComponent(board.name)}`} href={`/boards/${encodeURIComponent(board.name)}`}
@@ -306,21 +308,50 @@ const getBoardSwitcherResults = ({
onFocus={() => setActiveIndex(index)} onFocus={() => setActiveIndex(index)}
onPointerEnter={() => setActiveIndex(index)} onPointerEnter={() => setActiveIndex(index)}
> >
<Card padding={0} withBorder radius="md" className={classes.card}> <Card padding={0} withBorder radius="lg" className={classes.card}>
<Card.Section> <Card.Section>
<BoardLayoutThumbnail <BoardLayoutThumbnail
preview={board.preview} preview={board.preview}
label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })} label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })}
fitFullLayout
/> />
</Card.Section> </Card.Section>
<Card.Section withBorder p="sm"> <Card.Section withBorder px="sm" py={6}>
<Group gap="sm" wrap="nowrap"> <Group justify="space-between" gap="xs" wrap="nowrap">
<ThemeIcon variant="light" radius="md" size="md"> <Text fw={600} size="sm" truncate>
<IconLayoutBoard size={16} stroke={1.5} />
</ThemeIcon>
<Text fw={650} truncate>
{board.name} {board.name}
</Text> </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> </Group>
</Card.Section> </Card.Section>
</Card> </Card>
+5 -4
View File
@@ -86,6 +86,7 @@ interface BoardItemPlacementRectangle {
const boardItemPlacementTails = new Map<string, Promise<void>>(); const boardItemPlacementTails = new Map<string, Promise<void>>();
const maxManageOverviewPreviewRows = 12; const maxManageOverviewPreviewRows = 12;
const manageOverviewInputSchema = z.object({ fullPreview: z.boolean().default(false) }).optional();
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => { const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve(); const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
@@ -225,7 +226,7 @@ export const boardRouter = createTRPCRouter({
isMobileHome: currentUser?.mobileHomeBoardId === board.id, isMobileHome: currentUser?.mobileHomeBoardId === board.id,
})); }));
}), }),
getManageOverview: publicProcedure.query(async ({ ctx }) => { getManageOverview: publicProcedure.input(manageOverviewInputSchema).query(async ({ ctx, input }) => {
const userId = ctx.session?.user.id; const userId = ctx.session?.user.id;
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId); const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships); const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
@@ -306,7 +307,7 @@ export const boardRouter = createTRPCRouter({
inArray(itemLayouts.sectionId, rootSectionIds), inArray(itemLayouts.sectionId, rootSectionIds),
gte(itemLayouts.xOffset, 0), gte(itemLayouts.xOffset, 0),
gte(itemLayouts.yOffset, 0), gte(itemLayouts.yOffset, 0),
lt(itemLayouts.yOffset, maxManageOverviewPreviewRows), input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
gt(itemLayouts.width, 0), gt(itemLayouts.width, 0),
gt(itemLayouts.height, 0), gt(itemLayouts.height, 0),
), ),
@@ -331,7 +332,7 @@ export const boardRouter = createTRPCRouter({
inArray(sectionLayouts.parentSectionId, rootSectionIds), inArray(sectionLayouts.parentSectionId, rootSectionIds),
gte(sectionLayouts.xOffset, 0), gte(sectionLayouts.xOffset, 0),
gte(sectionLayouts.yOffset, 0), gte(sectionLayouts.yOffset, 0),
lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows), input?.fullPreview ? undefined : lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
gt(sectionLayouts.width, 0), gt(sectionLayouts.width, 0),
gt(sectionLayouts.height, 0), gt(sectionLayouts.height, 0),
), ),
@@ -366,7 +367,7 @@ export const boardRouter = createTRPCRouter({
inArray(itemLayouts.sectionId, previewContainerSectionIds), inArray(itemLayouts.sectionId, previewContainerSectionIds),
gte(itemLayouts.xOffset, 0), gte(itemLayouts.xOffset, 0),
gte(itemLayouts.yOffset, 0), gte(itemLayouts.yOffset, 0),
lt(itemLayouts.yOffset, maxManageOverviewPreviewRows), input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
gt(itemLayouts.width, 0), gt(itemLayouts.width, 0),
gt(itemLayouts.height, 0), gt(itemLayouts.height, 0),
), ),
+5 -1
View File
@@ -337,7 +337,7 @@ describe("getManageOverview", () => {
expect(overview.map((board) => board.id)).not.toContain(hiddenBoardId); expect(overview.map((board) => board.id)).not.toContain(hiddenBoardId);
}); });
test("returns one compact base-layout preview for each accessible board", async () => { test("returns compact previews by default and full previews on request", async () => {
const db = createDb(); const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const boardId = createId(); const boardId = createId();
@@ -487,6 +487,10 @@ describe("getManageOverview", () => {
expect(result[0]?.preview?.layouts).toHaveLength(1); expect(result[0]?.preview?.layouts).toHaveLength(1);
expect(result[0]?.preview?.items.map((item) => item.id)).toEqual([itemId, nestedItemId]); expect(result[0]?.preview?.items.map((item) => item.id)).toEqual([itemId, nestedItemId]);
expect(result[0]?.preview?.sections).toHaveLength(2); expect(result[0]?.preview?.sections).toHaveLength(2);
const fullResult = await caller.getManageOverview({ fullPreview: true });
expect(fullResult[0]?.preview?.items.map((item) => item.id)).toEqual([itemId, offscreenItemId, nestedItemId]);
}); });
test("includes app and widget icons in compact previews", async () => { test("includes app and widget icons in compact previews", async () => {