Compare commits

..
Author SHA1 Message Date
ajnart 4f86eb5731 fix: address board switcher review 2026-08-22 10:53:16 +02:00
ajnart 88541b013d feat: rework board switcher 2026-08-22 10:42:11 +02:00
125 changed files with 1373 additions and 2571 deletions

No files matched your search

+3 -4
View File
@@ -27,10 +27,9 @@ 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 every board you can access, with your current board at the end. Home-board markers and creator avatars
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.
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
+6 -7
View File
@@ -1,6 +1,6 @@
---
title: "Bookmarks"
description: "Keeps useful links together"
description: "Displays multiple app links"
hide_title: true
---
@@ -13,12 +13,11 @@ import { bookmarksWidget } from ".";
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
The bookmarks widget keeps direct URLs and Homarr Apps in one ordered list.
Paste a URL and press Enter, paste several comma-separated or one-per-line URLs, or search for an existing App.
URLs do not need an App record; Homarr uses the site's favicon and falls back to a link icon when it cannot load one.
Bookmarks selected from Apps continue to use the App name, URL, icon, and description.
**Adaptive** layout is the practical default: it changes between icons, compact rows, and tiles as the widget changes shape. Every compact item exposes its destination on hover, while advanced view expands items into descriptive cards.
The bookmarks widget allows you to display multiple links in a single widget.
Select one or more existing Apps directly in the widget settings; these bookmarks keep a reference to the App, so changes to its name, icon, or URL are reflected automatically.
For links that do not need an App, add their URLs under **Custom links** instead.
Its normal view adapts each configured layout to the available width and height: lower-priority hostnames are hidden before names or icons, and larger collections scroll instead of crushing cards.
Advanced view shows every bookmark as a roomy card with its full title, hostname, icon, and complete description regardless of the compact layout and visibility choices.
### Screenshots
+12 -18
View File
@@ -4,7 +4,7 @@ import { IconBookmark } from "@tabler/icons-react";
export const bookmarksWidget: WidgetDefinition = {
icon: IconBookmark,
name: "Bookmarks",
description: "Keeps useful links together",
description: "Displays multiple app links",
path: "../../widgets/bookmarks",
configuration: {
items: [
@@ -16,21 +16,9 @@ export const bookmarksWidget: WidgetDefinition = {
},
{
name: "Layout",
description: "How bookmarks use the available widget space.",
values: { type: "select", options: ["Adaptive", "Vertical", "Horizontal", "Grid", "Compact grid", "Icons"] },
defaultValue: "Adaptive",
},
{
name: "Appearance",
description: "Card surface style.",
values: { type: "select", options: ["Soft", "Filled", "Outline", "Plain"] },
defaultValue: "Soft",
},
{
name: "Card spacing",
description: "Space between bookmark cards.",
values: { type: "select", options: ["Extra small", "Small", "Medium", "Large", "Extra large"] },
defaultValue: "Extra small",
description: "Layout in which the bookmarks are displayed.",
values: { type: "select", options: ["Vertical", "Horizontal", "Grid", "Grid horizontal"] },
defaultValue: "Vertical",
},
{
name: "Hide title",
@@ -58,10 +46,16 @@ export const bookmarksWidget: WidgetDefinition = {
},
{
name: "Bookmarks",
description: "URLs and existing Apps shown by the widget.",
values: "Paste one or many URLs, or find an existing App. Drag to reorder.",
description: "List of bookmarks to display in the widget.",
values: "Select one or more Apps directly, then order them as you like.",
defaultValue: "No bookmarks selected",
},
{
name: "Custom links",
description: "URLs to display without first creating an App.",
values: "List of URLs.",
defaultValue: "No custom links",
},
],
},
};
@@ -1,53 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080" role="img" aria-labelledby="title description">
<title id="title">Abstract homelab network</title>
<description id="description">A dark indigo field with subtle connected nodes and cyan signal paths.</description>
<defs>
<radialGradient id="base" cx="50%" cy="40%" r="80%">
<stop offset="0" stop-color="#172047"/>
<stop offset="0.48" stop-color="#0b1028"/>
<stop offset="1" stop-color="#060814"/>
</radialGradient>
<radialGradient id="indigoGlow">
<stop offset="0" stop-color="#748ffc" stop-opacity="0.32"/>
<stop offset="1" stop-color="#748ffc" stop-opacity="0"/>
</radialGradient>
<radialGradient id="cyanGlow">
<stop offset="0" stop-color="#3bc9db" stop-opacity="0.22"/>
<stop offset="1" stop-color="#3bc9db" stop-opacity="0"/>
</radialGradient>
<linearGradient id="signal" x1="0" y1="0" x2="1" y2="1">
<stop stop-color="#748ffc" stop-opacity="0.05"/>
<stop offset="0.5" stop-color="#748ffc" stop-opacity="0.34"/>
<stop offset="1" stop-color="#3bc9db" stop-opacity="0.05"/>
</linearGradient>
<filter id="blur">
<feGaussianBlur stdDeviation="55"/>
</filter>
</defs>
<rect width="1920" height="1080" fill="url(#base)"/>
<ellipse cx="330" cy="90" rx="560" ry="430" fill="url(#indigoGlow)" filter="url(#blur)"/>
<ellipse cx="1680" cy="940" rx="620" ry="400" fill="url(#cyanGlow)" filter="url(#blur)"/>
<g fill="none" stroke="url(#signal)" stroke-width="1.5">
<path d="M-80 825C240 570 475 760 720 510s470-165 650-330 330-85 630-250"/>
<path d="M-120 1010C220 740 470 920 780 660s520-105 700-270 300-125 560-180"/>
<path d="M110 40c180 190 350 220 545 130s390-55 520 80 320 155 620 60" opacity=".45"/>
</g>
<g fill="#9aa8ff" opacity=".48">
<circle cx="248" cy="660" r="3"/>
<circle cx="718" cy="511" r="4"/>
<circle cx="1118" cy="287" r="3"/>
<circle cx="1450" cy="110" r="4"/>
</g>
<g fill="#5ee0ee" opacity=".42">
<circle cx="435" cy="850" r="3"/>
<circle cx="908" cy="604" r="4"/>
<circle cx="1350" cy="510" r="3"/>
<circle cx="1715" cy="285" r="4"/>
</g>
<g fill="none" stroke="#aeb8ff" stroke-opacity=".055">
<circle cx="330" cy="90" r="260"/>
<circle cx="330" cy="90" r="345"/>
<circle cx="1680" cy="940" r="250"/>
<circle cx="1680" cy="940" r="335"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

@@ -19,11 +19,35 @@
align-self: start;
box-sizing: border-box;
height: var(--board-gutter-viewport-height);
padding-top: 24px;
margin-top: -24px;
overflow: clip;
}
.gutter[data-board-editing="true"] {
outline: 0.0625rem solid var(--mantine-color-default-border);
outline-offset: -0.0625rem;
outline: 1px dashed var(--mantine-color-default-border);
outline-offset: -1px;
border-radius: var(--mantine-radius-md);
}
.gutter[data-board-editing="true"]::before {
position: sticky;
top: 0;
inset-inline-start: 0;
z-index: 2;
display: block;
width: fit-content;
height: 24px;
padding-inline: var(--mantine-spacing-xs);
margin-top: -24px;
overflow: hidden;
font-size: var(--mantine-font-size-xs);
line-height: 24px;
color: var(--mantine-color-dimmed);
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
content: attr(aria-label);
background: color-mix(in srgb, var(--mantine-color-body) 88%, transparent);
border-radius: var(--mantine-radius-sm);
}
@@ -1,6 +1,5 @@
"use client";
import type { PropsWithChildren } from "react";
import { Box, Paper, Stack, Text } from "@mantine/core";
import { IconBulb } from "@tabler/icons-react";
@@ -23,7 +22,6 @@ import {
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
import { GridEditorRegistryProvider } from "~/components/board/sections/grid/grid-editor-registry";
import { BoardGridPortalHost } from "~/components/board/sections/grid/grid-portal-host";
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
import { BoardBackgroundVideo } from "~/components/layout/background";
@@ -33,12 +31,6 @@ import classes from "./_client.module.css";
const APP_SHELL_INLINE_PADDING = 32;
const BoardSelectionGridProvider = ({ children }: PropsWithChildren) => (
<GridEditorRegistryProvider>
<BoardSelectionProvider>{children}</BoardSelectionProvider>
</GridEditorRegistryProvider>
);
export const ClientBoard = () => {
const board = useRequiredBoard();
const t = useI18n("board.landmark");
@@ -76,7 +68,7 @@ export const ClientBoard = () => {
.join(" ");
const content = (
<BoardSelectionGridProvider>
<BoardSelectionProvider>
<BoardAdvancedFocusProvider>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardBackgroundVideo />
@@ -152,7 +144,7 @@ export const ClientBoard = () => {
</FloatingTip>
</Box>
</BoardAdvancedFocusProvider>
</BoardSelectionGridProvider>
</BoardSelectionProvider>
);
if (representativeWidth === null) return content;
@@ -130,45 +130,6 @@
color: var(--mantine-color-text);
}
.widgetPanel[data-board-widget] {
background: transparent;
}
.widgetPanel[data-board-widget] .panelHeader,
.widgetPanel[data-board-widget] .composerWrap {
border-color: rgb(from var(--mantine-color-default-border) r g b / var(--opacity, 1));
}
.widgetPanel[data-board-widget] .composerWrap,
.widgetPanel[data-board-widget] .composer,
.widgetPanel[data-board-widget] .composerQuote,
.widgetPanel[data-board-widget] .pendingQuestionDock {
background: transparent;
}
.widgetPanel[data-board-widget] .suggestion,
.widgetPanel[data-board-widget] .runtimeSelectorTrigger,
.widgetPanel[data-board-widget] .composerContext,
.widgetPanel[data-board-widget] .providerQuotaTrigger {
border-color: rgb(from var(--mantine-color-default-border) r g b / var(--opacity, 1));
background: transparent;
}
.widgetPanel[data-board-widget] .suggestion:hover:not(:disabled),
.widgetPanel[data-board-widget] .runtimeSelectorTrigger:hover:not(:disabled),
.widgetPanel[data-board-widget] .composerContext:hover:not(:disabled),
.widgetPanel[data-board-widget] .providerQuotaTrigger:hover:not(:disabled) {
background: rgb(from var(--mantine-color-default-hover) r g b / var(--opacity, 1));
}
.widgetPanel[data-board-widget] .suggestion:focus-visible,
.widgetPanel[data-board-widget] .runtimeSelectorTrigger:focus-visible,
.widgetPanel[data-board-widget] .composerContext:focus-visible,
.widgetPanel[data-board-widget] .providerQuotaTrigger:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.widgetState {
container-type: inline-size;
overflow: auto;
@@ -196,12 +157,30 @@
border-bottom: 1px solid var(--mantine-color-default-border);
}
.panelIdentity {
min-width: 0;
flex: 1 1 auto;
}
.panelIdentityIcon {
flex: 0 0 auto;
}
.panelIdentityTitle,
.panelThreadTitle {
max-width: 14rem;
}
.panelActions {
min-width: 0;
flex: 0 0 auto;
margin-inline-start: auto;
}
.newConversationButton {
flex: 0 0 auto;
}
.panelAction {
width: 2.625rem;
height: 2.625rem;
@@ -228,6 +207,21 @@
display: none;
}
.newConversationButton {
width: 2.625rem;
max-width: 2.625rem;
height: 2.625rem;
padding-inline: 0.75rem;
}
.newConversationButton :global(.mantine-Button-section) {
margin-inline: 0;
}
.newConversationButton :global(.mantine-Button-label) {
display: none;
}
.messages {
padding: 0.5rem 0.65rem 1rem;
}
@@ -283,7 +277,8 @@
@container assistant-widget (max-width: 22rem) {
.panelAction,
.historyButton {
.historyButton,
.newConversationButton {
width: 2.25rem;
max-width: 2.25rem;
height: 2.25rem;
@@ -306,6 +301,10 @@
.composerContextLabel {
display: none;
}
.panelThreadTitle {
display: none;
}
}
@container assistant-widget (max-width: 18rem) {
@@ -2368,7 +2368,7 @@ const EmptyThread = () => {
<Box className={classes.empty}>
<Stack align="center" gap="lg" maw={560} w="100%">
<Stack align="center" gap="xs" maw={430}>
<ThemeIcon size={52} radius="xl" variant="light">
<ThemeIcon size={52} radius="xl" variant="light" color="red">
<IconRobot size={27} />
</ThemeIcon>
<Text size="xl" fw={700}>
@@ -2957,6 +2957,7 @@ const Composer = (props: ComposerProps) => {
) : (
<ComposerPrimitive.Send asChild>
<ActionIcon
color="red"
variant="filled"
size="lg"
aria-label={
@@ -3161,6 +3162,27 @@ interface AssistantConversationSurfaceProps extends AssistantConversationControl
onDismiss?: () => void;
}
const AssistantPanelIdentity = () => {
const t = useI18n("assistant");
const threadTitle = useAuiState((state) => state.threadListItem.title);
return (
<Group className={classes.panelIdentity} gap="xs" wrap="nowrap">
<ThemeIcon className={classes.panelIdentityIcon} variant="light" color="red" radius="xl" size="md">
<IconRobot size={16} />
</ThemeIcon>
<Box miw={0}>
<Text className={classes.panelIdentityTitle} size="sm" fw={700} truncate>
{t("title")}
</Text>
<Text className={classes.panelThreadTitle} size="xs" c="dimmed" truncate>
{threadTitle ?? t("newConversation")}
</Text>
</Box>
</Group>
);
};
export const AssistantConversationSurface = ({
isRunning,
pendingAction,
@@ -3190,20 +3212,23 @@ export const AssistantConversationSurface = ({
return (
<AssistantDirectiveEntitiesProvider>
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
<AssistantPanelIdentity />
<Group className={classes.panelActions} gap={2} wrap="nowrap">
<ConversationHistory />
<ViewRefreshAction isRefreshing={isRefreshing} onRefresh={onRefresh} />
<AutoApprovalControl />
<Tooltip label={t("newConversation")}>
<ThreadListPrimitive.New asChild>
<ActionIcon
className={classes.panelAction}
<Button
className={classes.newConversationButton}
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<IconPlus size={17} />}
aria-label={t("newConversation")}
>
<IconPlus size={17} />
</ActionIcon>
{t("newConversation")}
</Button>
</ThreadListPrimitive.New>
</Tooltip>
{onExpand && (
@@ -16,7 +16,7 @@ const AssistantPromptDetail = () => {
const t = useI18n("assistant");
return (
<Group px="md" py="sm" wrap="nowrap">
<ThemeIcon variant="light" radius="xl">
<ThemeIcon color="red" variant="light" radius="xl">
<IconRobot size={17} />
</ThemeIcon>
<Stack gap={1}>
@@ -137,7 +137,7 @@ const CompactAssistantWidget = ({
<Box className={classes.widgetPanel}>
<Group className={classes.compactState} justify="space-between" wrap="nowrap" gap="sm">
<Group wrap="nowrap" gap="xs" miw={0}>
<ThemeIcon variant="light" radius="xl" size="lg" flex="0 0 auto">
<ThemeIcon variant="light" color="red" radius="xl" size="lg" flex="0 0 auto">
<IconRobot size={19} />
</ThemeIcon>
<Stack className={classes.compactCopy} gap={1}>
@@ -167,7 +167,13 @@ interface AssistantWidgetStateProps {
children?: ReactNode;
}
const AssistantWidgetState = ({ icon: Icon, title, description, color, children }: AssistantWidgetStateProps) => (
const AssistantWidgetState = ({
icon: Icon,
title,
description,
color = "red",
children,
}: AssistantWidgetStateProps) => (
<Center className={classes.widgetState} h="100%" p="md">
<Stack align="center" gap="sm" ta="center" maw={360}>
<ThemeIcon variant="light" color={color} radius="xl" size="xl">
@@ -1,6 +1,5 @@
.canvas {
width: 100%;
min-width: 0;
min-height: 8rem;
aspect-ratio: 16 / 7;
overflow: hidden;
color: var(--mantine-color-dimmed);
@@ -84,3 +83,9 @@
gap: rem(1px);
overflow: hidden;
}
@media (max-width: $mantine-breakpoint-xs) {
.canvas {
min-height: 7rem;
}
}
@@ -11,12 +11,11 @@ import classes from "./board-layout-thumbnail.module.css";
interface BoardLayoutThumbnailProps {
preview: BoardPreviewData | null;
label: string;
fitFullLayout?: boolean;
}
const maxThumbnailRows = 12;
export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }: BoardLayoutThumbnailProps) => {
export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailProps) => {
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
if (!preview || !layout) {
return (
@@ -27,7 +26,6 @@ export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }:
}
const elements = projectBoardLayout(preview, layout, layout);
const thumbnailRowLimit = fitFullLayout ? Number.POSITIVE_INFINITY : maxThumbnailRows;
const roots = preview.sections
.filter((section) => section.kind === "empty")
.toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
@@ -67,12 +65,12 @@ export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }:
element.xOffset >= 0 &&
element.xOffset < columnCount &&
element.yOffset >= 0 &&
element.yOffset < thumbnailRowLimit &&
element.yOffset < maxThumbnailRows &&
element.width > 0 &&
element.height > 0,
);
const rowCount = Math.min(
thumbnailRowLimit,
maxThumbnailRows,
Math.max(4, ...laneElements.map((element) => element.yOffset + element.height)),
);
@@ -1,26 +1,7 @@
.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 {
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 {
min-width: 0;
border-radius: var(--mantine-radius-md);
@@ -30,12 +11,6 @@
.card {
height: 100%;
overflow: hidden;
box-shadow: var(--mantine-shadow-sm);
}
.metadataBadge {
width: rem(22px);
padding-inline: 0;
}
.option:hover .card,
@@ -3,31 +3,29 @@
import type { KeyboardEvent, ReactNode, RefObject } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Badge,
Card,
Center,
Group,
Kbd,
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 { 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, UserAvatar } from "@homarr/ui";
import { Link } from "@homarr/ui";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
@@ -41,7 +39,6 @@ interface BoardSwitcherProps {
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("");
@@ -53,27 +50,20 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
data: boards = [],
isPending,
isError,
} = clientApi.board.getManageOverview.useQuery(
{ fullPreview: true },
{
enabled: isOpen,
},
);
} = clientApi.board.getManageOverview.useQuery(undefined, {
enabled: isOpen,
});
const switcherBoards = useMemo(
() => [
...boards.filter((board) => board.id !== currentBoard?.id),
...boards.filter((board) => board.id === currentBoard?.id),
],
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 switcherBoards;
return switcherBoards.filter((board) => board.name.toLocaleLowerCase().includes(normalizedSearch));
}, [search, switcherBoards]);
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 modalColumnCount = Math.max(1, Math.min(switcherBoards.length, responsiveColumnCount));
const openSwitcher = useCallback(() => {
setIsOpen(true);
@@ -83,7 +73,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
setSearch("");
setActiveIndex(0);
}, []);
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
const preloadBoards = () => void utils.board.getManageOverview.prefetch();
useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]);
@@ -98,17 +88,14 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
}, [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") {
if (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) {
if (event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) {
event.preventDefault();
setSearch((current) => `${current}${event.key}`);
setActiveIndex(0);
@@ -154,7 +141,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
};
const results = getBoardSwitcherResults({
availableBoardCount: switcherBoards.length,
availableBoardCount: availableBoards.length,
filteredBoards,
isError,
isPending,
@@ -165,7 +152,6 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
setActiveIndex,
optionRefs,
columnCount,
manageBoardsT,
});
return (
@@ -175,40 +161,54 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
<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 }}
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" }}
classNames={{ content: classes.modalContent, body: classes.modalBody }}
>
<Stack
data-autofocus
tabIndex={-1}
gap="xs"
gap="md"
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">
<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>
</>
@@ -227,7 +227,6 @@ interface BoardSwitcherResultsProps {
setActiveIndex: (index: number) => void;
optionRefs: RefObject<Array<HTMLAnchorElement | null>>;
columnCount: number;
manageBoardsT: ReturnType<typeof useI18n<"management.page.board">>;
}
const getBoardSwitcherResults = ({
@@ -242,7 +241,6 @@ const getBoardSwitcherResults = ({
setActiveIndex,
optionRefs,
columnCount,
manageBoardsT,
}: BoardSwitcherResultsProps) => {
if (isPending) {
return (
@@ -289,7 +287,7 @@ const getBoardSwitcherResults = ({
}
return (
<SimpleGrid id="board-switcher-results" aria-label={t("results")} cols={columnCount} spacing="xs" p={2}>
<SimpleGrid id="board-switcher-results" aria-label={t("results")} cols={columnCount} spacing="md" p={4}>
{filteredBoards.map((board, index) => (
<UnstyledButton
key={board.id}
@@ -298,7 +296,7 @@ const getBoardSwitcherResults = ({
}}
className={classes.option}
w="100%"
maw={240}
maw={320}
mx="auto"
component={Link}
href={`/boards/${encodeURIComponent(board.name)}`}
@@ -308,50 +306,21 @@ const getBoardSwitcherResults = ({
onFocus={() => setActiveIndex(index)}
onPointerEnter={() => setActiveIndex(index)}
>
<Card padding={0} withBorder radius="lg" className={classes.card}>
<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) })}
fitFullLayout
/>
</Card.Section>
<Card.Section withBorder px="sm" py={6}>
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text fw={600} size="sm" truncate>
<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 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>
@@ -11,7 +11,6 @@ import { useI18n } from "@homarr/translation/client";
import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { getLayoutRowCount } from "../layout";
import { resolvePinnedGridCollisions } from "../sections/grid/dnd";
import { useRegisteredGridEditors } from "../sections/grid/grid-editor-registry";
import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements";
import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions";
import { useGridLayoutActions } from "../sections/grid/use-grid-layout-actions";
@@ -58,29 +57,26 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
const tContainer = useI18n("section.container");
const tMoveResize = useI18n("item.moveResize");
const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps;
const registeredGridEditors = useRegisteredGridEditors();
const minimumSize = useMemo(
() => getEntryMinimumSize(board, currentLayoutId, entry),
[board, currentLayoutId, entry],
);
const targets = getMoveTargets(
board,
currentLayoutId,
entry,
{
canvas: tLandmark("canvas"),
container: tContainer("action.create"),
leftRail: tLandmark("leftRail"),
rightRail: tLandmark("rightRail"),
numbered: (name, index) => tMoveResize("target.numbered", { name, index: String(index) }),
located: (name, location, index) =>
tMoveResize("target.located", {
name,
location,
index: String(index),
}),
},
registeredGridEditors,
const targets = useMemo(
() =>
getMoveTargets(board, currentLayoutId, entry, {
canvas: tLandmark("canvas"),
container: tContainer("action.create"),
leftRail: tLandmark("leftRail"),
rightRail: tLandmark("rightRail"),
numbered: (name, index) => tMoveResize("target.numbered", { name, index: String(index) }),
located: (name, location, index) =>
tMoveResize("target.located", {
name,
location,
index: String(index),
}),
}),
[board, currentLayoutId, entry, tContainer, tLandmark, tMoveResize],
);
const targetById = useMemo(() => new Map(targets.map((target) => [target.id, target])), [targets]);
const initialTarget = targetById.get(sourceSectionId) ?? targets[0];
@@ -256,7 +252,6 @@ export const getMoveTargets = (
layoutId: string,
entry: MovableEntry,
labels: MoveTargetLabels,
registeredGridEditors?: ReadonlyMap<string, { placementMaxRowCount: number | null }>,
): MoveTarget[] => {
const minimum = getEntryMinimumSize(board, layoutId, entry);
@@ -265,15 +260,13 @@ export const getMoveTargets = (
const columnCount = getSectionColumnCount(board, layoutId, section.id);
const lane = getRootSectionLane(section.xOffset);
if (columnCount === 0) return [];
const registeredEditor = registeredGridEditors?.get(section.id);
if (lane !== "main" && registeredGridEditors && !registeredEditor) return [];
return minimum.width <= columnCount
? [
{
id: section.id,
name: lane === "left" ? labels.leftRail : lane === "right" ? labels.rightRail : labels.canvas,
columnCount,
maxRowCount: registeredEditor?.placementMaxRowCount ?? getSectionMaxRowCount(board, layoutId, section.id),
maxRowCount: getSectionMaxRowCount(board, layoutId, section.id),
},
]
: [];
@@ -3,8 +3,8 @@
The board uses fixed logical units:
- one `1 × 1` item is exactly `200 × 200` logical pixels;
- the gap matches Mantine's `sm` spacing at `12` logical pixels, giving a `212` pixel track pitch;
- multi-cell items include the gaps between their cells (`2 × 1` is `412 × 200`);
- the gap is always `24` logical pixels, giving a `224` pixel track pitch;
- multi-cell items include the gaps between their cells (`2 × 1` is `424 × 200`);
- the canvas is zoomed once with one uniform scale derived from its available width.
Items keep the same logical dimensions at every viewport. The viewport reserves
@@ -3,7 +3,7 @@
* individual cards never change their internal layout at different viewports.
*/
export const LOGICAL_GRID_CELL_SIZE = 200;
export const LOGICAL_GRID_GAP = 12;
export const LOGICAL_GRID_GAP = 24;
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
export const COLLAPSED_SECTION_ROW_COUNT = 0.5;
@@ -23,8 +23,6 @@
* zoom and enlarges everything together.
*/
--mantine-scale: var(--board-canvas-ui-scale, 1);
/* Keep a subtle boundary inset without widening the visual gap too much. */
--board-grid-item-inset: calc(var(--mantine-spacing-xs) / 2);
--mantine-spacing-xs: calc(0.625rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-sm: calc(0.75rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1));
@@ -5,12 +5,7 @@ import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/bo
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
import {
getBoardLaneColumnCount,
getInitialBoardLogicalHeight,
getLogicalTrackSize,
getRootSectionForLane,
} from "../index";
import { getBoardLaneColumnCount, getInitialBoardLogicalHeight, getRootSectionForLane } from "../index";
describe("board lanes", () => {
test("gutters consume columns from the main canvas", () => {
@@ -131,6 +126,6 @@ describe("board lanes", () => {
},
];
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(getLogicalTrackSize(4));
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(872);
});
});
@@ -9,18 +9,18 @@ import {
} from "../index";
describe("fixed dashboard geometry", () => {
test("keeps a 1x1 item exactly 200px with a fixed 12px gap", () => {
test("keeps a 1x1 item exactly 200px with a fixed 24px gap", () => {
expect(getLogicalTrackSize(1)).toBe(200);
expect(getLogicalTrackSize(0.5)).toBe(94);
expect(getLogicalTrackSize(2)).toBe(412);
expect(getLogicalTrackSize(3)).toBe(624);
expect(getLogicalTrackSize(0.5)).toBe(88);
expect(getLogicalTrackSize(2)).toBe(424);
expect(getLogicalTrackSize(3)).toBe(648);
expect(getLogicalItemStyle({ x: 1, y: 2, w: 2, h: 3 })).toEqual({
position: "absolute",
left: 212,
top: 424,
width: 412,
height: 624,
left: 224,
top: 448,
width: 424,
height: 648,
});
});
@@ -35,7 +35,7 @@ describe("fixed dashboard geometry", () => {
expect(getGridRowCountForVisualHeight(1200, 1)).toBe(6);
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 1))).toBeGreaterThanOrEqual(1200);
expect(getGridRowCountForVisualHeight(1200, 0.5)).toBe(12);
expect(getGridRowCountForVisualHeight(1200, 0.5)).toBe(11);
expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 0.5)) * 0.5).toBeGreaterThanOrEqual(1200);
});
});
@@ -46,7 +46,6 @@ export const BoardContainerSection = ({ section }: Props) => {
<Card
className={combineClasses(
classes.itemCard,
classes.containerCard,
options.customCssClasses.join(" "),
isVisuallyCollapsed && classes.collapsedContainerCard,
)}
@@ -57,7 +56,7 @@ export const BoardContainerSection = ({ section }: Props) => {
root: {
overflow: "visible",
"--opacity": board.opacity / 100,
"--container-border-color": options.borderColor || undefined,
"--border-color": options.borderColor || undefined,
},
}}
radius={board.itemRadius}
@@ -67,7 +66,7 @@ export const BoardContainerSection = ({ section }: Props) => {
<Button
className={classes.containerToggle}
pos="absolute"
top={isVisuallyCollapsed ? 0 : "calc(var(--mantine-spacing-xs) * -1)"}
top={isVisuallyCollapsed ? 0 : -24}
left={0}
w="100%"
h={isVisuallyCollapsed ? "100%" : 24}
@@ -104,9 +103,8 @@ export const BoardContainerSection = ({ section }: Props) => {
)}
{!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && (
<Badge
className={classes.containerLabel}
pos="absolute"
top="calc(var(--mantine-spacing-xs) * -1)"
top={-24}
left={labelLeft}
maw={`calc(100% - ${labelLeft + labelRight}px)`}
size="md"
@@ -115,7 +113,12 @@ export const BoardContainerSection = ({ section }: Props) => {
c="var(--mantine-color-text)"
style={{
zIndex: 9,
overflow: "hidden",
pointerEvents: "none",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
backgroundColor: "var(--background-color)",
borderColor: "var(--border-color)",
}}
title={options.title}
data-board-container-label
@@ -125,9 +128,8 @@ export const BoardContainerSection = ({ section }: Props) => {
)}
{options.showOpenAll && !isEditMode && (
<ActionIcon
className={classes.containerAction}
pos="absolute"
top={isVisuallyCollapsed ? "50%" : "calc(var(--mantine-spacing-xs) * -1)"}
top={isVisuallyCollapsed ? "50%" : -24}
right={8}
style={{ zIndex: 10, transform: isVisuallyCollapsed ? "translateY(-50%)" : undefined }}
variant={options.collapsible ? "subtle" : "default"}
@@ -7,7 +7,7 @@ import { createPortal } from "react-dom";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getLoadedGridEditorModule, loadGridEditorAsync } from "./grid-editor-loader";
import { useRegisteredGridEditors } from "./grid-editor-registry";
import { GridEditorRegistryProvider, useRegisteredGridEditors } from "./grid-editor-registry";
import { GridEditorRuntimeProvider } from "./grid-editor-runtime";
type BoardGridEditorProviderComponent = ComponentType<PropsWithChildren>;
@@ -54,14 +54,14 @@ export const BoardGridEditorBoundary = ({ children }: PropsWithChildren) => {
const GridEditor = resolvedEditorModule?.default ?? null;
return (
<>
<GridEditorRegistryProvider>
<GridEditorRuntimeProvider status={runtimeStatus}>{children}</GridEditorRuntimeProvider>
{isEditMode && Provider && GridEditor && (
<Provider>
<RegisteredGridEditors GridEditor={GridEditor} />
</Provider>
)}
</>
</GridEditorRegistryProvider>
);
};
@@ -23,12 +23,7 @@ describe("dnd grid coordinates", () => {
test("uses the dragged shape origin so a non-center grab retains its pointer offset", () => {
const scale = 0.75;
const target = { left: -280, top: 390, width: getLogicalTrackSize(5) * scale };
const initialShape = {
left: target.left,
top: target.top,
width: getLogicalTrackSize(2) * scale,
height: 200 * scale,
};
const initialShape = { left: target.left, top: target.top, width: 424 * scale, height: 200 * scale };
const pointerDelta = { x: LOGICAL_GRID_PITCH * scale * 2, y: LOGICAL_GRID_PITCH * scale * 3 };
expect(
@@ -55,7 +55,6 @@ export interface GridEditorRenderProps {
}
export interface RegisteredGridEditor extends GridEditorRenderProps {
placementMaxRowCount: number | null;
host: HTMLElement;
disabled: boolean;
}
@@ -51,7 +51,7 @@
.board-grid-entry[data-board-item-selected="true"]::after {
position: absolute;
inset: var(--board-grid-item-inset, 0px);
inset: 0;
z-index: 100;
content: "";
background: color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent);
@@ -121,7 +121,6 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
.board-grid-placeholder {
z-index: 0;
clip-path: inset(var(--board-grid-item-inset, 0px));
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
border: 2px dashed var(--mantine-primary-color-filled);
border-radius: var(--mantine-radius-md);
@@ -140,7 +139,6 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
.board-grid-resize-outline {
z-index: 1002;
clip-path: inset(var(--board-grid-item-inset, 0px));
background: color-mix(in srgb, var(--mantine-primary-color-filled) 6%, transparent);
border: 2px solid var(--mantine-primary-color-filled);
border-radius: var(--mantine-radius-md);
@@ -155,6 +153,17 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
box-shadow: inset 0 0 0 2px var(--mantine-color-red-filled);
}
[data-section-kind="empty"][data-rail-placement="main"] > .board-grid-editor[data-dnd-drop-target="true"] {
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
outline-offset: 4px;
box-shadow: none;
}
[data-section-kind="empty"][data-rail-placement="main"]
> .board-grid-editor[data-dnd-drop-target="true"][data-dnd-drop-valid="false"] {
outline-color: var(--mantine-color-red-filled);
}
[data-board-container-collapsed]
> [data-board-container-body]
> [data-section-id]
@@ -231,11 +240,11 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
}
.board-grid-resize-handle[data-grid-resize-handle="n"] {
top: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
top: -0.25rem;
}
.board-grid-resize-handle[data-grid-resize-handle="s"] {
bottom: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
bottom: -0.25rem;
}
.board-grid-resize-handle[data-grid-resize-handle="e"],
@@ -249,11 +258,11 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
}
.board-grid-resize-handle[data-grid-resize-handle="e"] {
right: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
right: -0.25rem;
}
.board-grid-resize-handle[data-grid-resize-handle="w"] {
left: calc(var(--board-grid-item-inset, 0px) - 0.25rem);
left: -0.25rem;
}
.board-grid-resize-handle[data-grid-resize-handle="ne"],
@@ -266,30 +275,42 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
}
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
top: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
right: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
top: -0.625rem;
right: -0.625rem;
cursor: nesw-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="se"] {
right: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
bottom: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
right: -0.625rem;
bottom: -0.625rem;
cursor: nwse-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
bottom: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
left: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
bottom: -0.625rem;
left: -0.625rem;
cursor: nesw-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
top: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
left: calc(var(--board-grid-item-inset, 0px) - 0.625rem);
top: -0.625rem;
left: -0.625rem;
cursor: nwse-resize;
/* Leaves a thin draggable border around the corner instead of a solid 44x44 hit area,
so it doesn't sit on top of the container menu button anchored in this same corner. */
clip-path: polygon(evenodd, 0 0, 100% 0, 100% 100%, 0 100%, 0 0, 4px 4px, 4px 40px, 40px 40px, 40px 4px, 4px 4px);
clip-path: polygon(
evenodd,
0 0,
100% 0,
100% 100%,
0 100%,
0 0,
4px 4px,
4px 40px,
40px 40px,
40px 4px,
4px 4px
);
}
@media (prefers-reduced-motion: reduce) {
@@ -46,14 +46,12 @@
.contentMount {
position: absolute;
inset: var(--board-grid-item-inset, 0px);
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.staticItem:has([data-board-container-label]) {
z-index: 2;
}
.editorEntry {
overflow: visible;
cursor: grab;
@@ -130,10 +130,6 @@ export const SectionGrid = ({
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null;
let placementMaxRowCount = maxRowCount;
if (railPlacement !== "main") {
placementMaxRowCount = minimumViewportRowCount;
}
// A scrollable container isn't forced to grow with its content - it scrolls internally instead
// of expanding to fit every widget, so its viewport height is capped independently of rowCount.
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
@@ -179,7 +175,6 @@ export const SectionGrid = ({
columnCount,
rowCount,
maxRowCount,
placementMaxRowCount,
placements: displayPlacements,
transactionPlacements: placements,
className: editorClassName,
@@ -195,7 +190,6 @@ export const SectionGrid = ({
entryElementStore,
isInteractionDisabled,
maxRowCount,
placementMaxRowCount,
placements,
rowCount,
section,
@@ -1,7 +1,5 @@
import { beforeEach, describe, expect, test } from "vitest";
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import { createGridPreviewDomState, syncGridPreviewGeometry } from "../grid-preview-geometry";
describe("grid preview geometry", () => {
@@ -27,12 +25,8 @@ describe("grid preview geometry", () => {
expect(elements.get("active")?.hasAttribute("data-grid-preview")).toBe(false);
expect(elements.get("active")?.getAttribute("data-dnd-active")).toBe("true");
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-x")).toBe(
`${-2 * LOGICAL_GRID_PITCH}px`,
);
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-y")).toBe(
`${2 * LOGICAL_GRID_PITCH}px`,
);
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-x")).toBe("-448px");
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-y")).toBe("448px");
expect(elements.get("displaced")?.dataset.gridX).toBe("0");
expect(elements.get("displaced")?.dataset.gridY).toBe("2");
@@ -63,10 +57,10 @@ describe("grid preview geometry", () => {
const active = elements.get("active");
expect(active?.getAttribute("data-grid-preview")).toBe("true");
expect(active?.style.getPropertyValue("--board-grid-preview-x")).toBe(`${LOGICAL_GRID_PITCH}px`);
expect(active?.style.getPropertyValue("--board-grid-preview-y")).toBe(`${LOGICAL_GRID_PITCH}px`);
expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe(`${getLogicalTrackSize(3)}px`);
expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe(`${getLogicalTrackSize(4)}px`);
expect(active?.style.getPropertyValue("--board-grid-preview-x")).toBe("224px");
expect(active?.style.getPropertyValue("--board-grid-preview-y")).toBe("224px");
expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe("648px");
expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe("872px");
expect(active?.dataset.gridW).toBe("3");
expect(active?.dataset.gridH).toBe("4");
});
@@ -1,7 +1,5 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { getLogicalTrackSize } from "~/components/board/layout";
import { createGridResizeOutlineController } from "../grid-resize-outline";
describe("grid resize outline", () => {
@@ -30,8 +28,8 @@ describe("grid resize outline", () => {
const outline = shell.querySelector<HTMLElement>("[data-grid-resize-outline]");
expect(outline?.style.left).toBe("0px");
expect(outline?.style.top).toBe("0px");
expect(outline?.style.width).toBe(`${getLogicalTrackSize(1)}px`);
expect(outline?.style.height).toBe(`${getLogicalTrackSize(2)}px`);
expect(outline?.style.width).toBe("200px");
expect(outline?.style.height).toBe("424px");
expect(outline?.dataset.gridResizeValid).toBe("false");
controller.schedule(null);
@@ -11,18 +11,6 @@
border-color: var(--border-color) !important;
}
.containerCard {
@mixin dark {
--container-default-border-color: var(--mantine-color-dark-4);
}
@mixin light {
--container-default-border-color: var(--mantine-color-gray-3);
}
--border-color: rgb(
from var(--container-border-color, var(--container-default-border-color)) r g b / var(--opacity, 1)
);
}
.itemCard:global(.iframe-wrapper) {
background-color: transparent !important;
border-color: transparent !important;
@@ -38,8 +26,8 @@
z-index: 9;
overflow: hidden;
color: var(--mantine-color-text);
background-color: var(--background-color) !important;
border-color: var(--border-color) !important;
background-color: var(--background-color);
border-color: var(--border-color);
}
.containerToggle :global(.mantine-Button-inner) {
@@ -54,24 +42,6 @@
white-space: nowrap;
}
.containerLabel,
.containerAction {
background-color: var(--background-color) !important;
border-color: var(--border-color) !important;
}
.containerLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.containerLabel :global(.mantine-Badge-label) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.containerBody {
visibility: visible;
opacity: 1;
@@ -31,7 +31,7 @@ interface BoardSelectionContextValue {
copySelectedItems: () => Promise<void>;
pasteItems: () => Promise<void>;
removeSelectedItems: () => void;
moveSelectedItemsToSection: (sectionId: string, maxRowCount?: number | null) => void;
moveSelectedItemsToSection: (sectionId: string) => void;
}
const BoardSelectionContext = createContext<BoardSelectionContextValue | null>(null);
@@ -229,7 +229,7 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
}, [board.items, clearSelection, persistBoard, selectedItemIds, tCommon, tSelection]);
const moveSelectedItemsToSection = useCallback(
(targetSectionId: string, maxRowCount?: number | null) => {
(targetSectionId: string) => {
if (selectedItemIds.size === 0) return;
const idsToMove = new Set(selectedItemIds);
@@ -248,7 +248,7 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
targetSection.kind === "container"
? (containerLayout?.width ?? 0)
: getBoardLaneColumnCount(boardLayout, getRootSectionLane(targetSection.xOffset));
const rowCount = containerLayout?.height ?? maxRowCount ?? 9999;
const rowCount = containerLayout?.height ?? 9999;
if (columnCount === 0 || rowCount === 0) return previous;
const existingContainers = previous.sections
@@ -25,7 +25,6 @@ import { useI18n } from "@homarr/translation/client";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import type { EmptySection } from "~/app/[locale]/boards/_types";
import { useRegisteredGridEditors } from "~/components/board/sections/grid/grid-editor-registry";
import { useBoardSelection } from "./board-selection-context";
const MAX_AVATARS_DISPLAYED = 5;
@@ -41,7 +40,6 @@ export const BoardSelectionToolbar = () => {
moveSelectedItemsToSection,
} = useBoardSelection();
const board = useRequiredBoard();
const registeredGridEditors = useRegisteredGridEditors();
const currentLayoutId = useCurrentLayout();
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
const t = useI18n();
@@ -98,15 +96,7 @@ export const BoardSelectionToolbar = () => {
.at(0);
const placementOptions = [
...(mainCanvasSection
? [
{
value: mainCanvasSection.id,
label: t("item.create.destination.mainCanvas"),
maxRowCount: null,
},
]
: []),
...(mainCanvasSection ? [{ value: mainCanvasSection.id, label: t("item.create.destination.mainCanvas") }] : []),
...board.sections.flatMap((section) => {
if (section.kind !== "empty") return [];
const lane = getRootSectionLane(section.xOffset);
@@ -115,7 +105,6 @@ export const BoardSelectionToolbar = () => {
{
value: section.id,
label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`),
maxRowCount: registeredGridEditors.get(section.id)?.placementMaxRowCount,
},
];
}),
@@ -124,7 +113,6 @@ export const BoardSelectionToolbar = () => {
.map((section) => ({
value: section.id,
label: section.options.title.trim() || t("section.container.untitled"),
maxRowCount: section.layouts.find((layout) => layout.layoutId === currentLayoutId)?.height,
})),
];
@@ -256,11 +244,7 @@ export const BoardSelectionToolbar = () => {
<Menu.Dropdown>
<Menu.Label>{tSelection("moveTo")}</Menu.Label>
{placementOptions.map((opt) => (
<Menu.Item
key={opt.value}
disabled={opt.maxRowCount === undefined}
onClick={() => moveSelectedItemsToSection(opt.value, opt.maxRowCount)}
>
<Menu.Item key={opt.value} onClick={() => moveSelectedItemsToSection(opt.value)}>
{opt.label}
</Menu.Item>
))}
+4 -5
View File
@@ -86,7 +86,6 @@ interface BoardItemPlacementRectangle {
const boardItemPlacementTails = new Map<string, Promise<void>>();
const maxManageOverviewPreviewRows = 12;
const manageOverviewInputSchema = z.object({ fullPreview: z.boolean().default(false) }).optional();
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
@@ -226,7 +225,7 @@ export const boardRouter = createTRPCRouter({
isMobileHome: currentUser?.mobileHomeBoardId === board.id,
}));
}),
getManageOverview: publicProcedure.input(manageOverviewInputSchema).query(async ({ ctx, input }) => {
getManageOverview: publicProcedure.query(async ({ ctx }) => {
const userId = ctx.session?.user.id;
const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId);
const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships);
@@ -307,7 +306,7 @@ export const boardRouter = createTRPCRouter({
inArray(itemLayouts.sectionId, rootSectionIds),
gte(itemLayouts.xOffset, 0),
gte(itemLayouts.yOffset, 0),
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
gt(itemLayouts.width, 0),
gt(itemLayouts.height, 0),
),
@@ -332,7 +331,7 @@ export const boardRouter = createTRPCRouter({
inArray(sectionLayouts.parentSectionId, rootSectionIds),
gte(sectionLayouts.xOffset, 0),
gte(sectionLayouts.yOffset, 0),
input?.fullPreview ? undefined : lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
gt(sectionLayouts.width, 0),
gt(sectionLayouts.height, 0),
),
@@ -367,7 +366,7 @@ export const boardRouter = createTRPCRouter({
inArray(itemLayouts.sectionId, previewContainerSectionIds),
gte(itemLayouts.xOffset, 0),
gte(itemLayouts.yOffset, 0),
input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
gt(itemLayouts.width, 0),
gt(itemLayouts.height, 0),
),
+1 -5
View File
@@ -337,7 +337,7 @@ describe("getManageOverview", () => {
expect(overview.map((board) => board.id)).not.toContain(hiddenBoardId);
});
test("returns compact previews by default and full previews on request", async () => {
test("returns one compact base-layout preview for each accessible board", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const boardId = createId();
@@ -487,10 +487,6 @@ describe("getManageOverview", () => {
expect(result[0]?.preview?.layouts).toHaveLength(1);
expect(result[0]?.preview?.items.map((item) => item.id)).toEqual([itemId, nestedItemId]);
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 () => {
+61 -213
View File
@@ -514,9 +514,6 @@ const seedDefaultBoardAsync = async (db: Database) => {
interface DemoWidget {
kind: WidgetKind;
section?: "right";
xOffset: number;
yOffset: number;
width: number;
height: number;
needsIntegration: boolean;
@@ -565,9 +562,9 @@ const demoApps = [
href: "https://portainer.io",
},
{
name: "Uptime Kuma",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/uptime-kuma.svg",
href: "https://uptime.kuma.pet",
name: "Home Assistant",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg",
href: "https://home-assistant.io",
},
{
name: "Nextcloud",
@@ -586,163 +583,69 @@ const demoApps = [
},
] as const;
const buildDemoWidgets = (appIds: string[], customWidgetDefinitionId: string): DemoWidget[] => [
// Daily focus
{ kind: "calendar", xOffset: 0, yOffset: 0, width: 2, height: 2, needsIntegration: true },
const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [
// Row 1: calendar + downloads + clock = 12
{ kind: "calendar", width: 5, height: 3, needsIntegration: true },
{ kind: "downloads", width: 5, height: 3, needsIntegration: true },
{ kind: "clock", width: 2, height: 1, needsIntegration: false },
// Row 2: healthMonitoring + bookmarks = 12
{ kind: "healthMonitoring", width: 6, height: 3, needsIntegration: true },
{
kind: "weather",
xOffset: 2,
yOffset: 0,
width: 2,
height: 2,
needsIntegration: false,
options: {
location: { name: "Paris", latitude: 48.85341, longitude: 2.3488 },
hasForecast: false,
showHumidity: false,
showCurrentWindSpeed: false,
showCity: true,
animateIcons: false,
},
},
{
kind: "clock",
xOffset: 4,
yOffset: 0,
width: 1,
height: 2,
needsIntegration: false,
options: { customTitleToggle: false, showDate: false, customTimeFormat: "HH:mm" },
},
{
kind: "timer",
xOffset: 5,
yOffset: 0,
width: 2,
height: 1,
needsIntegration: false,
options: {
mode: "pomodoro",
focusMinutes: 25,
shortBreakMinutes: 5,
longBreakMinutes: 15,
sessionsBeforeLongBreak: 4,
autoStartBreaks: false,
autoStartFocus: false,
},
},
{
kind: "airQuality",
xOffset: 5,
yOffset: 1,
width: 2,
height: 1,
needsIntegration: false,
options: {
location: { name: "Paris", latitude: 48.85341, longitude: 2.3488 },
aqiStandard: "european",
showUv: false,
showPollutants: false,
showPollen: false,
},
},
{ kind: "downloads", xOffset: 7, yOffset: 0, width: 5, height: 2, needsIntegration: true },
// Homarr workspace
{
kind: "notebook",
xOffset: 0,
yOffset: 2,
width: 5,
kind: "bookmarks",
width: 6,
height: 3,
needsIntegration: false,
options: {
showToolbar: false,
allowReadOnlyCheck: true,
content: `<p style="text-align: center"><img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr-wordmark-light.svg" width="28%"></p><h2 style="text-align: center">Quick runbook</h2><p style="text-align: center">Keep today close: signals first, controls beside them, distractions out.</p><ul data-type="taskList"><li data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p>Backups verified</p></div></li><li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Review active alerts</p></div></li><li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Connect your everyday services</p></div></li><li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Tune this board to your routine</p></div></li></ul><p style="text-align: center"><em>Make it yours. Keep it useful.</em></p>`,
},
options: { title: "Homelab", items: appIds, layout: "grid", openNewTab: true },
},
{ kind: "beszelSystemGrid", xOffset: 5, yOffset: 2, width: 4, height: 3, needsIntegration: true },
{ kind: "assistant", xOffset: 9, yOffset: 2, width: 3, height: 3, needsIntegration: false },
// Operations center
{ kind: "mediaRequests-requestList", xOffset: 0, yOffset: 5, width: 3, height: 2, needsIntegration: true },
{ kind: "mediaMissing", xOffset: 9, yOffset: 5, width: 3, height: 2, needsIntegration: true },
{ kind: "mediaServer", xOffset: 0, yOffset: 7, width: 3, height: 2, needsIntegration: true },
{ kind: "mediaRequests-requestStats", xOffset: 9, yOffset: 7, width: 3, height: 2, needsIntegration: true },
// Row 3: mediaServer + mediaTranscoding + dnsHoleSummary = 12
{ kind: "mediaServer", width: 3, height: 2, needsIntegration: true },
{ kind: "mediaTranscoding", width: 3, height: 2, needsIntegration: true },
{ kind: "dnsHoleSummary", width: 6, height: 2, needsIntegration: true },
// Row 4: dnsHoleControls + mediaReleases + notifications = 12
{ kind: "dnsHoleControls", width: 2, height: 1, needsIntegration: true },
{ kind: "mediaReleases", width: 4, height: 2, needsIntegration: true },
{ kind: "notifications", width: 6, height: 2, needsIntegration: true },
// Row 5: requestList + requestStats + indexerManager = 12
{ kind: "mediaRequests-requestList", width: 4, height: 3, needsIntegration: true },
{ kind: "mediaRequests-requestStats", width: 4, height: 2, needsIntegration: true },
{ kind: "indexerManager", width: 4, height: 2, needsIntegration: true },
// Row 6: networkControllerSummary + networkControllerStatus + rssFeed = 12
{ kind: "networkControllerSummary", width: 3, height: 2, needsIntegration: true },
{ kind: "networkControllerStatus", width: 3, height: 2, needsIntegration: true },
{
kind: "rssFeed",
xOffset: 0,
yOffset: 9,
width: 3,
width: 6,
height: 2,
needsIntegration: false,
options: {
feedUrls: ["https://selfh.st/rss/", "https://hnrss.org/newest?q=self-hosted"],
maximumAmountPosts: 12,
maximumAmountPosts: 20,
textLinesClamp: 2,
hideDescription: true,
hideDescription: false,
},
},
{ kind: "indexerManager", xOffset: 9, yOffset: 9, width: 3, height: 2, needsIntegration: true },
// Infrastructure and activity
{ kind: "dockerContainers", xOffset: 0, yOffset: 11, width: 4, height: 3, needsIntegration: false },
{ kind: "mediaReleases", xOffset: 4, yOffset: 11, width: 4, height: 3, needsIntegration: true },
// Community Workshop
{
kind: "customApi",
xOffset: 8,
yOffset: 11,
width: 4,
height: 3,
needsIntegration: false,
options: { definitionId: customWidgetDefinitionId, refreshInterval: 300 },
},
// Network and health
{
kind: "healthMonitoring",
xOffset: 3,
yOffset: 5,
width: 6,
height: 2,
needsIntegration: true,
},
{
kind: "dnsHoleSummary",
xOffset: 3,
yOffset: 7,
width: 3,
height: 2,
needsIntegration: true,
options: { layout: "grid", usePiHoleColors: false },
},
{
kind: "beszelSystemStats",
xOffset: 6,
yOffset: 7,
width: 3,
height: 2,
needsIntegration: true,
},
{ kind: "notifications", xOffset: 3, yOffset: 9, width: 2, height: 2, needsIntegration: true },
{ kind: "beszelAlerts", xOffset: 5, yOffset: 9, width: 4, height: 2, needsIntegration: true },
// Right app rail
// Row 7-8: app widgets (2x1 each, 6 per row = 12)
...appIds.map(
(appId, index): DemoWidget => ({
(appId): DemoWidget => ({
kind: "app",
section: "right",
xOffset: 0,
yOffset: index,
width: 1,
width: 2,
height: 1,
needsIntegration: false,
options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false },
}),
),
// Row 9: beszelSystemGrid + beszelAlerts = 12
{ kind: "beszelSystemGrid", width: 8, height: 3, needsIntegration: true },
{ kind: "beszelAlerts", width: 4, height: 3, needsIntegration: true },
// Row 10: beszelSystemTable + beszelSystemStats = 12
{ kind: "beszelSystemTable", width: 6, height: 3, needsIntegration: true },
{ kind: "beszelSystemStats", width: 6, height: 4, needsIntegration: true },
// Row 11: notebook + dockerContainers + weather = 12
{ kind: "notebook", width: 4, height: 4, needsIntegration: false },
{ kind: "dockerContainers", width: 6, height: 2, needsIntegration: false },
{ kind: "weather", width: 2, height: 1, needsIntegration: false },
// Row 12: assistant
{ kind: "assistant", width: 3, height: 3, needsIntegration: false },
];
const seedDemoUserAsync = async (db: Database) => {
@@ -807,100 +710,44 @@ const seedDemoUserAsync = async (db: Database) => {
});
}
const customWidgetDefinitionId = createId();
const customWidgetDefinition = customWidgetDefinitionSchema.parse({
$schema: "homarr-custom-widget-v2",
name: "Community Workshop",
description: "Live Custom Widget and Custom CSS submissions shared by the Homarr community.",
sources: {
default: {
name: "Homarr Workshop",
baseUrl: "https://v2.preview.homarr.dev",
networkScope: "public",
auth: "none",
},
},
requests: {
workshop: {
path: "/api/collections/workshop_listings/records",
query: { perPage: 50 },
cacheSeconds: 300,
},
},
options: {},
template: `<Stack p="md" gap="xs" h="100%">
<Group justify="space-between" wrap="nowrap">
<Stack gap={0}><Text size="xs" c="indigo" fw={700}>COMMUNITY WORKSHOP</Text><Title order={3}>Made by Homarr users</Title></Stack>
<Badge color="indigo" variant="light">{data.workshop?.totalItems ?? "—"} shared</Badge>
</Group>
<Text size="xs" c="dimmed">Build Custom Widgets or dashboard CSS, then share it with the community.</Text>
{status.workshop?.loading ? <Stack gap="xs"><Skeleton height={26} radius="sm" /><Skeleton height={26} radius="sm" /><Skeleton height={26} radius="sm" /></Stack> : status.workshop?.error ? <Stack gap="xs"><Alert color="red" title="The Workshop could not be loaded">{status.workshop.error}</Alert><RefreshButton /></Stack> : (data.workshop?.items ?? []).length === 0 ? <Stack gap="xs"><Alert color="gray" title="Nothing shared yet">The first community submission could be yours.</Alert><RefreshButton /></Stack> : <Stack gap={4} style={{ flex: 1 }}>{(data.workshop?.items ?? []).slice(0, 4).map(submission => <Group key={submission.id} justify="space-between" wrap="nowrap"><Text size="sm" fw={600} lineClamp={1}>{submission.title}</Text><Group gap="xs" wrap="nowrap"><Text size="xs" c="dimmed">@{submission.authorName ?? "community"}</Text><Badge size="xs" color="gray" variant="light">{submission.upvotes ?? 0} ↑</Badge></Group></Group>)}{data.workshop?.totalItems > 4 ? <Text size="xs" c="dimmed">and {data.workshop.totalItems - 4} more in the Workshop</Text> : null}</Stack>}
<Anchor href="https://homarr.dev/docs/getting-started" target="_blank" rel="noreferrer" bg="indigo" c="white" fw={700} px="md" py="xs" radius="md" underline="never">Install Homarr now →</Anchor>
</Stack>`,
});
await db.insert(customWidgetDefinitions).values({
id: customWidgetDefinitionId,
name: customWidgetDefinition.name,
description: customWidgetDefinition.description ?? null,
iconUrl: null,
sources: SuperJSON.stringify(customWidgetDefinition.sources),
requests: SuperJSON.stringify(customWidgetDefinition.requests),
options: SuperJSON.stringify(customWidgetDefinition.options),
template: customWidgetDefinition.template,
enabled: true,
creatorId: userId,
});
const boardId = createId();
await db.insert(boards).values({
id: boardId,
name: "default",
isPublic: false,
creatorId: userId,
pageTitle: "Homarr demo",
backgroundImageUrl: "/images/demo-dashboard-background.svg",
primaryColor: "#748FFC",
secondaryColor: "#3BC9DB",
opacity: 90,
itemRadius: "xl",
});
const mainSectionId = createId();
const sectionId = createId();
await db.insert(sections).values({
id: mainSectionId,
id: sectionId,
kind: "empty",
xOffset: 0,
yOffset: 0,
boardId,
});
const rightSectionId = createId();
await db.insert(sections).values({
id: rightSectionId,
kind: "empty",
xOffset: 1,
yOffset: 0,
boardId,
});
const layoutId = createId();
await db.insert(layouts).values({
id: layoutId,
name: "Base",
columnCount: 13,
rightGutterColumnCount: 1,
columnCount: 12,
breakpoint: 768,
role: "base",
boardId,
});
const demoWidgets = buildDemoWidgets(appIds, customWidgetDefinitionId);
const demoWidgets = buildDemoWidgets(appIds);
let xOffset = 0;
let yOffset = 0;
let rowMaxHeight = 0;
for (const widget of demoWidgets) {
let sectionId = mainSectionId;
if (widget.section === "right") {
sectionId = rightSectionId;
if (xOffset + widget.width > 12) {
xOffset = 0;
yOffset += rowMaxHeight;
rowMaxHeight = 0;
}
rowMaxHeight = Math.max(rowMaxHeight, widget.height);
const itemId = createId();
await db.insert(items).values({
id: itemId,
@@ -912,8 +759,8 @@ const seedDemoUserAsync = async (db: Database) => {
itemId,
sectionId,
layoutId,
xOffset: widget.xOffset,
yOffset: widget.yOffset,
xOffset,
yOffset,
width: widget.width,
height: widget.height,
});
@@ -923,6 +770,7 @@ const seedDemoUserAsync = async (db: Database) => {
integrationId,
});
}
xOffset += widget.width;
}
await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId));
-163
View File
@@ -45,169 +45,6 @@ test("SQLite migrations seed the five disabled bundled custom widgets", async ()
connection.close();
});
test("SQLite migrations seed the redesigned demo dashboard", async () => {
const previousDemoMode = process.env.DEMO_MODE;
process.env.DEMO_MODE = "true";
const connection = new BetterSqlite3(":memory:");
const database = drizzle(connection, { schema: sqliteSchema, casing: DB_CASING });
try {
migrate(database, { migrationsFolder: path.join(__dirname, "..", "migrations", "sqlite") });
await seedDataAsync(database as unknown as Database);
const demoUser = await database.query.users.findFirst({
where: (table, { eq }) => eq(table.name, "demo"),
});
if (!demoUser?.homeBoardId) throw new Error("Demo user or home board was not seeded");
const board = await database.query.boards.findFirst({
where: (table, { eq }) => eq(table.id, demoUser.homeBoardId ?? ""),
with: {
layouts: true,
sections: { with: { collapseStates: true, layouts: true } },
items: { with: { layouts: true } },
},
});
if (!board) throw new Error("Demo board was not seeded");
expect(board).toMatchObject({
pageTitle: "Homarr demo",
backgroundImageUrl: "/images/demo-dashboard-background.svg",
});
expect(board.layouts).toHaveLength(2);
expect(board.layouts).toEqual(
expect.arrayContaining([
expect.objectContaining({ columnCount: 13, rightGutterColumnCount: 1, role: "base" }),
expect.objectContaining({ columnCount: 3, rightGutterColumnCount: 0, role: "mobile" }),
]),
);
const baseLayout = board.layouts.find((layout) => layout.role === "base");
if (!baseLayout) throw new Error("Demo base layout was not seeded");
const mainSection = board.sections.find((section) => section.kind === "empty" && section.xOffset === 0);
const rightSection = board.sections.find((section) => section.kind === "empty" && section.xOffset === 1);
if (!mainSection || !rightSection) throw new Error("Demo board sections were not seeded");
expect(board.sections.some((section) => section.kind === "container")).toBe(false);
const itemByKind = (kind: string) => board.items.find((item) => item.kind === kind);
const expectItemLayout = (
kind: string,
xOffset: number,
yOffset: number,
width: number,
height: number,
sectionId = mainSection.id,
) => {
const layout = itemByKind(kind)?.layouts.find((candidate) => candidate.layoutId === baseLayout.id);
expect(layout).toMatchObject({ sectionId, xOffset, yOffset, width, height });
};
expectItemLayout("calendar", 0, 0, 2, 2);
expectItemLayout("weather", 2, 0, 2, 2);
expectItemLayout("clock", 4, 0, 1, 2);
expectItemLayout("timer", 5, 0, 2, 1);
expectItemLayout("airQuality", 5, 1, 2, 1);
expectItemLayout("downloads", 7, 0, 5, 2);
expectItemLayout("notebook", 0, 2, 5, 3);
expectItemLayout("beszelSystemGrid", 5, 2, 4, 3);
expectItemLayout("assistant", 9, 2, 3, 3);
expectItemLayout("mediaRequests-requestList", 0, 5, 3, 2);
expectItemLayout("mediaMissing", 9, 5, 3, 2);
expectItemLayout("mediaServer", 0, 7, 3, 2);
expectItemLayout("mediaRequests-requestStats", 9, 7, 3, 2);
expectItemLayout("rssFeed", 0, 9, 3, 2);
expectItemLayout("indexerManager", 9, 9, 3, 2);
expectItemLayout("dockerContainers", 0, 11, 4, 3);
expectItemLayout("mediaReleases", 4, 11, 4, 3);
expectItemLayout("customApi", 8, 11, 4, 3);
expectItemLayout("healthMonitoring", 3, 5, 6, 2);
expectItemLayout("dnsHoleSummary", 3, 7, 3, 2);
expectItemLayout("beszelSystemStats", 6, 7, 3, 2);
expectItemLayout("notifications", 3, 9, 2, 2);
expectItemLayout("beszelAlerts", 5, 9, 4, 2);
for (const kind of ["weather", "airQuality"]) {
const item = itemByKind(kind);
if (!item?.options) throw new Error(`Demo ${kind} options were not seeded`);
expect(SuperJSON.parse<{ location: { name: string } }>(item.options).location.name).toBe("Paris");
}
const expectFilledGrid = (
placements: { xOffset: number; yOffset: number; width: number; height: number }[],
columnCount: number,
) => {
const rowCount = Math.max(...placements.map((placement) => placement.yOffset + placement.height));
const cells = Array.from({ length: rowCount }, () => Array<number>(columnCount).fill(0));
for (const placement of placements) {
for (let y = placement.yOffset; y < placement.yOffset + placement.height; y += 1) {
for (let x = placement.xOffset; x < placement.xOffset + placement.width; x += 1) {
const row = cells[y];
if (!row) throw new Error("Demo placement exceeds the grid height");
row[x] = (row[x] ?? 0) + 1;
}
}
}
expect(cells.every((row) => row.length === columnCount && row.every((cell) => cell === 1))).toBe(true);
};
const mainItemLayouts = board.items.flatMap((item) =>
item.layouts.filter((layout) => layout.layoutId === baseLayout.id && layout.sectionId === mainSection.id),
);
expectFilledGrid(mainItemLayouts, 12);
const rightRailItems = board.items.filter((item) =>
item.layouts.some((layout) => layout.layoutId === baseLayout.id && layout.sectionId === rightSection.id),
);
expect(rightRailItems).toHaveLength(12);
const rightRailLayouts = rightRailItems.flatMap((item) =>
item.layouts.filter((layout) => layout.layoutId === baseLayout.id && layout.sectionId === rightSection.id),
);
expect(
rightRailItems.every((item) => {
const layout = item.layouts.find((candidate) => candidate.layoutId === baseLayout.id);
return item.kind === "app" && layout?.width === 1 && layout.height === 1;
}),
).toBe(true);
expect(
rightRailLayouts
.toSorted((first, second) => first.yOffset - second.yOffset)
.map(({ sectionId, xOffset, yOffset, width, height }) => ({
sectionId,
xOffset,
yOffset,
width,
height,
})),
).toEqual(
Array.from({ length: 12 }, (_, yOffset) => ({
sectionId: rightSection.id,
xOffset: 0,
yOffset,
width: 1,
height: 1,
})),
);
expectFilledGrid(rightRailLayouts, 1);
const workshopDefinition = await database.query.customWidgetDefinitions.findFirst({
where: (table, { eq }) => eq(table.creatorId, demoUser.id),
});
if (!workshopDefinition) throw new Error("Demo Workshop definition was not seeded");
expect(workshopDefinition.name).toBe("Community Workshop");
expect(SuperJSON.parse<Record<string, { baseUrl: string }>>(workshopDefinition.sources).default?.baseUrl).toBe(
"https://v2.preview.homarr.dev",
);
const workshopItem = itemByKind("customApi");
if (!workshopItem) throw new Error("Demo Workshop item was not seeded");
expect(SuperJSON.parse<{ definitionId: string }>(workshopItem.options).definitionId).toBe(workshopDefinition.id);
} finally {
connection.close();
if (previousDemoMode === undefined) delete process.env.DEMO_MODE;
else process.env.DEMO_MODE = previousDemoMode;
}
});
test("Custom Widget v2 migration preserves v1 data and board references", () => {
const connection = new BetterSqlite3(":memory:");
const legacyPlacementOptions = SuperJSON.stringify({ definitionId: "legacy-weather", refreshInterval: 30 });
@@ -161,7 +161,7 @@ export interface DefaultWidgetConfig {
export const defaultWidgetConfigs: DefaultWidgetConfig[] = [
{ kind: "clock", width: 2, height: 1 },
{ kind: "weather", width: 2, height: 1, options: { showHumidity: false, showCity: false, hasForecast: false } },
{ kind: "weather", width: 2, height: 1, options: { showCity: true, hasForecast: true, forecastDayCount: 3 } },
{ kind: "bookmarks", width: 2, height: 2, options: { title: "Useful Links", layout: "grid", openNewTab: true } },
];
+10 -55
View File
@@ -2192,51 +2192,8 @@
"layout": {
"label": "Layout",
"option": {
"adaptive": {
"label": "Adaptive"
},
"gridHorizontal": {
"label": "Compact grid"
},
"icons": {
"label": "Icons"
}
}
},
"variant": {
"label": "Appearance",
"option": {
"soft": {
"label": "Soft"
},
"filled": {
"label": "Filled"
},
"outline": {
"label": "Outline"
},
"plain": {
"label": "Plain"
}
}
},
"spacing": {
"label": "Card spacing",
"option": {
"xs": {
"label": "Extra small"
},
"sm": {
"label": "Small"
},
"md": {
"label": "Medium"
},
"lg": {
"label": "Large"
},
"xl": {
"label": "Extra large"
"label": "Grid horizontal"
}
}
},
@@ -2253,21 +2210,17 @@
"label": "Show border"
},
"customUrls": {
"label": "Custom links"
"label": "Custom links",
"description": "Add URLs directly without creating an App first"
},
"items": {
"label": "Bookmarks",
"remove": "Remove bookmark",
"apps": {
"label": "Add bookmarks",
"description": "Paste URLs or find existing Apps. Drag the bookmarks below to reorder them.",
"placeholder": "Paste URLs or search Apps",
"addUrl": "Add this URL",
"hint": "Press Enter to add a URL. Paste comma-separated or one-per-line URLs to add several."
"label": "Apps",
"placeholder": "Select one or more apps"
}
}
},
"empty": "Add a URL or App in the widget settings"
}
},
"archiveTeamWarrior": {
"name": "ArchiveTeam Warrior",
@@ -3059,10 +3012,12 @@
"label": "Animate weather icons"
},
"showHumidity": {
"label": "Show humidity"
"label": "Show humidity",
"description": "Display humidity in current conditions, hourly details and daily forecasts"
},
"showCurrentWindSpeed": {
"label": "Show current wind speed"
"label": "Show current wind speed",
"description": "Only on current weather"
},
"useImperialSpeed": {
"label": "Use mph for windspeed"
+1 -1
View File
@@ -2,7 +2,7 @@ import type { MantineProviderProps } from "@mantine/core";
import { theme } from "./theme";
export { scrollAreaDefaultProps, theme } from "./theme";
export { theme } from "./theme";
export { modalSizeForm, modalSizeSelect } from "./theme/modal";
export * from "./components";
-14
View File
@@ -2,13 +2,6 @@ import { createTheme, rem } from "@mantine/core";
import { modalComponent } from "./theme/modal";
export const scrollAreaDefaultProps = {
type: "hover",
scrollbarSize: 2,
scrollHideDelay: 500,
offsetScrollbars: true,
} as const;
export const theme = createTheme({
primaryColor: "red",
autoContrast: true,
@@ -32,7 +25,6 @@ export const theme = createTheme({
},
fontSizes: {
xxs: rem(11),
xs: rem(12),
sm: rem(14),
md: rem(16),
@@ -103,12 +95,6 @@ export const theme = createTheme({
style: { borderRadius: 5 },
},
},
ScrollArea: {
defaultProps: scrollAreaDefaultProps,
},
ScrollAreaAutosize: {
defaultProps: scrollAreaDefaultProps,
},
LoadingOverlay: {
defaultProps: {
zIndex: 1000,
+1 -1
View File
@@ -16,7 +16,7 @@ export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
boardId?: string;
}
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder" | "remove") => string;
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string;
/**
* Short description why as and unknown convertions are used below:
@@ -324,7 +324,7 @@ export function JsonPathTreePicker({
</Combobox.Header>
)}
<Combobox.Options>
<ScrollArea.Autosize mah={420}>
<ScrollArea.Autosize mah={420} type="scroll">
{!hasData && <Combobox.Empty>{noDataHint}</Combobox.Empty>}
{hasData && visibleNodes.length === 0 && <Combobox.Empty>No matching paths</Combobox.Empty>}
{visibleNodes.map((node) => (
@@ -96,28 +96,6 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
updateItems((currentValues) => [...currentValues, options.uniqueIdentifier(item) as TOptionValue]);
};
const migrateItems = useCallback(
(items: TItem[], optionsPatch: Record<string, unknown>) => {
const migratedEntries = items.map((item) => [options.uniqueIdentifier(item) as TOptionValue, item] as const);
setTempMap((previous) => {
const next = new Map(previous);
for (const [value, item] of migratedEntries) next.set(value, item);
return next;
});
form.setFieldValue("options", (currentOptions) => {
const currentItems = Array.isArray(currentOptions[property])
? (currentOptions[property] as TOptionValue[])
: options.defaultValue;
const nextItems = [...currentItems];
for (const [value] of migratedEntries) {
if (!nextItems.includes(value)) nextItems.push(value);
}
return { ...currentOptions, ...optionsPatch, [property]: nextItems };
});
},
[form, options, property],
);
const removeItem = useCallback(
(value: TOptionValue) => {
updateItems((currentValues) => currentValues.filter((candidate) => candidate !== value));
@@ -134,13 +112,7 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
return (
<Fieldset legend={t("label")}>
<Stack>
<options.addButton
addItem={addItem}
migrateItems={migrateItems}
removeItem={removeItem}
values={values}
initialOptions={initialOptions}
/>
<options.addButton addItem={addItem} removeItem={removeItem} values={values} />
<DndContext
sensors={sensors}
@@ -181,7 +153,6 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
index={index}
item={item}
removeItem={() => removeItem(value)}
removeLabel={t("remove")}
options={options}
/>
);
@@ -206,7 +177,6 @@ interface ItemProps<TItem, TOptionValue extends UniqueIdentifier> {
item: TItem;
index: number;
removeItem: () => void;
removeLabel: string;
options: CommonWidgetInputProps<"sortableItemList">["options"];
}
@@ -215,7 +185,6 @@ const Item = <TItem, TOptionValue extends UniqueIdentifier>({
index,
item,
removeItem,
removeLabel,
options,
}: ItemProps<TItem, TOptionValue>) => {
const { attributes, isDragging, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({
@@ -251,7 +220,6 @@ const Item = <TItem, TOptionValue extends UniqueIdentifier>({
key={index}
item={item}
removeItem={removeItem}
removeLabel={removeLabel}
rootAttributes={attributes}
handle={
<ActionIcon
@@ -177,7 +177,7 @@ const AdvancedAirQuality = ({ airQuality, options, width }: AirQualityViewProps
];
return (
<ScrollArea h="100%" w="100%">
<ScrollArea h="100%" w="100%" type="auto" offsetScrollbars scrollbarSize={6}>
<Stack gap="md" p="md">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={2}>
@@ -82,7 +82,7 @@ const ArchiveTeamWarriorWidgetContent = ({
</Group>
{(isAdvanced || options.showBroadcastMessage) && status.broadcastMessage && layout.showBroadcast && (
<Card withBorder p="xs" bg="transparent" style={{ borderColor: neutralSurfaceBorder }}>
<Card withBorder p="xs">
<Text size="xs" lineClamp={isAdvanced ? undefined : 3}>
{status.broadcastMessage}
</Text>
@@ -143,13 +143,7 @@ const ArchiveTeamWarriorWidgetContent = ({
{status.items.map((item) => {
const itemStatusKey = getStatusKey(item.status);
return (
<Card
key={item.id}
withBorder
p="xs"
bg="transparent"
style={{ borderColor: neutralSurfaceBorder }}
>
<Card key={item.id} withBorder p="xs">
<Group justify="space-between" wrap="nowrap">
<Stack gap={0} miw={0}>
<Text size="sm" fw={600} lineClamp={1}>
@@ -207,9 +201,6 @@ export const getArchiveCompactLayout = (width: number, height: number, isAdvance
const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0));
const neutralSurfaceBorder =
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
type WarriorStatusKey = "running" | "completed" | "failed" | "canceled" | "stopped" | "idle" | "unknown";
const getStatusKey = (status?: string | null): WarriorStatusKey => {
+1 -1
View File
@@ -17,7 +17,7 @@ export default function AssistantWidget(props: WidgetComponentProps<"assistant">
return (
<Center h="100%" p="md">
<Stack align="center" gap="xs" ta="center">
<ThemeIcon variant="light" radius="xl" size="lg">
<ThemeIcon variant="light" color="red" radius="xl" size="lg">
<IconRobot size="var(--mantine-font-size-xl)" />
</ThemeIcon>
<Text size="sm" fw={600}>
@@ -40,7 +40,7 @@
gap: clamp(2px, 1.5cqi, 4px);
padding: clamp(4px, 2cqi, 8px);
border-radius: var(--mantine-radius-md);
background-color: transparent;
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
overflow: hidden;
}
@@ -29,7 +29,7 @@
gap: clamp(2px, 1.5cqi, 5px);
padding: clamp(4px, 2.5cqi, 10px);
border-radius: var(--mantine-radius-md);
background-color: transparent;
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
overflow: hidden;
}
@@ -63,11 +63,7 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap =
}}
>
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress
value={system.disk}
color={thresholdColor(system.disk)}
size={trackSize}
/>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
<Group
gap={dotGap}
wrap="nowrap"
@@ -105,12 +101,7 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap =
<Text size="sm" fw={600} miw={48} lh={1.25}>
{formatPercent(value)}
</Text>
<Progress
value={value}
color={thresholdColor(value)}
size="xs"
style={{ flex: 1 }}
/>
<Progress value={value} color={thresholdColor(value)} size="xs" style={{ flex: 1 }} />
</Group>
</Stack>
))}
@@ -120,11 +111,7 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap =
) : (
<Box style={{ display: "flex", alignItems: "center", flex: 1, minWidth: 24 }}>
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress
value={system.disk}
color={thresholdColor(system.disk)}
size={trackSize}
/>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
</Box>
</Box>
)}
+53 -172
View File
@@ -1,198 +1,79 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Avatar,
Combobox,
Group,
Loader,
PillsInput,
ScrollArea,
Stack,
Text,
ThemeIcon,
useCombobox,
} from "@mantine/core";
import { IconCheck, IconLink, IconPlus } from "@tabler/icons-react";
import type { MultiSelectProps } from "@mantine/core";
import { Group, Loader, MultiSelect } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { SortableItemListInput } from "../options";
import { createDirectBookmark, getBookmarkFaviconUrl, splitBookmarkUrls } from "./bookmark-item";
import type { BookmarkItem } from "./bookmark-item";
type SelectableApp = RouterOutputs["app"]["selectable"][number];
export type BookmarkSelectionItem = SelectableApp | BookmarkItem;
const createOptionValue = "$create-url";
export const BookmarkAddButton: SortableItemListInput<BookmarkSelectionItem, string>["AddButton"] = ({
export const BookmarkAddButton: SortableItemListInput<SelectableApp, string>["AddButton"] = ({
addItem,
migrateItems,
removeItem,
values,
initialOptions,
}) => {
const t = useI18n("widget.bookmarks.option.items.apps");
const tCommon = useI18n("common");
const tSelect = useI18n("app.action.select");
const { data: apps = [], isPending, error } = clientApi.app.selectable.useQuery();
const [search, setSearch] = useState("");
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
});
const selectedValues = useMemo(() => new Set(values), [values]);
const migrationStartedRef = useRef(false);
const legacyUrls = useMemo(() => {
if (!Array.isArray(initialOptions.customUrls)) return [];
return initialOptions.customUrls.filter((value): value is string => typeof value === "string");
}, [initialOptions.customUrls]);
const appsById = new Map(apps.map((app) => [app.id, app]));
useEffect(() => {
if (migrationStartedRef.current || legacyUrls.length === 0) return;
migrationStartedRef.current = true;
const legacyItems = legacyUrls.flatMap((url) => createDirectBookmark(url) ?? []);
migrateItems(legacyItems, { customUrls: [] });
}, [legacyUrls, migrateItems]);
const normalizedSearch = search.trim().toLowerCase();
const filteredApps = apps.filter((app) => {
if (selectedValues.has(app.id)) return false;
if (normalizedSearch.length === 0) return true;
return app.name.toLowerCase().includes(normalizedSearch) || app.href?.toLowerCase().includes(normalizedSearch);
});
const pendingUrl = createDirectBookmark(search);
const addUrls = (input: string) => {
const nextValues = new Set(values);
for (const value of splitBookmarkUrls(input)) {
const item = createDirectBookmark(value);
if (!item || nextValues.has(item.id)) continue;
addItem(item);
nextValues.add(item.id);
}
setSearch("");
combobox.closeDropdown();
};
const handleOptionSubmit = (value: string) => {
if (value === createOptionValue) {
addUrls(search);
return;
const handleChange = (nextValues: string[]) => {
const nextIds = new Set(nextValues);
for (const value of values) {
if (!nextIds.has(value)) removeItem(value);
}
const app = apps.find((candidate) => candidate.id === value);
if (app) addItem(app);
setSearch("");
const currentIds = new Set(values);
for (const value of nextValues) {
if (currentIds.has(value)) continue;
const app = appsById.get(value);
if (app) addItem(app);
}
};
const options = filteredApps.map((app) => (
<Combobox.Option value={app.id} key={app.id}>
<Group gap="sm" wrap="nowrap">
<Avatar src={app.iconUrl} name={app.name} color="gray" size={28} radius="sm">
<IconLink size={16} />
</Avatar>
<Stack gap={0} miw={0} flex={1}>
<Text size="sm" fw={600} truncate>
{app.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{app.href}
</Text>
</Stack>
<IconPlus size={16} aria-hidden />
</Group>
</Combobox.Option>
));
return (
<Combobox
store={combobox}
onOptionSubmit={handleOptionSubmit}
position="bottom-start"
middlewares={{ flip: true, shift: true }}
styles={{
dropdown: { overflow: "hidden" },
option: { borderRadius: "var(--mantine-radius-sm)" },
}}
>
<Combobox.DropdownTarget>
<PillsInput
label={t("label")}
description={t("description")}
error={error ? tCommon("error") : undefined}
onClick={() => combobox.openDropdown()}
leftSection={
<ThemeIcon variant="light" size="sm" radius="xl">
<IconLink size={14} />
</ThemeIcon>
}
rightSection={isPending ? <Loader size="xs" /> : undefined}
styles={{ input: { minHeight: 44 } }}
>
<Combobox.EventsTarget>
<PillsInput.Field
value={search}
placeholder={t("placeholder")}
onFocus={() => combobox.openDropdown()}
onBlur={() => combobox.closeDropdown()}
onChange={(event) => {
setSearch(event.currentTarget.value);
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
}}
onPaste={(event) => {
const pastedValue = event.clipboardData.getData("text");
if (splitBookmarkUrls(pastedValue).length < 2) return;
event.preventDefault();
addUrls(pastedValue);
}}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
if (!pendingUrl) return;
event.preventDefault();
addUrls(search);
}}
/>
</Combobox.EventsTarget>
</PillsInput>
</Combobox.DropdownTarget>
<Combobox.Dropdown>
<ScrollArea.Autosize mah={280}>
<Combobox.Options>
{pendingUrl && !selectedValues.has(pendingUrl.id) ? (
<Combobox.Option value={createOptionValue}>
<Group gap="sm" wrap="nowrap">
<Avatar src={getBookmarkFaviconUrl(pendingUrl.href)} size={28} radius="sm" color="gray">
<IconLink size={16} />
</Avatar>
<Stack gap={0} miw={0} flex={1}>
<Text size="sm" fw={600} truncate>
{t("addUrl")}
</Text>
<Text size="xs" c="dimmed" truncate>
{pendingUrl.href}
</Text>
</Stack>
<IconCheck size={16} aria-hidden />
</Group>
</Combobox.Option>
) : null}
{options}
{!pendingUrl && options.length === 0 ? <Combobox.Empty>{tSelect("notFound")}</Combobox.Empty> : null}
</Combobox.Options>
</ScrollArea.Autosize>
<Combobox.Footer>
<Text size="xs" c="dimmed">
{t("hint")}
</Text>
</Combobox.Footer>
</Combobox.Dropdown>
</Combobox>
<MultiSelect
label={t("label")}
placeholder={t("placeholder")}
value={values}
onChange={handleChange}
searchable
clearable
clearSearchOnChange
hidePickedOptions
disabled={isPending || Boolean(error)}
error={error ? tCommon("error") : undefined}
rightSection={isPending ? <Loader size="xs" /> : undefined}
nothingFoundMessage={tSelect("notFound")}
renderOption={renderAppOption}
data={apps.map((app) => ({
value: app.id,
label: app.name,
iconUrl: app.iconUrl,
}))}
/>
);
};
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: 18,
};
const renderAppOption: MultiSelectProps["renderOption"] = ({ option, checked }) => (
<Group flex="1" gap="xs">
{"iconUrl" in option && typeof option.iconUrl === "string" ? (
<img width={20} height={20} src={option.iconUrl} alt="" />
) : null}
{option.label}
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
</Group>
);
@@ -1,66 +0,0 @@
import { getSafeApplicationUrl } from "../common/application-url";
export const directBookmarkPrefix = "url:";
export interface BookmarkItem {
id: string;
name: string;
description: string | null;
iconUrl?: string;
href: string | null;
}
export const normalizeBookmarkUrl = (value: string): string | undefined => {
const trimmedValue = value.trim();
if (trimmedValue.length === 0) return undefined;
const directUrl = getSafeApplicationUrl(trimmedValue);
if (directUrl) return directUrl;
if (/^(?:localhost(?::\d+)?|(?:[\w-]+\.)+[\w-]+(?::\d+)?)(?:[/?#].*)?$/i.test(trimmedValue)) {
return getSafeApplicationUrl(`https://${trimmedValue}`);
}
return undefined;
};
export const getDirectBookmarkValue = (url: string) => `${directBookmarkPrefix}${url}`;
export const getDirectBookmarkUrl = (value: string): string | undefined => {
if (!value.startsWith(directBookmarkPrefix)) return undefined;
return normalizeBookmarkUrl(value.slice(directBookmarkPrefix.length));
};
export const getBookmarkFaviconUrl = (href: string | null): string | undefined => {
const safeUrl = getSafeApplicationUrl(href);
if (!safeUrl) return undefined;
const url = new URL(safeUrl);
return `${url.origin}/favicon.ico`;
};
export const createDirectBookmark = (url: string): BookmarkItem | undefined => {
const href = normalizeBookmarkUrl(url);
if (!href) return undefined;
const parsedUrl = new URL(href);
return {
id: getDirectBookmarkValue(href),
name: parsedUrl.hostname,
description: null,
href,
};
};
export const splitBookmarkUrls = (value: string) =>
value.split(/\r?\n+/).flatMap((line) => {
const trimmedLine = line.trim();
if (trimmedLine.length === 0) return [];
const commaSeparatedValues = trimmedLine.split(",").map((part) => part.trim());
if (commaSeparatedValues.length > 1 && commaSeparatedValues.every((part) => normalizeBookmarkUrl(part))) {
return commaSeparatedValues;
}
return [trimmedLine];
});
@@ -0,0 +1,25 @@
.card:hover {
background-color: var(--mantine-color-primaryColor-light-hover);
}
.bookmarkButton:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
border-radius: var(--mantine-radius-sm);
}
[data-mantine-color-scheme="light"] .card-grid {
background-color: var(--mantine-color-gray-1);
}
[data-mantine-color-scheme="dark"] .card-grid {
background-color: var(--mantine-color-dark-7);
}
.card:hover > div > div.bookmarkIcon {
background-color: var(--mantine-color-iconColor-filled-hover);
}
.bookmarkButton:focus-visible .bookmarkIcon {
background-color: var(--mantine-color-iconColor-filled-hover);
}
@@ -1,364 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import {
Avatar,
Box,
Card,
Center,
Group,
ScrollArea,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useReducedMotion } from "@mantine/hooks";
import { IconArrowUpRight, IconBookmark, IconLink } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData } from "../common/query-state";
import type { WidgetComponentProps } from "../definition";
import { createDirectBookmark, getBookmarkFaviconUrl, getDirectBookmarkUrl } from "./bookmark-item";
import type { BookmarkItem } from "./bookmark-item";
import { getBookmarkCardDisplay, getBookmarkDisplayPlan } from "./layout";
import type { BookmarkOrientation } from "./layout";
type BookmarkVariant = WidgetComponentProps<"bookmarks">["options"]["variant"];
export default function BookmarksWidget({
options,
itemId,
width,
height,
displayMode,
}: WidgetComponentProps<"bookmarks">) {
const t = useI18n("widget.bookmarks");
const board = useRequiredBoard();
const advanced = displayMode === "advanced";
const appIds = useMemo(() => options.items.filter((value) => !getDirectBookmarkUrl(value)), [options.items]);
const apps =
getUsableWidgetQueryData(
clientApi.app.byIds.useQuery(appIds, {
select(selectedApps) {
return selectedApps;
},
}),
) ?? [];
const appsById = new Map(apps.map((app) => [app.id, app]));
const configuredItems = options.items.flatMap((value) => {
const directUrl = getDirectBookmarkUrl(value);
if (directUrl) return createDirectBookmark(directUrl) ?? [];
return appsById.get(value) ?? [];
});
const configuredHrefs = new Set(configuredItems.map((item) => item.href));
const legacyItems = options.customUrls.flatMap((url) => {
const item = createDirectBookmark(url);
if (!item || configuredHrefs.has(item.href)) return [];
return [item];
});
const data = [...configuredItems, ...legacyItems];
useRegisterSpotlightContextResults(
`bookmark-${itemId}`,
data.flatMap((bookmark) => {
const href = getSafeAppHref(bookmark.href);
if (!href) return [];
return [
{
id: bookmark.id,
name: bookmark.name,
icon: bookmark.iconUrl ?? IconLink,
interaction() {
return {
type: "link" as const,
href,
newTab: false,
};
},
},
];
}),
[data],
);
const plan = useMemo(
() =>
getBookmarkDisplayPlan({
advanced,
height: options.title.length > 0 ? height - 36 : height,
itemCount: data.length,
layout: options.layout,
width,
}),
[advanced, data.length, height, options.layout, options.title.length, width],
);
const cardDisplay = getBookmarkCardDisplay({
advanced,
hideHostname: options.hideHostname,
hideIcon: options.hideIcon,
hideTitle: options.hideTitle,
plan,
});
const cards = data.map((bookmark) => (
<BookmarkCard
key={bookmark.id}
bookmark={bookmark}
advanced={advanced}
orientation={cardDisplay.orientation}
showHostname={cardDisplay.showHostname}
showIcon={cardDisplay.showIcon}
showTitle={cardDisplay.showTitle}
openNewTab={options.openNewTab}
variant={options.variant}
withBorder={options.withBorder}
radius={board.itemRadius}
height={plan.itemHeight}
width={plan.horizontalScroll ? plan.itemWidth : undefined}
/>
));
return (
<Stack h="100%" mih={0} gap={height < 120 ? 6 : "sm"} p={height < 120 ? 6 : "sm"}>
{options.title.length > 0 ? (
<Text fz={11} fw={600} px={2} lh={1.2} lineClamp={1}>
{options.title}
</Text>
) : null}
{data.length === 0 ? (
<Center flex={1}>
<Stack align="center" gap={6}>
<ThemeIcon variant="light" size="lg" radius="xl">
<IconBookmark size={20} />
</ThemeIcon>
<Text size="sm" c="dimmed" ta="center">
{t("empty")}
</Text>
</Stack>
</Center>
) : (
<ScrollArea
scrollbars={plan.horizontalScroll ? "x" : "y"}
style={{ flex: 1, minHeight: 0 }}
>
<Box miw="100%" mih="100%" pb={2}>
{plan.horizontalScroll ? (
<Group gap={options.spacing} wrap="nowrap" mih="100%" align="stretch">
{cards}
</Group>
) : (
<SimpleGrid cols={plan.columns} spacing={options.spacing} verticalSpacing={options.spacing}>
{cards}
</SimpleGrid>
)}
</Box>
</ScrollArea>
)}
</Stack>
);
}
interface BookmarkCardProps {
advanced: boolean;
bookmark: BookmarkItem;
height: number;
openNewTab: boolean;
orientation: BookmarkOrientation;
radius: string;
showHostname: boolean;
showIcon: boolean;
showTitle: boolean;
variant: BookmarkVariant;
width?: number;
withBorder: boolean;
}
const BookmarkCard = ({
advanced,
bookmark,
height,
openNewTab,
orientation,
radius,
showHostname,
showIcon,
showTitle,
variant,
width,
withBorder,
}: BookmarkCardProps) => {
const [active, setActive] = useState(false);
const reduceMotion = useReducedMotion();
const href = getSafeAppHref(bookmark.href);
const hostname = bookmark.href ? getBookmarkHostname(bookmark.href) : undefined;
const iconUrl = bookmark.iconUrl ?? getBookmarkFaviconUrl(bookmark.href);
const iconOnly = orientation === "icon" || (!showTitle && !showHostname);
const background = getBookmarkBackground(variant, active);
const content =
orientation === "vertical" && !advanced ? (
<Stack h="100%" align="center" justify="center" gap={6} flex={1}>
{showIcon ? <BookmarkAvatar bookmark={bookmark} iconUrl={iconUrl} size={iconOnly ? 34 : 38} /> : null}
{showTitle ? (
<Text size="xxs" fw={600} lh={1.2} ta="center" lineClamp={2}>
{bookmark.name}
</Text>
) : null}
{showHostname ? (
<Text fz={9} c="dimmed" ta="center" truncate w="100%">
{hostname}
</Text>
) : null}
</Stack>
) : (
<Group
h="100%"
gap={advanced ? "sm" : "xs"}
wrap="nowrap"
justify={iconOnly ? "center" : "flex-start"}
align="center"
flex={1}
>
{showIcon ? (
<BookmarkAvatar bookmark={bookmark} iconUrl={iconUrl} size={advanced ? 42 : iconOnly ? 32 : 30} />
) : null}
{!iconOnly ? (
<Stack gap={advanced ? 3 : 0} miw={0} flex={1}>
{showTitle ? (
<Text size={advanced ? "xs" : "xxs"} fw={600} lh={1.2} truncate>
{bookmark.name}
</Text>
) : null}
{showHostname ? (
<Text fz={advanced ? 10 : 9} c="dimmed" truncate>
{hostname}
</Text>
) : null}
{advanced && bookmark.description ? (
<Text fz={10} c="dimmed" lineClamp={2}>
{bookmark.description}
</Text>
) : null}
</Stack>
) : null}
{advanced ? (
<ThemeIcon
variant={active ? "light" : "transparent"}
color={active ? "primaryColor" : "gray"}
size="sm"
radius="xl"
style={{
opacity: active ? 1 : 0.45,
transform: active && !reduceMotion ? "translate(1px, -1px)" : undefined,
transition: reduceMotion ? undefined : "opacity 120ms ease, transform 120ms ease",
}}
>
<IconArrowUpRight size={14} />
</ThemeIcon>
) : null}
</Group>
);
return (
<Tooltip
label={
<Stack gap={0}>
<Text size="xs" fw={600}>
{bookmark.name}
</Text>
{hostname ? (
<Text size="xs" opacity={0.8}>
{hostname}
</Text>
) : null}
</Stack>
}
openDelay={450}
disabled={advanced}
withArrow
>
<Card
component={href ? "a" : "div"}
href={href}
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
aria-label={bookmark.name}
radius={radius}
withBorder={withBorder || variant === "outline"}
p={iconOnly ? 6 : advanced ? "md" : "xs"}
h={height}
w={width}
miw={width}
onMouseEnter={() => setActive(true)}
onMouseLeave={() => setActive(false)}
onFocus={() => setActive(true)}
onBlur={() => setActive(false)}
styles={{
root: {
background,
borderColor: active
? "rgb(from var(--mantine-primary-color-filled) r g b / calc(var(--opacity, 1) * 0.62))"
: "rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38))",
boxShadow: active ? "var(--mantine-shadow-sm)" : undefined,
color: "var(--mantine-color-text)",
cursor: href ? "pointer" : "default",
display: "flex",
outline: active ? "2px solid var(--mantine-primary-color-light)" : undefined,
outlineOffset: -2,
overflow: "hidden",
textDecoration: "none",
transform: active && !reduceMotion ? "translateY(-1px)" : undefined,
transition: reduceMotion
? undefined
: "background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease",
},
}}
>
{content}
</Card>
</Tooltip>
);
};
const BookmarkAvatar = ({ bookmark, iconUrl, size }: { bookmark: BookmarkItem; iconUrl?: string; size: number }) => (
<Avatar
src={iconUrl}
name={bookmark.name}
color="gray"
radius="sm"
size={size}
imageProps={{ referrerPolicy: "no-referrer" }}
styles={{ image: { objectFit: "contain" } }}
>
<IconLink size={Math.max(14, size / 2)} />
</Avatar>
);
const getBookmarkBackground = (variant: BookmarkVariant, active: boolean): string => {
if (variant === "plain" || variant === "outline") return "transparent";
if (variant === "filled") {
const opacity = active ? 0.64 : 0.48;
return `rgb(from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * ${opacity}))`;
}
const opacity = active ? 0.14 : 0.08;
return `rgb(from var(--mantine-primary-color-filled) r g b / calc(var(--opacity, 1) * ${opacity}))`;
};
const getBookmarkHostname = (href: string): string | undefined => {
try {
return new URL(href).hostname || undefined;
} catch {
return undefined;
}
};
+523 -1
View File
@@ -1,3 +1,525 @@
"use client";
export { default } from "./bookmarks-widget";
import type { CSSProperties } from "react";
import { useMemo } from "react";
import {
Anchor,
Box,
Card,
Flex,
Group,
ScrollArea,
SimpleGrid,
Stack,
Text,
Title,
UnstyledButton,
} from "@mantine/core";
import { IconLink } from "@tabler/icons-react";
import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { MaskedOrNormalImage } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import { getSafeApplicationUrl, getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData } from "../common/query-state";
import classes from "./bookmark.module.css";
type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
export interface BookmarkDisplayItem {
id: string;
name: string;
description: string | null;
iconUrl?: string;
href: string | null;
}
export interface CompactBookmarkLayout {
columns: number;
hideHostname: boolean;
hideTitle: boolean;
minimumItemSize: number;
}
export function getCompactBookmarkLayout(
width: number,
height: number,
itemCount: number,
layout: BookmarkLayout,
): CompactBookmarkLayout {
const count = Math.max(1, itemCount);
const isGrid = layout === "grid" || layout === "gridHorizontal";
const targetWidth = layout === "gridHorizontal" ? 180 : 104;
const columns = isGrid ? Math.max(1, Math.min(count, Math.floor(width / targetWidth))) : layout === "row" ? count : 1;
const rows = Math.max(1, Math.ceil(count / columns));
const cellWidth = width / columns;
const cellHeight = height / rows;
const usesHorizontalItems = layout === "column" || layout === "gridHorizontal";
return {
columns,
hideHostname: cellWidth < 120 || cellHeight < (usesHorizontalItems ? 52 : 96),
hideTitle: cellWidth < 64 || cellHeight < 40,
minimumItemSize: usesHorizontalItems ? 48 : 96,
};
}
export default function BookmarksWidget({
options,
itemId,
width,
height,
displayMode,
}: WidgetComponentProps<"bookmarks">) {
const board = useRequiredBoard();
const apps =
getUsableWidgetQueryData(
clientApi.app.byIds.useQuery(options.items, {
select(selectedApps) {
return selectedApps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
},
}),
) ?? [];
const customLinks = useMemo(
() => options.customUrls.flatMap((url) => createCustomBookmark(url) ?? []),
[options.customUrls],
);
const data: BookmarkDisplayItem[] = [...apps, ...customLinks];
useRegisterSpotlightContextResults(
`bookmark-${itemId}`,
data.flatMap((app) => {
const href = getSafeAppHref(app.href);
return href
? [
{
id: app.id,
name: app.name,
icon: app.iconUrl ?? IconLink,
interaction() {
return {
type: "link",
href,
newTab: false,
};
},
},
]
: [];
}),
[data],
);
const compactLayout = useMemo(
() => getCompactBookmarkLayout(width, height, data.length, options.layout),
[width, height, data.length, options.layout],
);
const compactHideTitle = options.hideTitle || (compactLayout.hideTitle && !options.hideIcon);
const compactHideHostname =
options.hideHostname || (compactLayout.hideHostname && !(options.hideTitle && options.hideIcon));
return (
<Stack h="100%" mih={0} gap="sm" p={height < 120 ? "xs" : "sm"}>
{options.title.length > 0 && (
<Title order={4} px="0.25rem" lineClamp={displayMode === "advanced" ? undefined : 1}>
{options.title}
</Title>
)}
{displayMode === "advanced" && (
<AdvancedBookmarksLayout
data={data}
width={width}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
)}
{displayMode !== "advanced" && (
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars style={{ flex: 1, minHeight: 0 }}>
<Box mih="100%">
{options.layout === "grid" || options.layout === "gridHorizontal" ? (
<GridLayout
data={data}
columns={compactLayout.columns}
minimumItemHeight={compactLayout.minimumItemSize}
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
hideTitle={compactHideTitle}
hideIcon={options.hideIcon}
hideHostname={compactHideHostname}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
) : (
<FlexLayout
data={data}
direction={options.layout}
minimumItemSize={compactLayout.minimumItemSize}
hideTitle={compactHideTitle}
hideIcon={options.hideIcon}
hideHostname={compactHideHostname}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
)}
</Box>
</ScrollArea>
)}
</Stack>
);
}
interface FlexLayoutProps {
data: BookmarkDisplayItem[];
direction: "row" | "column";
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
openNewTab: boolean;
withBorder: boolean;
hasIconColor: boolean;
minimumItemSize: number;
}
const FlexLayout = ({
data,
direction,
hideTitle,
hideIcon,
hideHostname,
openNewTab,
withBorder,
hasIconColor,
minimumItemSize,
}: FlexLayoutProps) => {
const board = useRequiredBoard();
return (
<Flex direction={direction} gap={4} w="100%" mih="100%" wrap="nowrap">
{data.map((app) => {
const href = getSafeAppHref(app.href);
return (
<div key={app.id} style={{ display: "flex", flex: `1 0 ${minimumItemSize}px`, flexDirection: direction }}>
<UnstyledButton
className={classes.bookmarkButton}
component={href ? "a" : "div"}
href={href}
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
key={app.id}
w="100%"
>
<Card
radius={board.itemRadius}
className={classes.card}
w="100%"
display="flex"
p={4}
h="100%"
withBorder={withBorder}
>
{direction === "row" ? (
<VerticalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
) : (
<HorizontalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
)}
</Card>
</UnstyledButton>
</div>
);
})}
</Flex>
);
};
interface AdvancedBookmarksLayoutProps {
data: BookmarkDisplayItem[];
width: number;
openNewTab: boolean;
withBorder: boolean;
hasIconColor: boolean;
}
export const getAdvancedBookmarkColumns = (width: number, itemCount: number): number =>
Math.max(1, Math.min(itemCount || 1, Math.floor(width / 280)));
export const getBookmarkHostname = (href: string | null): string | undefined => {
const safeHref = getSafeApplicationUrl(href);
return safeHref ? new URL(safeHref).hostname : undefined;
};
export const createCustomBookmark = (url: string): BookmarkDisplayItem | null => {
const normalizedUrl = url.trim();
const href = getSafeAppHref(normalizedUrl);
if (!href) return null;
return {
id: `custom-link:${normalizedUrl}`,
name: getBookmarkHostname(href) ?? normalizedUrl,
description: null,
href,
};
};
const AdvancedBookmarksLayout = ({
data,
width,
openNewTab,
withBorder,
hasIconColor,
}: AdvancedBookmarksLayoutProps) => {
const board = useRequiredBoard();
return (
<ScrollArea h="100%" style={{ flex: 1 }}>
<SimpleGrid cols={getAdvancedBookmarkColumns(width, data.length)} spacing="sm">
{data.map((app) => {
const href = getSafeAppHref(app.href);
return (
<UnstyledButton
key={app.id}
className={classes.bookmarkButton}
component={href ? "a" : "div"}
href={href}
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
>
<Card radius={board.itemRadius} className={classes.card} withBorder={withBorder} p="md" h="100%">
<Group align="flex-start" wrap="nowrap">
<BookmarkIcon
app={app}
hasColor={hasIconColor}
className={classes.bookmarkIcon}
style={{ width: 40, height: 40, flex: "0 0 auto" }}
/>
<Stack gap={3} miw={0}>
<Text fw={700} size="sm">
{app.name}
</Text>
<Anchor component="span" size="xs" truncate="end">
{getBookmarkHostname(app.href)}
</Anchor>
{app.description && (
<Text size="xs" c="dimmed">
{app.description}
</Text>
)}
</Stack>
</Group>
</Card>
</UnstyledButton>
);
})}
</SimpleGrid>
</ScrollArea>
);
};
interface GridLayoutProps {
data: BookmarkDisplayItem[];
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
openNewTab: boolean;
withBorder: boolean;
itemDirection: "horizontal" | "vertical";
hasIconColor: boolean;
columns: number;
minimumItemHeight: number;
}
const GridLayout = ({
data,
hideTitle,
hideIcon,
hideHostname,
openNewTab,
withBorder,
itemDirection,
hasIconColor,
columns,
minimumItemHeight,
}: GridLayoutProps) => {
const board = useRequiredBoard();
return (
<SimpleGrid
cols={columns}
spacing={4}
verticalSpacing={4}
miw="100%"
mih="100%"
style={{ gridAutoRows: `minmax(${minimumItemHeight}px, 1fr)` }}
>
{data.map((app) => {
const href = getSafeAppHref(app.href);
return (
<UnstyledButton
className={classes.bookmarkButton}
component={href ? "a" : "div"}
href={href}
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
key={app.id}
h="100%"
>
<Card
h="100%"
className={combineClasses(classes.card, classes["card-grid"])}
radius={board.itemRadius}
withBorder={withBorder}
p="xs"
>
{itemDirection === "horizontal" ? (
<HorizontalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
) : (
<VerticalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
)}
</Card>
</UnstyledButton>
);
})}
</SimpleGrid>
);
};
const VerticalItem = ({
app,
hideTitle,
hideIcon,
hideHostname,
hasIconColor,
}: {
app: BookmarkDisplayItem;
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
hasIconColor: boolean;
}) => {
return (
<Stack h="100%" miw={16} gap="sm" justify={"center"}>
{!hideTitle && (
<Text fw={700} ta="center" size="xs" lineClamp={2}>
{app.name}
</Text>
)}
{!hideIcon && (
<BookmarkIcon
app={app}
hasColor={hasIconColor}
className={classes.bookmarkIcon}
style={{
width: hideHostname && hideTitle ? "min(max(100%, 16px), 40px)" : 40,
height: hideHostname && hideTitle ? "min(max(100%, 16px), 40px)" : 40,
overflow: "auto",
flex: "unset",
marginLeft: "auto",
marginRight: "auto",
}}
/>
)}
{!hideHostname && (
<Anchor ta="center" component="span" size="xs" truncate="end" w="100%">
{getBookmarkHostname(app.href)}
</Anchor>
)}
</Stack>
);
};
const HorizontalItem = ({
app,
hideTitle,
hideIcon,
hideHostname,
hasIconColor,
}: {
app: BookmarkDisplayItem;
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
hasIconColor: boolean;
}) => {
return (
<Group wrap="nowrap" gap="xs" h="100%" justify="start">
{!hideIcon && (
<BookmarkIcon
app={app}
hasColor={hasIconColor}
className={classes.bookmarkIcon}
style={{
overflow: "auto",
width: hideHostname ? 16 : 24,
height: hideHostname ? 16 : 24,
flex: "unset",
}}
/>
)}
{!(hideTitle && hideHostname) && (
<>
<Stack justify="space-between" gap={0} miw={0} style={{ flex: 1 }}>
{!hideTitle && (
<Text fw={700} size="xs" lineClamp={hideHostname ? 2 : 1}>
{app.name}
</Text>
)}
{!hideHostname && (
<Anchor component="span" size="xs" truncate="end">
{getBookmarkHostname(app.href)}
</Anchor>
)}
</Stack>
</>
)}
</Group>
);
};
interface BookmarkIconProps {
app: BookmarkDisplayItem;
hasColor: boolean;
className?: string;
style?: CSSProperties;
}
const BookmarkIcon = ({ app, hasColor, className, style }: BookmarkIconProps) =>
app.iconUrl ? (
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={hasColor}
alt={app.name}
className={className}
style={style}
/>
) : (
<IconLink aria-label={app.name} className={className} style={style} />
);
+19 -46
View File
@@ -1,13 +1,14 @@
import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core";
import { IconBookmark, IconLink, IconX } from "@tabler/icons-react";
import { IconBookmark, IconX } from "@tabler/icons-react";
import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getSafeAppHref } from "../common/application-url";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
import { BookmarkAddButton } from "./add-button";
import type { BookmarkSelectionItem } from "./add-button";
import { createDirectBookmark, getBookmarkFaviconUrl, getDirectBookmarkUrl } from "./bookmark-item";
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
icon: IconBookmark,
@@ -17,63 +18,42 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
return optionsBuilder.from((factory) => ({
title: factory.text(),
layout: factory.select({
options: (["adaptive", "column", "row", "grid", "gridHorizontal", "icons"] as const).map((value) => ({
options: (["grid", "gridHorizontal", "row", "column"] as const).map((value) => ({
value,
label: (t) => {
if (value === "adaptive") return t("widget.bookmarks.option.layout.option.adaptive.label");
if (value === "grid") return t("widget.common.layout.option.grid");
if (value === "row") return t("widget.common.layout.option.horizontal");
if (value === "column") return t("widget.common.layout.option.vertical");
if (value === "icons") return t("widget.bookmarks.option.layout.option.icons.label");
return t("widget.bookmarks.option.layout.option.gridHorizontal.label");
},
})),
defaultValue: "adaptive",
}),
variant: factory.select({
options: (["soft", "filled", "outline", "plain"] as const).map((value) => ({
value,
label: (t) => t(`widget.bookmarks.option.variant.option.${value}.label`),
})),
defaultValue: "soft",
}),
spacing: factory.select({
options: (["xs", "sm", "md", "lg", "xl"] as const).map((value) => ({
value,
label: (t) => t(`widget.bookmarks.option.spacing.option.${value}.label`),
})),
defaultValue: "xs",
defaultValue: "column",
}),
hideTitle: factory.switch({ defaultValue: false }),
hideIcon: factory.switch({ defaultValue: false }),
hideHostname: factory.switch({ defaultValue: false }),
openNewTab: factory.switch({ defaultValue: true }),
withBorder: factory.switch({ defaultValue: false }),
customUrls: factory.internal({ defaultValue: [] as string[] }),
items: factory.sortableItemList<BookmarkSelectionItem, string>({
ItemComponent: ({ item, handle, removeItem, removeLabel, rootAttributes }) => {
const iconUrl = item.iconUrl ?? getBookmarkFaviconUrl(item.href);
customUrls: factory.multiText({
validate: z.string().refine((value) => getSafeAppHref(value) !== undefined),
withDescription: true,
}),
items: factory.sortableItemList<RouterOutputs["app"]["selectable"][number], string>({
ItemComponent: ({ item, handle, removeItem, rootAttributes }) => {
return (
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
<Group wrap="nowrap" miw={0}>
<Group wrap="nowrap">
{handle}
<Group wrap="nowrap" miw={0}>
<Avatar src={iconUrl} name={item.name} color="gray" radius="sm">
<IconLink size={20} />
</Avatar>
<Stack gap={0} miw={0}>
<Text fw={600} truncate>
{item.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{item.href}
</Text>
<Group>
<Avatar src={item.iconUrl} alt={item.name} />
<Stack gap={0}>
<Text>{item.name}</Text>
</Stack>
</Group>
</Group>
<ActionIcon variant="subtle" color="red" onClick={removeItem} aria-label={removeLabel}>
<ActionIcon variant="transparent" color="red" onClick={removeItem}>
<IconX size="var(--mantine-font-size-xl)" />
</ActionIcon>
</Group>
@@ -82,14 +62,7 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
AddButton: BookmarkAddButton,
uniqueIdentifier: (item) => item.id,
useData: (initialIds) => {
const appIds = initialIds.filter((value) => !getDirectBookmarkUrl(value));
const { data: apps, error, isLoading } = clientApi.app.byIds.useQuery(appIds);
const appsById = new Map(apps?.map((app) => [app.id, app]));
const data = initialIds.flatMap((value) => {
const directUrl = getDirectBookmarkUrl(value);
if (directUrl) return createDirectBookmark(directUrl) ?? [];
return appsById.get(value) ?? [];
});
const { data, error, isLoading } = clientApi.app.byIds.useQuery(initialIds);
return {
data,
+34 -49
View File
@@ -1,69 +1,54 @@
import { describe, expect, test } from "vitest";
import { createDirectBookmark, normalizeBookmarkUrl, splitBookmarkUrls } from "./bookmark-item";
import { getBookmarkCardDisplay, getBookmarkDisplayPlan } from "./layout";
import {
createCustomBookmark,
getAdvancedBookmarkColumns,
getBookmarkHostname,
getCompactBookmarkLayout,
} from "./component";
describe("bookmark display helpers", () => {
test("uses explicit width breakpoints for advanced columns", () => {
expect(
getBookmarkDisplayPlan({ advanced: true, width: 900, height: 500, itemCount: 8, layout: "adaptive" }),
).toMatchObject({
columns: 3,
orientation: "horizontal",
});
expect(
getBookmarkDisplayPlan({ advanced: true, width: 120, height: 500, itemCount: 8, layout: "adaptive" }),
).toMatchObject({
columns: 1,
orientation: "horizontal",
});
test("uses bounded advanced columns", () => {
expect(getAdvancedBookmarkColumns(900, 8)).toBe(3);
expect(getAdvancedBookmarkColumns(120, 8)).toBe(1);
expect(getAdvancedBookmarkColumns(900, 0)).toBe(1);
});
test("selects an adaptive plan from width and height breakpoints", () => {
expect(
getBookmarkDisplayPlan({ advanced: false, width: 560, height: 260, itemCount: 4, layout: "adaptive" }),
).toMatchObject({
test("uses container geometry to keep compact cards legible", () => {
expect(getCompactBookmarkLayout(420, 220, 4, "gridHorizontal")).toMatchObject({
columns: 2,
itemHeight: 128,
orientation: "horizontal",
showHostname: true,
showTitle: true,
hideHostname: false,
hideTitle: false,
minimumItemSize: 48,
});
expect(
getBookmarkDisplayPlan({ advanced: false, width: 180, height: 80, itemCount: 4, layout: "adaptive" }),
).toMatchObject({
columns: 1,
orientation: "icon",
showHostname: false,
showTitle: false,
expect(getCompactBookmarkLayout(180, 80, 4, "row")).toMatchObject({
columns: 4,
hideHostname: true,
hideTitle: true,
minimumItemSize: 96,
});
});
test("keeps compact cards visible when icons are hidden", () => {
const plan = getBookmarkDisplayPlan({ advanced: false, width: 180, height: 80, itemCount: 4, layout: "icons" });
expect(
getBookmarkCardDisplay({ advanced: false, hideHostname: false, hideIcon: true, hideTitle: false, plan }),
).toEqual({
orientation: "horizontal",
showHostname: false,
showIcon: false,
showTitle: true,
});
test("does not crash on malformed bookmark URLs", () => {
expect(getBookmarkHostname("https://homarr.dev/docs")).toBe("homarr.dev");
expect(getBookmarkHostname("not a URL")).toBeUndefined();
expect(getBookmarkHostname(null)).toBeUndefined();
});
test("creates standalone bookmarks without app records", () => {
expect(createDirectBookmark("https://homarr.dev/docs")).toEqual({
id: "url:https://homarr.dev/docs",
expect(createCustomBookmark("https://homarr.dev/docs")).toEqual({
id: "custom-link:https://homarr.dev/docs",
name: "homarr.dev",
description: null,
href: "https://homarr.dev/docs",
});
expect(normalizeBookmarkUrl("homarr.dev/docs")).toBe("https://homarr.dev/docs");
expect(normalizeBookmarkUrl("javascript:alert(1)")).toBeUndefined();
});
test("splits URL lists without corrupting commas inside a URL", () => {
expect(splitBookmarkUrls("https://one.example, two.example")).toEqual(["https://one.example", "two.example"]);
expect(splitBookmarkUrls("https://maps.example/@52.5,13.4,15z")).toEqual(["https://maps.example/@52.5,13.4,15z"]);
expect(createCustomBookmark(" https://homarr.dev/docs ")).toEqual({
id: "custom-link:https://homarr.dev/docs",
name: "homarr.dev",
description: null,
href: "https://homarr.dev/docs",
});
expect(createCustomBookmark("javascript:alert(1)")).toBeNull();
});
});
-265
View File
@@ -1,265 +0,0 @@
import type { WidgetComponentProps } from "../definition";
export type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
export type BookmarkOrientation = "horizontal" | "vertical" | "icon";
export interface BookmarkDisplayPlan {
columns: number;
horizontalScroll: boolean;
itemHeight: number;
itemWidth: number;
orientation: BookmarkOrientation;
showHostname: boolean;
showTitle: boolean;
}
export interface BookmarkCardDisplay {
orientation: BookmarkOrientation;
showHostname: boolean;
showIcon: boolean;
showTitle: boolean;
}
export const getBookmarkCardDisplay = ({
advanced,
hideHostname,
hideIcon,
hideTitle,
plan,
}: {
advanced: boolean;
hideHostname: boolean;
hideIcon: boolean;
hideTitle: boolean;
plan: BookmarkDisplayPlan;
}): BookmarkCardDisplay => {
let orientation = plan.orientation;
let showTitle = advanced || (!hideTitle && plan.showTitle);
if (!advanced && hideIcon && !hideTitle && !showTitle) {
orientation = "horizontal";
showTitle = true;
}
const showHostname = advanced || (!hideHostname && plan.showHostname);
const showIcon = advanced || !hideIcon || (!showTitle && !showHostname);
return { orientation, showHostname, showIcon, showTitle };
};
interface BookmarkWidthBreakpoint {
adaptiveColumns: number;
adaptiveOrientation: BookmarkOrientation;
advancedColumns: number;
compactColumns: number;
gridColumns: number;
iconColumns: number;
minWidth: number;
rowItemWidth: number;
showHostname: boolean;
showTitle: boolean;
}
const widthBreakpoints = [
{
minWidth: 900,
adaptiveColumns: 4,
advancedColumns: 3,
compactColumns: 4,
gridColumns: 5,
iconColumns: 10,
rowItemWidth: 180,
adaptiveOrientation: "vertical",
showHostname: true,
showTitle: true,
},
{
minWidth: 720,
adaptiveColumns: 3,
advancedColumns: 2,
compactColumns: 3,
gridColumns: 4,
iconColumns: 8,
rowItemWidth: 172,
adaptiveOrientation: "horizontal",
showHostname: true,
showTitle: true,
},
{
minWidth: 480,
adaptiveColumns: 2,
advancedColumns: 2,
compactColumns: 2,
gridColumns: 3,
iconColumns: 6,
rowItemWidth: 160,
adaptiveOrientation: "horizontal",
showHostname: true,
showTitle: true,
},
{
minWidth: 300,
adaptiveColumns: 2,
advancedColumns: 1,
compactColumns: 1,
gridColumns: 2,
iconColumns: 4,
rowItemWidth: 144,
adaptiveOrientation: "horizontal",
showHostname: false,
showTitle: true,
},
{
minWidth: 0,
adaptiveColumns: 1,
advancedColumns: 1,
compactColumns: 1,
gridColumns: 1,
iconColumns: 2,
rowItemWidth: 112,
adaptiveOrientation: "icon",
showHostname: false,
showTitle: false,
},
] as const satisfies readonly BookmarkWidthBreakpoint[];
interface BookmarkHeightBreakpoint {
columnItemHeight: number;
itemHeight: number;
minHeight: number;
orientation: BookmarkOrientation;
rowItemHeight: number;
showHostname: boolean;
showTitle: boolean;
}
const heightBreakpoints = [
{
minHeight: 300,
columnItemHeight: 72,
itemHeight: 128,
rowItemHeight: 128,
orientation: "vertical",
showHostname: true,
showTitle: true,
},
{
minHeight: 180,
columnItemHeight: 64,
itemHeight: 128,
rowItemHeight: 112,
orientation: "horizontal",
showHostname: true,
showTitle: true,
},
{
minHeight: 110,
columnItemHeight: 56,
itemHeight: 80,
rowItemHeight: 80,
orientation: "horizontal",
showHostname: false,
showTitle: true,
},
{
minHeight: 0,
columnItemHeight: 48,
itemHeight: 56,
rowItemHeight: 56,
orientation: "icon",
showHostname: false,
showTitle: false,
},
] as const satisfies readonly BookmarkHeightBreakpoint[];
export const getBookmarkDisplayPlan = ({
advanced,
height,
itemCount,
layout,
width,
}: {
advanced: boolean;
height: number;
itemCount: number;
layout: BookmarkLayout;
width: number;
}): BookmarkDisplayPlan => {
const count = Math.max(1, itemCount);
const normalizedWidth = Math.max(0, width);
const normalizedHeight = Math.max(0, height);
const widthSettings = widthBreakpoints.find(({ minWidth }) => normalizedWidth >= minWidth);
const heightSettings = heightBreakpoints.find(({ minHeight }) => normalizedHeight >= minHeight);
if (!widthSettings || !heightSettings) throw new Error("Bookmark breakpoints must cover all widget sizes");
if (advanced) {
return {
columns: Math.min(count, widthSettings.advancedColumns),
horizontalScroll: false,
itemHeight: 104,
itemWidth: 260,
orientation: "horizontal",
showHostname: true,
showTitle: true,
};
}
if (layout === "row") {
let orientation: BookmarkOrientation = "vertical";
if (!heightSettings.showTitle) orientation = "icon";
return {
columns: count,
horizontalScroll: true,
itemHeight: heightSettings.rowItemHeight,
itemWidth: widthSettings.rowItemWidth,
orientation,
showHostname: widthSettings.showHostname && heightSettings.showHostname,
showTitle: widthSettings.showTitle && heightSettings.showTitle,
};
}
if (layout === "column") {
return {
columns: 1,
horizontalScroll: false,
itemHeight: heightSettings.columnItemHeight,
itemWidth: width,
orientation: "horizontal",
showHostname: widthSettings.showHostname && heightSettings.showHostname,
showTitle: widthSettings.showTitle && heightSettings.showTitle,
};
}
if (layout === "icons") {
return {
columns: Math.min(count, widthSettings.iconColumns),
horizontalScroll: false,
itemHeight: 56,
itemWidth: 56,
orientation: "icon",
showHostname: false,
showTitle: false,
};
}
let columns: number = widthSettings.compactColumns;
let orientation: BookmarkOrientation = "horizontal";
if (layout === "grid") {
columns = widthSettings.gridColumns;
orientation = "vertical";
}
if (layout === "adaptive") {
columns = widthSettings.adaptiveColumns;
orientation = widthSettings.adaptiveOrientation;
if (heightSettings.orientation === "icon") orientation = "icon";
if (heightSettings.orientation === "horizontal" && orientation === "vertical") orientation = "horizontal";
}
return {
columns: Math.min(count, columns),
horizontalScroll: false,
itemHeight: heightSettings.itemHeight,
itemWidth: widthSettings.rowItemWidth,
orientation,
showHostname: orientation !== "icon" && widthSettings.showHostname && heightSettings.showHostname,
showTitle: orientation !== "icon" && widthSettings.showTitle && heightSettings.showTitle,
};
};
+4 -6
View File
@@ -4,12 +4,11 @@ import { apps } from "@homarr/db/schema";
import type { Prefetch } from "../definition";
import { createTrpcQueryKey } from "../trpc-query-key";
import { getDirectBookmarkUrl } from "./bookmark-item";
const logger = createLogger({ module: "bookmarksWidgetPrefetch" });
const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
const appIds = items.flatMap((item) => item.options.items.filter((value) => !getDirectBookmarkUrl(value)));
const appIds = items.flatMap((item) => item.options.items);
const distinctAppIds = [...new Set(appIds)];
const dbApps = await db.query.apps.findMany({
@@ -17,14 +16,13 @@ const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
});
for (const item of items) {
const itemAppIds = item.options.items.filter((value) => !getDirectBookmarkUrl(value));
if (itemAppIds.length === 0) {
if (item.options.items.length === 0) {
continue;
}
queryClient.setQueryData(
createTrpcQueryKey("app.byIds", itemAppIds),
dbApps.filter((app) => itemAppIds.includes(app.id)),
createTrpcQueryKey("app.byIds", item.options.items),
dbApps.filter((app) => item.options.items.includes(app.id)),
);
}
@@ -56,6 +56,7 @@ export const CalendarEventList = ({
return (
<ScrollArea
offsetScrollbars
pt={5}
w="100%"
h={fillHeight ? "100%" : undefined}
@@ -3,13 +3,3 @@
display: flex;
flex-direction: column;
}
.errorIndicator {
top: calc(var(--mantine-spacing-md) + var(--mantine-spacing-xl));
inset-inline-end: 4px;
z-index: 1;
padding: 2px;
border: 1px solid rgb(from var(--mantine-primary-color-filled) r g b / calc(var(--opacity, 1) * 0.45));
border-radius: var(--mantine-radius-sm);
background-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.18));
}
+8 -10
View File
@@ -180,10 +180,14 @@ const CalendarBase = ({
);
}
const hasErrors = failedIntegrations.length > 0 || Boolean(queryError);
return (
<Box ref={ref} h="100%" w="100%" pos="relative" style={{ overflow: "hidden" }}>
<Stack ref={ref} h="100%" w="100%" gap="xs" style={{ overflow: "hidden" }}>
{(failedIntegrations.length > 0 || Boolean(queryError)) && (
<Group px="xs" justify="flex-end">
<IntegrationErrorIndicator results={failedIntegrations} />
<WidgetQueryErrorIndicator error={queryError} label={t("name")} />
</Group>
)}
<Calendar
defaultDate={new Date()}
onPreviousMonth={(previousMonth) => setMonth(new Date(previousMonth))}
@@ -256,13 +260,7 @@ const CalendarBase = ({
);
}}
/>
{hasErrors && (
<Group className={classes.errorIndicator} gap={0} pos="absolute">
<IntegrationErrorIndicator results={failedIntegrations} />
<WidgetQueryErrorIndicator error={queryError} label={t("name")} />
</Group>
)}
</Box>
</Stack>
);
};
@@ -68,6 +68,9 @@ export const AdvancedClockView = ({
<ScrollArea
h="100%"
w="100%"
type="auto"
offsetScrollbars
scrollbarSize={6}
aria-label={t("worldClock.advancedLabel")}
>
<Stack gap="md" p={{ base: "sm", sm: "md" }}>
@@ -22,16 +22,6 @@
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
.homarr-data-table .mantine-datatable-scroll-area-scrollbar[data-state="visible"]:hover {
@mixin light {
background-color: var(--mantine-color-gray-0);
}
@mixin dark {
background-color: var(--mantine-color-dark-8);
}
}
.homarr-data-table th {
padding: 0;
white-space: nowrap;
@@ -6,8 +6,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { DataTableColumn, DataTableProps } from "mantine-datatable";
import { DataTable } from "mantine-datatable";
import { scrollAreaDefaultProps } from "@homarr/ui";
const fallbackColumnWidth = 60;
const getColumnWidth = (width: string | number | undefined): number => {
@@ -137,13 +135,14 @@ export function HomarrDataTable<T>({
withTableBorder={false}
borderRadius={0}
highlightOnHover
striped="odd"
stripedColor={{ dark: "dark.7", light: "gray.0" }}
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
verticalAlign="center"
loaderBackgroundBlur={2}
textSelectionDisabled
height="100%"
{...props}
striped={false}
columns={fittedColumns}
onMouseDownCapture={handleMouseDownCapture}
className={mergedClassName}
@@ -155,7 +154,7 @@ export function HomarrDataTable<T>({
resizable: !isEditMode,
cellsStyle: defaultColumnProps?.cellsStyle ?? (() => ({ padding: cellPadding })),
}}
scrollAreaProps={{ ...scrollAreaDefaultProps, ...scrollAreaProps }}
scrollAreaProps={{ type: "auto", scrollbarSize: 6, ...scrollAreaProps }}
/>
);
}
@@ -1,29 +0,0 @@
.accordion {
--coolify-neutral-fill: rgb(
from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * 0.12)
);
--coolify-neutral-border: rgb(
from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45)
);
}
.accordion :global(.mantine-Accordion-item) {
--item-border-color: var(--coolify-neutral-border);
--item-filled-color: var(--coolify-neutral-fill);
}
.accordion :global(.mantine-Accordion-control) {
background-color: transparent;
}
.accordion :global(.mantine-Accordion-control:not(:disabled, [data-disabled]):hover) {
background-color: var(--coolify-neutral-fill);
}
.neutralDividerBottom {
border-bottom: 1px solid var(--coolify-neutral-border);
}
.neutralDividerTop {
border-top: 1px solid var(--coolify-neutral-border);
}
@@ -8,7 +8,6 @@ import { useTimeAgo } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { ApplicationsSection } from "./applications-section";
import classes from "./component.module.css";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { buildServerResourceCounts, getBadgeColor, isCoolifyServerOnline, parseStatus } from "./coolify-utils";
import { ServersSection } from "./servers-section";
@@ -72,12 +71,12 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
.join(" · ");
return (
<Card p={0} radius="sm" bg="transparent">
<Card p={0} radius="sm">
<Group
p="xs"
justify="space-between"
wrap="nowrap"
className={classes.neutralDividerBottom}
style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }}
>
<Group gap={4} wrap="nowrap" miw={0}>
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
@@ -136,7 +135,6 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
</Group>
<Accordion
className={classes.accordion}
variant="filled"
chevronPosition="right"
multiple
@@ -177,7 +175,11 @@ const InstanceFooter = ({ version, updatedAt }: { version: string; updatedAt: Da
const t = useI18n("widget.coolify");
const relativeTime = useTimeAgo(updatedAt, 60_000);
return (
<Group justify="space-between" p={4} className={classes.neutralDividerTop}>
<Group
justify="space-between"
p={4}
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
>
<Text size="10px" c="dimmed">
v{version}
</Text>
@@ -8,7 +8,6 @@ import { useTimeAgo } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { ApplicationsSection } from "./applications-section";
import classes from "./component.module.css";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { buildServerResourceCounts } from "./coolify-utils";
import { ServersSection } from "./servers-section";
@@ -83,7 +82,6 @@ export function SingleInstanceLayout({
</Group>
<Accordion
className={classes.accordion}
variant="contained"
chevronPosition="right"
multiple
@@ -125,7 +123,11 @@ const InstanceFooter = ({ version, updatedAt }: { version: string; updatedAt: Da
const t = useI18n("widget.coolify");
const relativeTime = useTimeAgo(updatedAt, 60_000);
return (
<Group justify="space-between" p={4} className={classes.neutralDividerTop}>
<Group
justify="space-between"
p={4}
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
>
<Group gap={2}>
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
<Text size="xs" c="dimmed">
+1 -1
View File
@@ -184,7 +184,7 @@ export default function CountdownWidget({
onNotificationsChange={setNotifications}
/>
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
<ScrollArea type="auto" style={{ flex: 1, minHeight: 0 }}>
<Stack gap="md" pb="xs">
<EventSection
title={t("upcoming")}
@@ -1,3 +1,7 @@
.card {
background-color: transparent;
[data-mantine-color-scheme="light"] .card {
background-color: var(--mantine-color-gray-1);
}
[data-mantine-color-scheme="dark"] .card {
background-color: var(--mantine-color-dark-7);
}
@@ -104,7 +104,7 @@ export default function DnsHoleSummaryWidget({
)}
</SimpleGrid>
{showSourceStatuses && (
<ScrollArea px="xs" pb="xs">
<ScrollArea type="auto" scrollbarSize={4} px="xs" pb="xs">
<Group gap="xs" wrap="nowrap">
{successfulSummaries.map(({ integration, summary }) => (
<Badge
@@ -195,9 +195,6 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
const board = useRequiredBoard();
const label = translateIfNecessary(t, item.label);
const value = item.value(data, width <= 64 ? "sm" : "md");
const backgroundColor = usePiHoleColors
? `rgb(from ${item.color} r g b / calc(var(--opacity, 1) * 0.4))`
: "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
return (
<Tooltip label={tooltip} disabled={!tooltip} w={250} multiline events={{ hover: true, focus: true, touch: true }}>
@@ -209,11 +206,9 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
className="summary-card"
p="sm"
radius={board.itemRadius}
bg={backgroundColor}
bg={usePiHoleColors ? item.color : "var(--mantine-color-default-hover)"}
style={{
flex: 1,
border:
"1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))",
}}
>
<Flex
+2 -6
View File
@@ -1,7 +1,5 @@
"use client";
import "./styles.css";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActionIcon,
@@ -148,9 +146,8 @@ const createColumns = (
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
{inlineState && <ContainerStateDot state={container.state} />}
<Avatar
variant="outline"
radius="sm"
size={20}
radius="xl"
size={24}
styles={{ image: { objectFit: "contain" } }}
src={container.iconUrl}
style={{ flexShrink: 0 }}
@@ -435,7 +432,6 @@ export default function DockerWidget({
<HomarrDataTable
isEditMode={isEditMode}
cellPadding="2px 8px"
className="docker-table"
rowCursor="default"
fetching={isFetching && containers.length === 0}
fz={width < 400 ? "xs" : "sm"}
-5
View File
@@ -1,5 +0,0 @@
/* Keep Docker rows translucent so board wallpaper and the inherited item opacity remain visible. */
.homarr-data-table.docker-table {
--mantine-datatable-background-color: transparent !important;
--mantine-datatable-striped-color: transparent !important;
}
+8 -2
View File
@@ -143,6 +143,11 @@ const stateColorMap: Record<DownloadState, string> = {
unknown: "gray",
};
const rowBgAlpha: Partial<Record<DownloadState, string>> = {
failed: "var(--mantine-color-red-light)",
paused: "var(--mantine-color-yellow-light)",
};
const columnVisibilityChecks: Partial<Record<string, (ctx: ColumnContext) => boolean>> = {
upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns,
ratio: (ctx) => ctx.hasTorrents,
@@ -601,7 +606,7 @@ export default function DownloadClientsWidget({
title: t("items.state.columnTitle"),
width: 90,
render: (record) => (
<Badge size="xs" variant="dot" color={stateColorMap[record.state]}>
<Badge size="xs" variant="light" color={stateColorMap[record.state]}>
{t(`states.${record.state}`)}
</Badge>
),
@@ -779,6 +784,7 @@ export default function DownloadClientsWidget({
className="downloads-table"
customRowAttributes={(_, index) => ({ "data-row-index": index })}
onRowContextMenu={rowContextMenuHandler}
rowBackgroundColor={(record) => rowBgAlpha[record.state]}
rowExpansion={{
trigger: "click",
allowMultiple: true,
@@ -894,7 +900,7 @@ function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; col
<Text size="sm" fw={600} truncate style={{ minWidth: 0 }}>
{item.name}
</Text>
<Badge size="xs" variant="dot" color={stateColorMap[item.state]} ml="auto" style={{ flexShrink: 0 }}>
<Badge size="xs" variant="light" color={stateColorMap[item.state]} ml="auto" style={{ flexShrink: 0 }}>
{t(`states.${item.state}`)}
</Badge>
</Group>
+1 -3
View File
@@ -27,7 +27,6 @@ import type { WidgetComponentProps } from "../definition";
import { calculateBandwidth, formatBitsPerSec } from "./bandwidth";
import { FirewallMenu } from "./firewall-menu";
import { FirewallVersion, formatVersion } from "./firewall-version";
import classes from "./firewall.module.css";
export interface Firewall {
label: string;
@@ -244,7 +243,7 @@ const FirewallPanel = ({
t,
tCommon,
}: FirewallPanelProps) => (
<Paper className={classes.card} withBorder={isAdvanced} radius="sm" p={isAdvanced ? "xs" : 0} bg="transparent">
<Paper withBorder={isAdvanced} radius="sm" p={isAdvanced ? "xs" : 0}>
<Stack gap="xs">
{isAdvanced && (
<Group justify="space-between" wrap="nowrap">
@@ -383,7 +382,6 @@ const InterfacesPanel = ({
value={isAdvanced ? "interfaces" : accordionValue}
onChange={isAdvanced ? undefined : setAccordionValue}
variant={isAdvanced ? "contained" : "default"}
classNames={{ item: classes.accordionItem, control: classes.accordionControl }}
>
<Accordion.Item value="interfaces">
<Accordion.Control icon={<IconTopologyBus size="var(--mantine-font-size-md)" />}>
@@ -1,7 +1,6 @@
import { Box, Select } from "@mantine/core";
import type { Firewall } from "./component";
import classes from "./firewall.module.css";
interface FirewallMenuProps {
onChange: (value: string | null) => void;
@@ -19,7 +18,6 @@ export const FirewallMenu = ({ onChange, isTiny, dropdownItems, selectedFirewall
w="100%"
color="lightgray"
data={dropdownItems}
classNames={{ input: classes.selectInput }}
styles={{
input: {
minHeight: "24px",
@@ -1,33 +1,7 @@
.card {
background-color: transparent;
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
[data-mantine-color-scheme="light"] .card {
background-color: var(--mantine-color-gray-1);
}
.accordionItem {
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
background-color: transparent;
}
.accordionItem[data-active] {
background-color: transparent;
}
.accordionControl {
background-color: transparent;
}
.accordionControl:hover,
.accordionControl:focus-visible,
.accordionControl:active {
background-color: rgb(from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * 0.4));
}
.selectInput {
background-color: transparent;
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
}
.selectInput:focus,
.selectInput:focus-within {
border-color: var(--mantine-primary-color-filled);
[data-mantine-color-scheme="dark"] .card {
background-color: var(--mantine-color-dark-7);
}
@@ -1,5 +1,9 @@
.card {
background-color: transparent;
[data-mantine-color-scheme="light"] .card {
background-color: var(--mantine-color-gray-1);
}
[data-mantine-color-scheme="dark"] .card {
background-color: var(--mantine-color-dark-7);
}
.systemPanel {
@@ -191,7 +191,7 @@ export const SystemHealthMonitoring = ({
))}
</Flex>
{isAdvanced && (
<Card className={classes.card} radius={board.itemRadius} p="sm">
<Card radius={board.itemRadius} p="sm">
<SystemInformationList healthInfo={healthInfo} memoryUsage={memoryUsage} t={t} compact />
</Card>
)}
@@ -228,7 +228,7 @@ function Carousel({
)}
</Box>
{advanced && (
<ScrollArea px="xs" py={6}>
<ScrollArea type="never" scrollbarSize={0} px="xs" py={6}>
<Group gap={6} wrap="nowrap">
{assets.map((asset, index) => (
<UnstyledButton
@@ -1,22 +1,13 @@
.statItem {
padding: 8px 10px;
border: 0.0625rem solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38));
border-radius: var(--mantine-radius-sm);
background-color: transparent;
padding: 12px;
border-radius: var(--mantine-radius-md);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.statItemDense {
min-width: 0;
min-height: 40px;
padding: 3px 6px;
border: 0.0625rem solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.32));
min-height: 44px;
padding: 4px 6px;
border-radius: var(--mantine-radius-sm);
background-color: transparent;
}
@media (forced-colors: active) {
.statItem,
.statItemDense {
border-color: CanvasText;
}
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
@@ -55,49 +55,42 @@ export default function ImmichServerStatsWidget({
Number(statVisibility.showStorage);
const statsLayout = getImmichStatsLayout(width, height, statCount, isAdvanced);
const statItems = [
{
key: "users",
enabled: statVisibility.showUsers,
icon: <IconUsers size={statsLayout.dense ? 16 : 18} />,
label: t("users"),
value: stats.userCount.toLocaleString(locale),
},
{
key: "photos",
enabled: statVisibility.showPhotos,
icon: <IconPhoto size={statsLayout.dense ? 16 : 18} />,
label: t("photos"),
value: stats.photoCount.toLocaleString(locale),
},
{
key: "videos",
enabled: statVisibility.showVideos,
icon: <IconVideo size={statsLayout.dense ? 16 : 18} />,
label: t("videos"),
value: stats.videoCount.toLocaleString(locale),
},
{
key: "storage",
enabled: statVisibility.showStorage,
icon: <IconDatabase size={statsLayout.dense ? 16 : 18} />,
label: t("storage"),
value: formatBytes(stats.totalLibraryUsageInBytes),
},
]
.filter((item) => item.enabled)
.map(({ key, icon, label, value }) => (
<StatItem key={key} icon={icon} label={label} value={value} dense={statsLayout.dense} />
));
const statsContent =
isAdvanced || statsLayout.dense ? (
<SimpleGrid cols={statsLayout.columns} spacing={statsLayout.dense ? 4 : "sm"}>
{statItems}
</SimpleGrid>
) : (
<Stack gap="xs">{statItems}</Stack>
);
const statsContent = (
<SimpleGrid cols={statsLayout.columns} spacing={statsLayout.dense ? 4 : "sm"}>
{statVisibility.showUsers && (
<StatItem
icon={<IconUsers size="var(--mantine-font-size-xl)" />}
label={t("users")}
value={stats.userCount.toLocaleString(locale)}
dense={statsLayout.dense}
/>
)}
{statVisibility.showPhotos && (
<StatItem
icon={<IconPhoto size="var(--mantine-font-size-xl)" />}
label={t("photos")}
value={stats.photoCount.toLocaleString(locale)}
dense={statsLayout.dense}
/>
)}
{statVisibility.showVideos && (
<StatItem
icon={<IconVideo size="var(--mantine-font-size-xl)" />}
label={t("videos")}
value={stats.videoCount.toLocaleString(locale)}
dense={statsLayout.dense}
/>
)}
{statVisibility.showStorage && (
<StatItem
icon={<IconDatabase size="var(--mantine-font-size-xl)" />}
label={t("storage")}
value={formatBytes(stats.totalLibraryUsageInBytes)}
dense={statsLayout.dense}
/>
)}
</SimpleGrid>
);
if (!isAdvanced) {
return (
@@ -1,6 +1,9 @@
.card {
background-color: transparent;
border: 1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38));
[data-mantine-color-scheme="light"] .card {
background-color: var(--mantine-color-gray-1);
}
[data-mantine-color-scheme="dark"] .card {
background-color: var(--mantine-color-dark-7);
}
.root {
@@ -28,7 +31,7 @@
&:hover,
&:focus-within {
background-color: transparent;
background-color: var(--mantine-color-default-hover);
}
}
@@ -4,19 +4,23 @@
display: flex;
text-decoration: none;
color: inherit;
border: 1px solid
rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
background-color: transparent;
border: 1px solid var(--border-color);
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
transition:
border-color 120ms ease,
background-color 120ms ease;
@mixin dark {
--border-color: var(--mantine-color-dark-4);
}
@mixin light {
--border-color: var(--mantine-color-gray-3);
}
&:hover,
&:focus-visible {
border-color: var(--mantine-primary-color-filled);
background-color: rgb(
from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * 0.14)
);
background-color: var(--mantine-color-default-hover);
}
&:focus-visible {
@@ -25,16 +29,6 @@
}
}
:global(.mediaMissing-wrapper) :global(.mantine-SimpleGrid-root) > :global(.mantine-Paper-root) {
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
background-color: transparent;
}
:global(.mediaMissing-wrapper) :global(.mantine-Tabs-list) {
--tab-border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
--tab-hover-color: rgb(from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * 0.14));
}
.content {
position: relative;
z-index: 1;
@@ -1,7 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
Badge,
Box,
@@ -36,37 +36,11 @@ import classes from "./component.module.css";
import type { MediaMissingTab } from "./tabs";
import { resolveMediaMissingTab } from "./tabs";
type Density = "thin" | "compact" | "comfortable";
const MIN_CARD_WIDTH = 280;
const MAX_COLUMNS = 4;
const GRID_HORIZONTAL_PADDING = 20;
const GRID_COLUMN_GAP = 10;
const columnBreakpoints = Array.from({ length: MAX_COLUMNS }, (_, index) => {
const columns = MAX_COLUMNS - index;
return {
columns,
minimumWidth: columns * MIN_CARD_WIDTH + (columns - 1) * GRID_COLUMN_GAP,
};
});
const getMediaMissingGridLayout = (panelWidth: number) => {
const contentWidth = Math.max(0, panelWidth - GRID_HORIZONTAL_PADDING);
const breakpoint = columnBreakpoints.find((candidate) => contentWidth >= candidate.minimumWidth);
return { columns: breakpoint?.columns ?? 1, contentWidth };
};
const getMediaMissingDensity = (contentWidth: number, columns: number, isThin: boolean): Density => {
if (isThin) return "thin";
if (contentWidth > 0 && contentWidth / columns < 180) return "compact";
return "comfortable";
};
export default function MediaMissingWidget({
integrationIds,
options,
width,
height,
displayMode,
widgetRuntimeRef,
}: WidgetComponentProps<"mediaMissing">) {
@@ -94,12 +68,6 @@ export default function MediaMissingWidget({
if (activeTab !== null && activeTab !== selectedTab) setSelectedTab(activeTab);
}, [activeTab, selectedTab]);
const enabledPanelCount = Number(showMissing) + Number(showQueued);
const panelWidth = isAdvanced && enabledPanelCount > 1 ? width / enabledPanelCount : width;
const isThin = !isAdvanced && panelWidth > 0 && panelWidth < 160;
const gridLayout = useMemo(() => getMediaMissingGridLayout(panelWidth), [panelWidth]);
const density = getMediaMissingDensity(gridLayout.contentWidth, gridLayout.columns, isThin);
if (!data) return <WidgetEmptyState />;
if (data.length === 0) throw new NoIntegrationDataError();
if (!showMissing && !showQueued)
@@ -117,6 +85,14 @@ export default function MediaMissingWidget({
const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 0);
const failedIntegrations = data.filter((entry): entry is typeof entry & { error: string } => Boolean(entry.error));
const enabledPanelCount = Number(showMissing) + Number(showQueued);
const panelWidth = isAdvanced && enabledPanelCount > 1 ? width / enabledPanelCount : width;
const isThin = !isAdvanced && panelWidth > 0 && panelWidth < 160;
const isShort = !isAdvanced && height > 0 && height < 180;
const targetCardWidth = isShort ? 130 : 200;
const columns = panelWidth > 0 ? Math.max(1, Math.min(Math.floor(panelWidth / targetCardWidth), 4)) : 1;
const density: Density = isThin ? "thin" : panelWidth > 0 && panelWidth / columns < 180 ? "compact" : "comfortable";
const tabLabel = (label: string, shown: number, total: number) => (isThin ? total : `${label} (${shown}/${total})`);
const renderPanel = (
@@ -126,6 +102,7 @@ export default function MediaMissingWidget({
) => (
<ScrollArea
h="100%"
scrollbarSize={4}
startScrollPosition={tabScrollPositionsRef.current[tab]}
onScrollPositionChange={(position) => {
tabScrollPositionsRef.current[tab] = position;
@@ -137,7 +114,7 @@ export default function MediaMissingWidget({
{emptyLabel}
</Text>
) : (
<SimpleGrid cols={Math.min(gridLayout.columns, entries.length)} spacing="xs" verticalSpacing="xs">
<SimpleGrid cols={columns} spacing="xs" verticalSpacing="xs">
{entries.map(({ item, integrationId }) => (
<MediaCard
key={`${integrationId}-${item.type}-${item.id}`}
@@ -249,6 +226,8 @@ export default function MediaMissingWidget({
);
}
type Density = "thin" | "compact" | "comfortable";
const CARD_HEIGHT: Record<Density, number> = { thin: 52, compact: 56, comfortable: 68 };
const posterSizes: Record<Density, number> = { thin: 34, compact: 40, comfortable: 52 };
@@ -1,15 +1,14 @@
.card {
min-width: 0;
background-color: transparent;
border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38));
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
transition:
background-color 120ms ease,
border-color 120ms ease;
&:hover,
&:focus-within {
border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.62));
background-color: transparent;
border-color: var(--mantine-primary-color-filled);
background-color: var(--mantine-color-default-hover);
}
}
@@ -61,6 +61,7 @@ export default function MediaServerWidget({
)}
<ScrollArea
className="mediaRequests-list-scrollArea"
scrollbarSize="md"
style={{ flex: 1, minHeight: 0, pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack className="mediaRequests-list-list" gap="xs" p="sm">
@@ -148,7 +149,7 @@ const MediaRequestCard = ({
<Image
className="mediaRequests-list-item-poster"
src={request.posterImagePath}
h={isDense ? 32 : 40}
h={isDense ? 36 : 44}
w="auto"
radius="sm"
alt=""
@@ -156,7 +157,7 @@ const MediaRequestCard = ({
/>
)}
<Stack gap={0} w="100%" miw={0}>
<Stack gap={2} w="100%" miw={0}>
<Group gap="xs" justify="space-between" wrap="nowrap" className="mediaRequests-list-item-top-group">
<Anchor
className="mediaRequests-list-item-info-second-line mediaRequests-list-item-media-title"
@@ -273,7 +274,7 @@ const DecisionButtons = ({ requestId, integrationId, canInteract, alwaysVisible
className={`mediaRequests-list-item-pending-button-approve ${actionTargetClasses.root}`}
variant="light"
color="green"
size="xs"
size="sm"
disabled={!canInteract || isPending}
aria-label={t("pending.approve")}
onClick={() => {
@@ -288,7 +289,7 @@ const DecisionButtons = ({ requestId, integrationId, canInteract, alwaysVisible
className={`mediaRequests-list-item-pending-button-decline ${actionTargetClasses.root}`}
variant="light"
color="red"
size="xs"
size="sm"
disabled={!canInteract || isPending}
aria-label={t("pending.decline")}
onClick={() => {
@@ -1,5 +1,9 @@
.card {
background-color: transparent;
[data-mantine-color-scheme="light"] .card {
background-color: var(--mantine-color-gray-1);
}
[data-mantine-color-scheme="dark"] .card {
background-color: var(--mantine-color-dark-7);
}
.userCard {
@@ -9,7 +13,7 @@
&:hover,
&:focus-visible {
background-color: transparent;
background-color: var(--mantine-color-default-hover);
}
&:focus-visible {
@@ -147,7 +147,7 @@ function StreamTableHeader({
const SortIcon = !active ? IconArrowsSort : sort.descending ? IconChevronDown : IconChevronUp;
return (
<Table.Th style={{ width }} aria-sort={active ? (sort.descending ? "descending" : "ascending") : "none"}>
<Table.Th w={width} aria-sort={active ? (sort.descending ? "descending" : "ascending") : "none"}>
{sortable ? (
<UnstyledButton className={classes.sortButton} onClick={() => onSort(column)}>
<Text component="span" size="xs" fw={600} c="dimmed" style={{ letterSpacing: "0.02em" }} truncate>
@@ -1,20 +1,21 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { getQueryKey } from "@trpc/react-query";
import {
Box,
Button,
Center,
Divider,
Group,
Pagination,
Paper,
ScrollArea,
Select,
SegmentedControl,
SimpleGrid,
Stack,
Text,
VisuallyHidden,
} from "@mantine/core";
import { IconClipboardList, IconCpu2, IconReportAnalytics } from "@tabler/icons-react";
@@ -43,24 +44,6 @@ const viewIcons = {
statistics: IconReportAnalytics,
} satisfies Record<View, TablerIcon>;
type TranscodingControlMode = "buttons" | "select";
const narrowWidthLayout = { minimumWidth: 0, advancedColumns: 1, controlMode: "select" } as const;
const widthBreakpoints = [
{ minimumWidth: 1100, advancedColumns: 3, controlMode: "buttons" },
{ minimumWidth: 700, advancedColumns: 2, controlMode: "buttons" },
{ minimumWidth: 560, advancedColumns: 1, controlMode: "buttons" },
narrowWidthLayout,
] as const satisfies readonly {
minimumWidth: number;
advancedColumns: number;
controlMode: TranscodingControlMode;
}[];
const getTranscodingWidthLayout = (width: number) =>
widthBreakpoints.find((candidate) => width >= candidate.minimumWidth) ?? narrowWidthLayout;
export default function MediaTranscodingWidget({
integrationIds,
options,
@@ -108,7 +91,6 @@ export default function MediaTranscodingWidget({
const handleQueuePageChange = (page: number) => {
setQueuePagination({ page, pageSize: queuePageSize, isAdvanced });
};
const widthLayout = useMemo(() => getTranscodingWidthLayout(width), [width]);
if (!transcodingData) return <WidgetEmptyState />;
@@ -126,7 +108,7 @@ export default function MediaTranscodingWidget({
<Stack gap="xs" h="100%" p="xs" pos="relative">
{queryIndicator}
<ScrollArea h="100%" style={{ flex: 1 }}>
<SimpleGrid cols={widthLayout.advancedColumns} spacing="sm">
<SimpleGrid cols={width >= 1100 ? 3 : width >= 700 ? 2 : 1} spacing="sm">
<AdvancedPanel title={t("tab.workers")} icon={IconCpu2}>
<WorkersPanel workers={transcodingData.data.workers} isTiny={false} />
</AdvancedPanel>
@@ -165,45 +147,35 @@ export default function MediaTranscodingWidget({
)}
<Divider />
<Group gap="xs" mb={4} ms={4} me={8} wrap="nowrap">
{widthLayout.controlMode === "buttons" ? (
<Button.Group style={{ flex: 1, minWidth: 0 }}>
{views.map((value) => {
const Icon = viewIcons[value];
return (
<Button
key={value}
size="compact-xs"
variant={value === view ? "filled" : "default"}
leftSection={<Icon size="var(--mantine-font-size-xs)" />}
aria-pressed={value === view}
style={{ flex: 1 }}
onClick={() => setView(value)}
>
{t(`tab.${value}`)}
</Button>
);
})}
</Button.Group>
) : (
<Select
size="xs"
value={view}
data={views.map((value) => ({
<SegmentedControl
data={views.map((value) => {
const Icon = viewIcons[value];
return {
label: (
<Center style={{ gap: 4 }}>
<Icon size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
{footerLayout.showTabLabels ? (
<Text span size="xs">
{t(`tab.${value}`)}
</Text>
) : (
<VisuallyHidden>{t(`tab.${value}`)}</VisuallyHidden>
)}
</Center>
),
value,
label: t(`tab.${value}`),
}))}
aria-label={t("option.defaultView.label")}
style={{ flex: 1, minWidth: 0 }}
onChange={(value) => {
if (!value) return;
const nextView = viewBySegmentValue[value];
if (nextView) {
setView(nextView);
}
}}
/>
)}
};
})}
value={view}
onChange={(value) => {
const nextView = viewBySegmentValue[value];
if (nextView) {
setView(nextView);
}
}}
size="xs"
style={{ minWidth: 0, flexShrink: footerLayout.showTabLabels ? 1 : 0 }}
/>
<Group gap="xs" ml="auto" wrap="nowrap">
{view === "queue" && queuePageCount > 1 && (
@@ -97,7 +97,7 @@ function StatisticItem(props: StatisticItemProps) {
const board = useRequiredBoard();
return (
<Tooltip label={props.label}>
<Card p={0} radius={board.itemRadius} miw={48} flex={1} bg="transparent">
<Card p={0} radius={board.itemRadius} miw={48} flex={1}>
<Group justify="center" align="center" gap="xs" w="100%" wrap="nowrap">
<props.icon size="var(--mantine-font-size-md)" style={{ flexShrink: 0 }} />
<Text size="md">{props.value}</Text>
@@ -14,9 +14,6 @@ import { getNetworkControllerStatusLayout } from "./layout";
import { WifiVariant } from "./variants/wifi-variant";
import { WiredVariant } from "./variants/wired-variant";
const neutralBorderColor =
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
export default function NetworkControllerNetworkStatusWidget({
options,
integrationIds,
@@ -72,7 +69,7 @@ export default function NetworkControllerNetworkStatusWidget({
{queryIndicators}
<SimpleGrid cols={summaries.length > 1 ? layout.sourceColumns : 1} spacing="sm">
{summaries.map(({ integration, summary, updatedAt }) => (
<Card key={integration.id} withBorder p="sm" bg="transparent" style={{ borderColor: neutralBorderColor }}>
<Card key={integration.id} withBorder p="sm">
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap">
<Stack gap={0} miw={0}>
@@ -90,20 +87,10 @@ export default function NetworkControllerNetworkStatusWidget({
</Badge>
</Group>
<SimpleGrid cols={layout.columns} spacing="sm">
<Card
p={layout.cardPadding}
withBorder
bg="transparent"
style={{ borderColor: neutralBorderColor }}
>
<Card p={layout.cardPadding} withBorder>
<WifiVariant countGuests={summary.wifi.guests} countUsers={summary.wifi.users} />
</Card>
<Card
p={layout.cardPadding}
withBorder
bg="transparent"
style={{ borderColor: neutralBorderColor }}
>
<Card p={layout.cardPadding} withBorder>
<WiredVariant countGuests={summary.lan.guests} countUsers={summary.lan.users} />
</Card>
</SimpleGrid>
@@ -118,15 +105,9 @@ export default function NetworkControllerNetworkStatusWidget({
return (
<Stack p={layout.padding} h="100%" gap={0}>
{queryIndicators}
<SimpleGrid cols={layout.columns} spacing="sm" style={{ flex: 1, alignItems: "stretch" }}>
<SimpleGrid cols={layout.columns} h="100%" spacing="sm">
{layout.showWifi && (
<Card
p={layout.cardPadding}
withBorder={layout.withBorder}
bg="transparent"
h="100%"
style={{ borderColor: neutralBorderColor }}
>
<Card p={layout.cardPadding} withBorder={layout.withBorder}>
<WifiVariant
countGuests={countWifiGuests}
countUsers={countWifiUsers}
@@ -136,13 +117,7 @@ export default function NetworkControllerNetworkStatusWidget({
</Card>
)}
{layout.showWired && (
<Card
p={layout.cardPadding}
withBorder={layout.withBorder}
bg="transparent"
h="100%"
style={{ borderColor: neutralBorderColor }}
>
<Card p={layout.cardPadding} withBorder={layout.withBorder}>
<WiredVariant
countGuests={countLanGuests}
countUsers={countLanUsers}
@@ -10,11 +10,11 @@ export const StatRow = ({
compact?: boolean;
}) => {
return (
<Stack gap={2} align="center" justify="center">
<Text size={compact ? "xl" : "2xl"} fw={800} lh={1} ta="center">
<Stack gap={0}>
<Text size={compact ? "xl" : "2xl"} fw={800} lh={1}>
{value}
</Text>
<Text size={compact ? "sm" : "md"} c="dimmed" ta="center">
<Text size={compact ? "sm" : "md"} c="dimmed">
{label}
</Text>
</Stack>
@@ -1,4 +1,4 @@
import { Center, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { Group, SimpleGrid, Text } from "@mantine/core";
import { IconWifi } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
@@ -18,19 +18,17 @@ export const WifiVariant = ({
}) => {
const t = useI18n("widget.networkControllerStatus.card");
return (
<Stack h="100%" align="center" justify="center" gap={compact ? "sm" : "md"}>
<Group gap="xs" wrap="nowrap" justify="center">
<Center w={24} h={24}>
<IconWifi size={20} />
</Center>
<>
<Group gap="xs" wrap="nowrap" mb={compact ? "xs" : "md"}>
<IconWifi size={24} />
<Text size={"md"} fw={"bold"}>
{t("variants.wifi.name")}
</Text>
</Group>
<SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"} w="100%" maw={horizontal ? 360 : 220}>
<SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"}>
<StatRow label={t("users.label")} value={countUsers} compact={compact} />
<StatRow label={t("guests.label")} value={countGuests} compact={compact} />
</SimpleGrid>
</Stack>
</>
);
};
@@ -1,4 +1,4 @@
import { Center, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { Group, SimpleGrid, Text } from "@mantine/core";
import { IconNetwork } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
@@ -18,19 +18,17 @@ export const WiredVariant = ({
}) => {
const t = useI18n("widget.networkControllerStatus.card");
return (
<Stack h="100%" align="center" justify="center" gap={compact ? "sm" : "md"}>
<Group gap="xs" wrap="nowrap" justify="center">
<Center w={24} h={24}>
<IconNetwork size={20} />
</Center>
<>
<Group gap="xs" wrap="nowrap" mb={compact ? "xs" : "md"}>
<IconNetwork size={24} />
<Text size={"md"} fw={"bold"}>
{t("variants.wired.name")}
</Text>
</Group>
<SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"} w="100%" maw={horizontal ? 360 : 220}>
<SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"}>
<StatRow label={t("users.label")} value={countUsers} compact={compact} />
<StatRow label={t("guests.label")} value={countGuests} compact={compact} />
</SimpleGrid>
</Stack>
</>
);
};
@@ -83,13 +83,7 @@ export default function NetworkControllerSummaryWidget({
{queryIndicators}
<SimpleGrid cols={controllerColumns} spacing="sm">
{summaries.map(({ integration, summary, updatedAt }) => (
<Card
key={integration.id}
p="sm"
withBorder
bg="transparent"
style={{ borderColor: neutralSurfaceBorder }}
>
<Card key={integration.id} p="sm" withBorder>
<Stack gap="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={0} style={{ minWidth: 0 }}>
@@ -111,13 +105,7 @@ export default function NetworkControllerSummaryWidget({
</Group>
<SimpleGrid cols={matrixColumns} spacing="xs">
{getNetworkControllerMatrix(summary).map((section) => (
<Card
key={section.key}
p="xs"
withBorder
bg="transparent"
style={{ borderColor: neutralSurfaceBorder }}
>
<Card key={section.key} p="xs" withBorder>
<Stack gap="xs">
<Group justify="space-between" wrap="nowrap">
<Group gap={6} wrap="nowrap">
@@ -161,18 +149,12 @@ export default function NetworkControllerSummaryWidget({
}
return (
<Stack h="100%" p={isDense ? "xs" : "sm"} gap={0} style={{ overflow: "hidden" }}>
<ScrollArea h="100%" p={isDense ? "xs" : "sm"}>
{queryIndicators}
<SimpleGrid cols={columns} spacing="sm" style={{ flex: 1, minHeight: 0, alignContent: "center" }}>
<SimpleGrid cols={columns} spacing="sm">
{summaries.map(({ integration, summary }) => (
<Card
key={integration.id}
p={isDense ? "xs" : "sm"}
withBorder={summaries.length > 1}
bg="transparent"
style={{ borderColor: neutralSurfaceBorder }}
>
<Stack h="100%" gap={isDense ? 4 : 8} justify="center" align="center">
<Card key={integration.id} p={isDense ? "xs" : "sm"} withBorder={summaries.length > 1}>
<Stack gap="xs">
{summaries.length > 1 && (
<Group justify="space-between" wrap="nowrap">
<Text fw={600} size="sm" truncate="end">
@@ -183,7 +165,7 @@ export default function NetworkControllerSummaryWidget({
</Badge>
</Group>
)}
<List spacing={isDense ? 2 : 4} center size={isDense ? "xs" : "sm"} w="fit-content" mx="auto">
<List spacing={isDense ? 2 : "xs"} center size={isDense ? "sm" : undefined}>
<List.Item
icon={
<StatusIcon
@@ -229,7 +211,7 @@ export default function NetworkControllerSummaryWidget({
<StatusIcon
status={summary.lan.status}
label={statusLabels[getBinaryStatusKey(summary.lan.status)]}
size={isDense ? 16 : 20}
size="var(--mantine-font-size-xl)"
/>
}
>
@@ -257,7 +239,7 @@ export default function NetworkControllerSummaryWidget({
</Card>
))}
</SimpleGrid>
</Stack>
</ScrollArea>
);
}
@@ -280,9 +262,6 @@ const getMatrixSectionLabel = (
return t(`card.${key}` as never);
};
const neutralSurfaceBorder =
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
const StatusIcon = ({
status,
label,
+3 -25
View File
@@ -1,6 +1,5 @@
.tiptap hr {
border-top-style: double;
border-top-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
}
.tiptap ul[data-type="taskList"] {
@@ -21,10 +20,6 @@
outline: 3px solid rgba(0, 65, 198, 0.8);
}
.tiptap table :where(tr) {
background-color: transparent;
}
.tiptap table {
border-collapse: collapse;
margin: 0;
@@ -33,8 +28,8 @@
width: 100%;
}
.tiptap table :where(th, td) {
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45)) !important;
.tiptap table td {
border-color: var(--mantine-color-gray-5) !important;
border-width: 1px !important;
border-style: solid !important;
box-sizing: border-box;
@@ -44,7 +39,7 @@
vertical-align: top;
}
.tiptap table :where(th, td) > * {
.tiptap table td > * {
margin-bottom: 0;
}
@@ -71,23 +66,6 @@
.tiptap[contenteditable="false"].resize-cursor {
pointer-events: none;
}
.homarr-notebook-toolbar-group {
background-color: rgb(from var(--mantine-color-body) r g b / calc(var(--opacity, 1) * 0.12));
}
.homarr-notebook .tiptap :where(pre, blockquote) {
background-color: rgb(from var(--mantine-color-body) r g b / calc(var(--opacity, 1) * 0.12));
}
.homarr-notebook .tiptap :where(code, kbd) {
background-color: rgb(from var(--mantine-color-body) r g b / calc(var(--opacity, 1) * 0.12));
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45));
}
.homarr-notebook .tiptap :where(pre) :where(code) {
background-color: transparent;
}
.ProseMirror {
height: 100%;
+13 -12
View File
@@ -121,6 +121,8 @@ export function Notebook({
}, [options.allowReadOnlyCheck]);
const { primaryColor } = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const { mutateAsync, isPending: isSaving } = clientApi.widget.notebook.updateContent.useMutation({
scope: { id: `notebook-content:${boardId ?? "preview"}:${itemId ?? "preview"}` },
});
@@ -358,9 +360,9 @@ export function Notebook({
isEditing && !isSaving ? getHotkeyHandler([[hotkeys.saveNotebook, () => void handleEditToggle()]]) : undefined
}
editor={editor}
styles={() => ({
styles={(theme) => ({
root: {
backgroundColor: "transparent",
backgroundColor: colorScheme === "dark" ? theme.colors.dark[6] : "white",
border: "none",
borderRadius: "0.5rem",
display: "flex",
@@ -369,8 +371,6 @@ export function Notebook({
},
toolbar: {
backgroundColor: "transparent",
borderColor:
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))",
padding: "0.5rem",
},
content: {
@@ -390,7 +390,7 @@ export function Notebook({
overflowY: display.toolbarMaxHeight === undefined ? undefined : "auto",
}}
>
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
<RichTextEditor.ControlsGroup>
<RichTextEditor.Bold title={tControls("bold")} />
<RichTextEditor.Italic title={tControls("italic")} />
<RichTextEditor.Strikethrough title={tControls("strikethrough")} />
@@ -401,14 +401,14 @@ export function Notebook({
<RichTextEditor.ClearFormatting title={tControls("clear")} />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
<RichTextEditor.ControlsGroup>
<RichTextEditor.H1 title={tControls("heading", { level: "1" })} />
<RichTextEditor.H2 title={tControls("heading", { level: "2" })} />
<RichTextEditor.H3 title={tControls("heading", { level: "3" })} />
<RichTextEditor.H4 title={tControls("heading", { level: "4" })} />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
<RichTextEditor.ControlsGroup>
<RichTextEditor.AlignLeft
title={tControls("align", {
position: t("align.left"),
@@ -442,12 +442,12 @@ export function Notebook({
</RichTextEditor.Control>
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
<RichTextEditor.ControlsGroup>
<RichTextEditor.Blockquote title={tControls("blockquote")} />
<RichTextEditor.Hr title={tControls("horizontalLine")} />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
<RichTextEditor.ControlsGroup>
<RichTextEditor.BulletList title={tControls("bulletList")} />
<RichTextEditor.OrderedList title={tControls("orderedList")} />
<TaskListToggle />
@@ -461,13 +461,13 @@ export function Notebook({
)}
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
<RichTextEditor.ControlsGroup>
<RichTextEditor.Link title={tControls("link")} />
<RichTextEditor.Unlink title={tControls("unlink")} />
<EmbedImage />
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
<RichTextEditor.ControlsGroup>
<TableToggle />
{editor?.isActive("table") && (
<>
@@ -483,7 +483,7 @@ export function Notebook({
)}
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup className="homarr-notebook-toolbar-group">
<RichTextEditor.ControlsGroup>
<RichTextEditor.Undo />
<RichTextEditor.Redo />
</RichTextEditor.ControlsGroup>
@@ -500,6 +500,7 @@ export function Notebook({
<ScrollArea
mih="4rem"
offsetScrollbars
pl={12}
pt={12}
styles={{
@@ -1,10 +0,0 @@
.card,
.row {
background-color: transparent;
border-color: rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.38));
}
.row {
border-bottom-style: solid;
border-bottom-width: 1px;
}
@@ -14,7 +14,6 @@ import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import { getNotificationDisplay } from "./display";
import classes from "./component.module.css";
export default function NotificationsWidget({
options,
@@ -98,13 +97,17 @@ export default function NotificationsWidget({
href={href}
target={href ? "_blank" : undefined}
rel={href ? SAFE_NEW_TAB_REL : undefined}
className={columns > 1 ? classes.card : classes.row}
radius={board.itemRadius}
w="100%"
p={isRoomy ? "sm" : isDense ? 6 : "xs"}
bg={columns > 1 ? undefined : "transparent"}
style={{
color: "inherit",
textDecoration: "none",
borderBottom:
columns > 1
? undefined
: "1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4))",
}}
>
<Flex gap={isDense ? "xs" : "sm"} align="flex-start" w="100%">
-3
View File
@@ -34,15 +34,12 @@ export interface SortableItemListInput<TItem, TOptionValue extends UniqueIdentif
> {
AddButton: (props: {
addItem: (item: TItem) => void;
migrateItems: (items: TItem[], optionsPatch: Record<string, unknown>) => void;
removeItem: (value: TOptionValue) => void;
values: TOptionValue[];
initialOptions: Record<string, unknown>;
}) => React.ReactNode;
ItemComponent: (props: {
item: TItem;
removeItem: () => void;
removeLabel: string;
rootAttributes: DraggableAttributes;
handle: React.ReactNode;
}) => React.ReactNode;
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more