Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e45b31320e | ||
|
|
7569abbec7 | ||
|
|
90348ad736 | ||
|
|
b626b7fcfe |
No files matched your search
@@ -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>
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in new issue
Block a user