Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f86eb5731 | ||
|
|
88541b013d |
No files matched your search
@@ -132,8 +132,8 @@ Here you have the following options in the header:
|
||||
- **Search**: Here you can search for data. You can search for integrations, boards, actions, sites and much more. Some actions may be context dependent (eg. will only show when certain widgets are on your page)
|
||||
- **Edit board**: Clicking this button will toggle edit mode. In this mode you can edit the contents of your board.
|
||||
- **Settings**: Clicking this button will redirect you to a settings page where you can adjust the board settings (page title, colors, ...).
|
||||
- **Board switcher**: Use this button to open another board that you can access.
|
||||
- **Profile**: This avatar will be visible on every page. Clicking it will show a menu with actions.
|
||||
- **Profile**: This avatar will be visible on every page. Its menu includes the board switcher; press `Shift+C` to open
|
||||
it directly. The current board is left out of the results.
|
||||
|
||||
When entering the edit mode, you'll get some additional buttons to work with:
|
||||
|
||||
@@ -369,7 +369,7 @@ Each page highlights the list view and the create action so you know where to fi
|
||||
|
||||
### Board tour
|
||||
|
||||
When you first open a board, a tour highlights the board header elements: search, edit mode, settings, board switcher, and user menu.
|
||||
When you first open a board, a tour highlights the board header elements: search, edit mode, settings, and the user menu.
|
||||
|
||||
### Tour controls
|
||||
|
||||
|
||||
@@ -27,6 +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.
|
||||
|
||||
Press `Shift+C`, or select **Switch board** from the user menu, to open the board switcher. It shows
|
||||
structural previews of the other boards you can access. 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
|
||||
|
||||
When creating a board, the availability of the board name will be checked for you.
|
||||
|
||||
@@ -3,18 +3,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconDeviceFloppy,
|
||||
IconLayoutBoard,
|
||||
IconPencil,
|
||||
IconPencilOff,
|
||||
IconReplace,
|
||||
IconSettings,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { IconDeviceFloppy, IconPencil, IconPencilOff, IconSettings, IconX } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
@@ -25,8 +16,6 @@ import { hotkeys } from "@homarr/definitions";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
@@ -56,9 +45,7 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
const t = useI18n("board");
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
|
||||
if (!hasChangeAccess) {
|
||||
return <SelectBoardsMenu />;
|
||||
}
|
||||
if (!hasChangeAccess) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -73,8 +60,6 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
|
||||
</HeaderButton>
|
||||
</TourTarget>
|
||||
)}
|
||||
|
||||
<SelectBoardsMenu />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -235,47 +220,6 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const SelectBoardsMenu = () => {
|
||||
const t = useI18n("board");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
|
||||
const preloadBoards = () => void utils.board.getAllBoards.prefetch();
|
||||
|
||||
return (
|
||||
<TourTarget id="board-switcher">
|
||||
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
|
||||
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("action.switch")}>
|
||||
<IconReplace stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||
<ScrollArea.Autosize mah={300}>
|
||||
{isPending && (
|
||||
<Center p="xs">
|
||||
<Loader size="xs" />
|
||||
</Center>
|
||||
)}
|
||||
{boards.map((board) => (
|
||||
<Menu.Item
|
||||
key={board.id}
|
||||
component={Link}
|
||||
href={`/boards/${board.name}`}
|
||||
leftSection={<IconLayoutBoard size={20} />}
|
||||
>
|
||||
{board.name}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</ScrollArea.Autosize>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Box>
|
||||
</TourTarget>
|
||||
);
|
||||
};
|
||||
|
||||
const anchorSelector = "a[href]:not([target='_blank'])";
|
||||
const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
const tBoard = useI18n("board");
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
.content {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.option {
|
||||
min-width: 0;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.option:hover .card,
|
||||
.option:focus-visible .card,
|
||||
.option[data-active="true"] .card {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
"use client";
|
||||
|
||||
import type { KeyboardEvent, ReactNode, RefObject } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Kbd,
|
||||
Loader,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
UnstyledButton,
|
||||
useMatches,
|
||||
VisuallyHidden,
|
||||
} from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import { IconArrowDown, IconArrowRight, IconLayoutBoard, IconReplace } 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 } 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 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(undefined, {
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const availableBoards = useMemo(
|
||||
() => boards.filter((board) => board.id !== currentBoard?.id),
|
||||
[boards, currentBoard?.id],
|
||||
);
|
||||
const filteredBoards = useMemo(() => {
|
||||
const normalizedSearch = search.trim().toLocaleLowerCase();
|
||||
if (normalizedSearch.length === 0) return availableBoards;
|
||||
return availableBoards.filter((board) => board.name.toLocaleLowerCase().includes(normalizedSearch));
|
||||
}, [availableBoards, search]);
|
||||
const columnCount = Math.max(1, Math.min(filteredBoards.length, responsiveColumnCount));
|
||||
|
||||
const openSwitcher = useCallback(() => {
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
const closeSwitcher = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setSearch("");
|
||||
setActiveIndex(0);
|
||||
}, []);
|
||||
const preloadBoards = () => void utils.board.getManageOverview.prefetch();
|
||||
|
||||
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>) => {
|
||||
if (event.key === "Backspace") {
|
||||
event.preventDefault();
|
||||
setSearch((current) => current.slice(0, -1));
|
||||
setActiveIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (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: availableBoards.length,
|
||||
filteredBoards,
|
||||
isError,
|
||||
isPending,
|
||||
search,
|
||||
t,
|
||||
activeIndex,
|
||||
closeSwitcher,
|
||||
setActiveIndex,
|
||||
optionRefs,
|
||||
columnCount,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{children({ open: openSwitcher, preload: preloadBoards, hotkey: boardSwitcherHotkey })}
|
||||
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={closeSwitcher}
|
||||
title={
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" radius="md">
|
||||
<IconReplace size={18} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>{t("title")}</Text>
|
||||
</Group>
|
||||
}
|
||||
size={1280}
|
||||
yOffset={32}
|
||||
overlayProps={{ backgroundOpacity: 0.18, blur: 2 }}
|
||||
transitionProps={{ transition: "fade", duration: 100, timingFunction: "ease" }}
|
||||
>
|
||||
<Stack
|
||||
data-autofocus
|
||||
tabIndex={-1}
|
||||
gap="md"
|
||||
onKeyDown={handleModalKeyDown}
|
||||
className={classes.content}
|
||||
aria-describedby="board-switcher-instructions"
|
||||
>
|
||||
<VisuallyHidden id="board-switcher-instructions">{t("keyboard.instructions")}</VisuallyHidden>
|
||||
<ScrollArea.Autosize mah={640} type="auto" offsetScrollbars>
|
||||
{results}
|
||||
</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>
|
||||
</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;
|
||||
}
|
||||
|
||||
const getBoardSwitcherResults = ({
|
||||
availableBoardCount,
|
||||
filteredBoards,
|
||||
isError,
|
||||
isPending,
|
||||
search,
|
||||
t,
|
||||
activeIndex,
|
||||
closeSwitcher,
|
||||
setActiveIndex,
|
||||
optionRefs,
|
||||
columnCount,
|
||||
}: 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="md" p={4}>
|
||||
{filteredBoards.map((board, index) => (
|
||||
<UnstyledButton
|
||||
key={board.id}
|
||||
ref={(node) => {
|
||||
optionRefs.current[index] = node;
|
||||
}}
|
||||
className={classes.option}
|
||||
w="100%"
|
||||
maw={320}
|
||||
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="md" className={classes.card}>
|
||||
<Card.Section>
|
||||
<BoardLayoutThumbnail
|
||||
preview={board.preview}
|
||||
label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })}
|
||||
/>
|
||||
</Card.Section>
|
||||
<Card.Section withBorder p="sm">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" radius="md" size="md">
|
||||
<IconLayoutBoard size={16} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<Text fw={650} truncate>
|
||||
{board.name}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
@@ -66,16 +66,6 @@ export const BoardTourProvider = ({ children }: PropsWithChildren) => {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "board-switcher",
|
||||
title: t("switcher.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("switcher.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/boards")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "board-user-menu",
|
||||
title: t("userMenu.title"),
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IconHome,
|
||||
IconLogin,
|
||||
IconLogout,
|
||||
IconReplace,
|
||||
IconSettings,
|
||||
IconRobot,
|
||||
IconTool,
|
||||
@@ -22,6 +23,7 @@ import { Link } from "@homarr/ui";
|
||||
|
||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||
import { useOptionalHomarrAssistant } from "./assistant/assistant-context";
|
||||
import { BoardSwitcher } from "./board/board-switcher";
|
||||
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
||||
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
||||
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
|
||||
@@ -41,6 +43,7 @@ const formatHotkeyLabel = (hotkey: string, modifierLabel: string) =>
|
||||
|
||||
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
|
||||
const t = useI18n("common.userAvatar.menu");
|
||||
const tBoard = useI18n("board");
|
||||
const session = useSession();
|
||||
|
||||
const { logoutUrl } = useAuthContext();
|
||||
@@ -56,98 +59,111 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }:
|
||||
}, [logoutUrl]);
|
||||
|
||||
return (
|
||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
{t("homeBoard")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentColorSchemeCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentLanguageCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{Boolean(session.data) && (
|
||||
<>
|
||||
{assistant?.enabled && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
|
||||
}
|
||||
rightSection={
|
||||
assistant.unreadCount > 0 ? (
|
||||
<Badge size="sm" variant="filled" color="red" circle>
|
||||
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
|
||||
</Badge>
|
||||
) : assistant.isRunning ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("assistantThinking")}
|
||||
</Text>
|
||||
) : (
|
||||
<Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
|
||||
)
|
||||
}
|
||||
onClick={assistant.open}
|
||||
>
|
||||
{t("assistant")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<BoardSwitcher>
|
||||
{({ open, preload, hotkey }) => (
|
||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
{t("homeBoard")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/manage/users/${session.data?.user.id}/general`}
|
||||
leftSection={<IconSettings size="1rem" />}
|
||||
leftSection={<IconReplace size="1rem" />}
|
||||
rightSection={<Kbd size="xs">{formatHotkeyLabel(hotkey, t("modifier"))}</Kbd>}
|
||||
onClick={open}
|
||||
onFocus={preload}
|
||||
onPointerEnter={preload}
|
||||
>
|
||||
{t("preferences")}
|
||||
{tBoard("action.switch")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
||||
{t("management")}
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentColorSchemeCombobox />
|
||||
</Menu.Item>
|
||||
{isDockerEnabled && (
|
||||
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
|
||||
{invariantTechnicalLabels.docker}
|
||||
<Menu.Item p={0} closeMenuOnClick={false} component="div">
|
||||
<CurrentLanguageCombobox />
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{Boolean(session.data) && (
|
||||
<>
|
||||
{assistant?.enabled && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
|
||||
}
|
||||
rightSection={
|
||||
assistant.unreadCount > 0 ? (
|
||||
<Badge size="sm" variant="filled" color="red" circle>
|
||||
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
|
||||
</Badge>
|
||||
) : assistant.isRunning ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("assistantThinking")}
|
||||
</Text>
|
||||
) : (
|
||||
<Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
|
||||
)
|
||||
}
|
||||
onClick={assistant.open}
|
||||
>
|
||||
{t("assistant")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/manage/users/${session.data?.user.id}/general`}
|
||||
leftSection={<IconSettings size="1rem" />}
|
||||
>
|
||||
{t("preferences")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
||||
{t("management")}
|
||||
</Menu.Item>
|
||||
{isDockerEnabled && (
|
||||
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
|
||||
{invariantTechnicalLabels.docker}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
{session.status === "authenticated" ? (
|
||||
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
|
||||
{t("logout")}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
|
||||
{t("login")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
{session.status === "authenticated" ? (
|
||||
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
|
||||
{t("logout")}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
|
||||
{t("login")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
<Menu.Target>
|
||||
<Indicator
|
||||
inline
|
||||
disabled={!assistant?.isRunning && !assistant?.unreadCount}
|
||||
color="red"
|
||||
size={20}
|
||||
offset={4}
|
||||
label={
|
||||
assistant?.isRunning ? (
|
||||
<Loader type="bars" color="white" size={10} />
|
||||
) : assistant?.unreadCount ? (
|
||||
assistant.unreadCount > 99 ? (
|
||||
"99+"
|
||||
) : (
|
||||
assistant.unreadCount
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Indicator>
|
||||
</Menu.Target>
|
||||
</Menu>
|
||||
</Menu.Dropdown>
|
||||
<Menu.Target>
|
||||
<Indicator
|
||||
inline
|
||||
disabled={!assistant?.isRunning && !assistant?.unreadCount}
|
||||
color="red"
|
||||
size={20}
|
||||
offset={4}
|
||||
label={
|
||||
assistant?.isRunning ? (
|
||||
<Loader type="bars" color="white" size={10} />
|
||||
) : assistant?.unreadCount ? (
|
||||
assistant.unreadCount > 99 ? (
|
||||
"99+"
|
||||
) : (
|
||||
assistant.unreadCount
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Indicator>
|
||||
</Menu.Target>
|
||||
</Menu>
|
||||
)}
|
||||
</BoardSwitcher>
|
||||
);
|
||||
};
|
||||
@@ -5130,6 +5130,25 @@
|
||||
"addContent": "Add board content",
|
||||
"settings": "Board settings",
|
||||
"switch": "Switch board",
|
||||
"switcher": {
|
||||
"title": "Switch board",
|
||||
"search": {
|
||||
"empty": "No boards match \"{search}\"."
|
||||
},
|
||||
"results": "Available boards",
|
||||
"preview": "Preview of {name} with {count} items",
|
||||
"currentHidden": "Current board: {name}",
|
||||
"keyboard": {
|
||||
"navigate": "Navigate",
|
||||
"open": "Open",
|
||||
"instructions": "Type to filter boards. Use the arrow keys to move through results and Enter to open a board."
|
||||
},
|
||||
"empty": {
|
||||
"title": "No other boards",
|
||||
"description": "There are no other boards you can access."
|
||||
},
|
||||
"error": "Boards could not be loaded."
|
||||
},
|
||||
"create": {
|
||||
"availability": {
|
||||
"checking": "Checking availability…",
|
||||
|
||||
Reference in new issue
Block a user