mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-26 16:30:57 +01:00
feat: add dynamic section (#842)
* chore: add parent_section_id and change position to x and y_offset for sqlite section table * chore: rename existing positions to x_offset and y_offset * chore: add related mysql migration * chore: add missing height and width to section table * fix: missing width and height in migration copy script * fix: typecheck issues * fix: test not working caused by unsimilar schemas * wip: add dynamic section * refactor: improve structure of gridstack sections * feat: add rendering of dynamic sections * feat: add saving of moved sections * wip: add static row count, restrict min-width and height * chore: address pull request feedback * fix: format issues * fix: size calculation within dynamic sections * fix: on resize not called when min width or height is reached * fix: size of items while dragging is to big * chore: temporarly remove migration files * chore: readd migrations * fix: format and deepsource issues * chore: remove db_dev.sqlite file * chore: add *.sqlite to .gitignore * chore: address pull request feedback * feat: add dynamic section actions for adding and removing them
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -48,7 +48,7 @@ yarn-error.log*
|
||||
.turbo
|
||||
|
||||
# database
|
||||
db.sqlite
|
||||
*.sqlite
|
||||
|
||||
# logs
|
||||
*.log
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/form": "workspace:^0.1.0",
|
||||
"@homarr/gridstack": "^1.0.0",
|
||||
"@homarr/gridstack": "^1.0.3",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^",
|
||||
"@homarr/modals": "workspace:^0.1.0",
|
||||
|
||||
@@ -44,7 +44,9 @@ export const ClientBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const isReady = useIsBoardReady();
|
||||
|
||||
const sortedSections = board.sections.sort((sectionA, sectionB) => sectionA.position - sectionB.position);
|
||||
const fullWidthSortedSections = board.sections
|
||||
.filter((section) => section.kind === "empty" || section.kind === "category")
|
||||
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -58,11 +60,11 @@ export const ClientBoard = () => {
|
||||
h={fullHeightWithoutHeaderAndFooter}
|
||||
/>
|
||||
<Stack ref={ref} h="100%" style={{ visibility: isReady ? "visible" : "hidden" }}>
|
||||
{sortedSections.map((section) =>
|
||||
{fullWidthSortedSections.map((section) =>
|
||||
section.kind === "empty" ? (
|
||||
<BoardEmptySection key={section.id} section={section} mainRef={ref} />
|
||||
<BoardEmptySection key={section.id} section={section} />
|
||||
) : (
|
||||
<BoardCategorySection key={section.id} section={section} mainRef={ref} />
|
||||
<BoardCategorySection key={section.id} section={section} />
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
IconPencil,
|
||||
IconPencilOff,
|
||||
IconPlus,
|
||||
IconResize,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
@@ -23,6 +24,7 @@ import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
import { useEditMode, useRequiredBoard } from "./_context";
|
||||
|
||||
@@ -52,6 +54,7 @@ const AddMenu = () => {
|
||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { addCategoryToEnd } = useCategoryActions();
|
||||
const { addDynamicSection } = useDynamicSectionActions();
|
||||
const t = useI18n();
|
||||
|
||||
const handleAddCategory = useCallback(
|
||||
@@ -99,6 +102,10 @@ const AddMenu = () => {
|
||||
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
||||
{t("section.category.action.create")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={addDynamicSection}>
|
||||
{t("section.dynamic.action.create")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -7,5 +7,6 @@ export type Item = Section["items"][number];
|
||||
|
||||
export type CategorySection = Extract<Section, { kind: "category" }>;
|
||||
export type EmptySection = Extract<Section, { kind: "empty" }>;
|
||||
export type DynamicSection = Extract<Section, { kind: "dynamic" }>;
|
||||
|
||||
export type ItemOfKind<TKind extends WidgetKind> = Extract<Item, { kind: TKind }>;
|
||||
|
||||
@@ -57,7 +57,7 @@ export const useItemActions = () => {
|
||||
updateBoard((previous) => {
|
||||
const lastSection = previous.sections
|
||||
.filter((section): section is EmptySection => section.kind === "empty")
|
||||
.sort((sectionA, sectionB) => sectionB.position - sectionA.position)[0];
|
||||
.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset)[0];
|
||||
|
||||
if (!lastSection) return previous;
|
||||
|
||||
|
||||
89
apps/nextjs/src/components/board/items/item-content.tsx
Normal file
89
apps/nextjs/src/components/board/items/item-content.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Card } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||
import combineClasses from "clsx";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, useServerDataFor } from "@homarr/widgets";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import { useEditMode, useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
||||
import classes from "../sections/item.module.css";
|
||||
import { BoardItemMenu } from "./item-menu";
|
||||
|
||||
interface BoardItemContentProps {
|
||||
item: Item;
|
||||
}
|
||||
|
||||
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
||||
const board = useRequiredBoard();
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"grid-stack-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
withBorder
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
containerType: "size",
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
>
|
||||
<InnerContent item={item} width={width} height={height} />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface InnerContentProps {
|
||||
item: Item;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const serverData = useServerDataFor(item.id);
|
||||
const Comp = loadWidgetDynamic(item.kind);
|
||||
const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options);
|
||||
const newItem = { ...item, options };
|
||||
|
||||
if (!serverData?.isReady) return null;
|
||||
|
||||
return (
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
|
||||
<WidgetError kind={item.kind} error={error as unknown} resetErrorBoundary={resetErrorBoundary} />
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<BoardItemMenu offset={4} item={newItem} />
|
||||
<Comp
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
serverData={serverData?.data as never}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
{...dimensions}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
);
|
||||
};
|
||||
110
apps/nextjs/src/components/board/items/item-menu.tsx
Normal file
110
apps/nextjs/src/components/board/items/item-menu.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { WidgetEditModal, widgetImports } from "@homarr/widgets";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
|
||||
export const BoardItemMenu = ({
|
||||
offset,
|
||||
item,
|
||||
resetErrorBoundary,
|
||||
}: {
|
||||
offset: number;
|
||||
item: Item;
|
||||
resetErrorBoundary?: () => void;
|
||||
}) => {
|
||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||
const tItem = useScopedI18n("item");
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(WidgetEditModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [isEditMode] = useEditMode();
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
||||
|
||||
// Reset error boundary on next render if item has been edited
|
||||
useEffect(() => {
|
||||
if (refResetErrorBoundaryOnNextRender.current) {
|
||||
resetErrorBoundary?.();
|
||||
refResetErrorBoundaryOnNextRender.current = false;
|
||||
}
|
||||
}, [item, resetErrorBoundary]);
|
||||
|
||||
if (!isEditMode || isPending) return null;
|
||||
|
||||
const openEditModal = () => {
|
||||
openModal({
|
||||
kind: item.kind,
|
||||
value: {
|
||||
advancedOptions: item.advancedOptions,
|
||||
options: item.options,
|
||||
integrationIds: item.integrationIds,
|
||||
},
|
||||
onSuccessfulEdit: ({ options, integrationIds, advancedOptions }) => {
|
||||
updateItemOptions({
|
||||
itemId: item.id,
|
||||
newOptions: options,
|
||||
});
|
||||
updateItemAdvancedOptions({
|
||||
itemId: item.id,
|
||||
newAdvancedOptions: advancedOptions,
|
||||
});
|
||||
updateItemIntegrations({
|
||||
itemId: item.id,
|
||||
newIntegrations: integrationIds,
|
||||
});
|
||||
refResetErrorBoundaryOnNextRender.current = true;
|
||||
},
|
||||
integrationData: (integrationData ?? []).filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in currentDefinition &&
|
||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
),
|
||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||
});
|
||||
};
|
||||
|
||||
const openRemoveModal = () => {
|
||||
openConfirmModal({
|
||||
title: tItem("remove.title"),
|
||||
children: tItem("remove.message"),
|
||||
onConfirm: () => {
|
||||
removeItem({ itemId: item.id });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={offset} right={offset} style={{ zIndex: 10 }}>
|
||||
<IconDotsVertical size={"1rem"} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
|
||||
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
|
||||
{tItem("action.edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconLayoutKanban size={16} />}>{tItem("action.move")}</Menu.Item>
|
||||
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
|
||||
{tItem("action.duplicate")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
|
||||
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
|
||||
{tItem("action.remove")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -1,20 +1,16 @@
|
||||
import type { RefObject } from "react";
|
||||
import { Card, Collapse, Group, Stack, Title, UnstyledButton } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
import { CategoryMenu } from "./category/category-menu";
|
||||
import { SectionContent } from "./content";
|
||||
import { useGridstack } from "./gridstack/use-gridstack";
|
||||
import { GridStack } from "./gridstack/gridstack";
|
||||
|
||||
interface Props {
|
||||
section: CategorySection;
|
||||
mainRef: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const BoardCategorySection = ({ section, mainRef }: Props) => {
|
||||
const { refs } = useGridstack({ section, mainRef });
|
||||
export const BoardCategorySection = ({ section }: Props) => {
|
||||
const [opened, { toggle }] = useDisclosure(false);
|
||||
|
||||
return (
|
||||
@@ -30,9 +26,7 @@ export const BoardCategorySection = ({ section, mainRef }: Props) => {
|
||||
<CategoryMenu category={section} />
|
||||
</Group>
|
||||
<Collapse in={opened} p="sm" pt={0}>
|
||||
<div className="grid-stack grid-stack-category" data-category data-section-id={section.id} ref={refs.wrapper}>
|
||||
<SectionContent items={section.items} refs={refs} />
|
||||
</div>
|
||||
<GridStack section={section} />
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
|
||||
|
||||
interface AddCategory {
|
||||
name: string;
|
||||
position: number;
|
||||
yOffset: number;
|
||||
}
|
||||
|
||||
interface RenameCategory {
|
||||
@@ -28,8 +28,8 @@ export const useCategoryActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const addCategory = useCallback(
|
||||
({ name, position }: AddCategory) => {
|
||||
if (position <= -1) {
|
||||
({ name, yOffset }: AddCategory) => {
|
||||
if (yOffset <= -1) {
|
||||
return;
|
||||
}
|
||||
updateBoard((previous) => ({
|
||||
@@ -37,32 +37,32 @@ export const useCategoryActions = () => {
|
||||
sections: [
|
||||
// Place sections before the new category
|
||||
...previous.sections.filter(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
(section) => (section.kind === "category" || section.kind === "empty") && section.position < position,
|
||||
(section) => (section.kind === "category" || section.kind === "empty") && section.yOffset < yOffset,
|
||||
),
|
||||
{
|
||||
id: createId(),
|
||||
name,
|
||||
kind: "category",
|
||||
position,
|
||||
yOffset,
|
||||
xOffset: 0,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
kind: "empty",
|
||||
position: position + 1,
|
||||
yOffset: yOffset + 1,
|
||||
xOffset: 0,
|
||||
items: [],
|
||||
},
|
||||
// Place sections after the new category
|
||||
...previous.sections
|
||||
.filter(
|
||||
(section): section is CategorySection | EmptySection =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
(section.kind === "category" || section.kind === "empty") && section.position >= position,
|
||||
(section.kind === "category" || section.kind === "empty") && section.yOffset >= yOffset,
|
||||
)
|
||||
.map((section) => ({
|
||||
...section,
|
||||
position: section.position + 2,
|
||||
yOffset: section.yOffset + 2,
|
||||
})),
|
||||
],
|
||||
}));
|
||||
@@ -76,14 +76,13 @@ export const useCategoryActions = () => {
|
||||
const lastSection = previous.sections
|
||||
.filter(
|
||||
(section): section is CategorySection | EmptySection =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
section.kind === "empty" || section.kind === "category",
|
||||
)
|
||||
.sort((sectionA, sectionB) => sectionB.position - sectionA.position)
|
||||
.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset)
|
||||
.at(0);
|
||||
|
||||
if (!lastSection) return previous;
|
||||
const lastPosition = lastSection.position;
|
||||
const lastYOffset = lastSection.yOffset;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
@@ -93,13 +92,15 @@ export const useCategoryActions = () => {
|
||||
id: createId(),
|
||||
name,
|
||||
kind: "category",
|
||||
position: lastPosition + 1,
|
||||
yOffset: lastYOffset + 1,
|
||||
xOffset: 0,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
kind: "empty",
|
||||
position: lastPosition + 2,
|
||||
yOffset: lastYOffset + 2,
|
||||
xOffset: 0,
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
@@ -133,40 +134,39 @@ export const useCategoryActions = () => {
|
||||
(section): section is CategorySection => section.kind === "category" && section.id === id,
|
||||
);
|
||||
if (!currentCategory) return previous;
|
||||
if (currentCategory.position === 1 && direction === "up") return previous;
|
||||
if (currentCategory.position === previous.sections.length - 2 && direction === "down") return previous;
|
||||
if (currentCategory.yOffset === 1 && direction === "up") return previous;
|
||||
if (currentCategory.yOffset === previous.sections.length - 2 && direction === "down") return previous;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (section.kind !== "category" && section.kind !== "empty") return section;
|
||||
const offset = direction === "up" ? -2 : 2;
|
||||
// Move category and empty section
|
||||
if (section.position === currentCategory.position || section.position - 1 === currentCategory.position) {
|
||||
if (section.yOffset === currentCategory.yOffset || section.yOffset - 1 === currentCategory.yOffset) {
|
||||
return {
|
||||
...section,
|
||||
position: section.position + offset,
|
||||
yOffset: section.yOffset + offset,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
direction === "up" &&
|
||||
(section.position === currentCategory.position - 2 || section.position === currentCategory.position - 1)
|
||||
(section.yOffset === currentCategory.yOffset - 2 || section.yOffset === currentCategory.yOffset - 1)
|
||||
) {
|
||||
return {
|
||||
...section,
|
||||
position: section.position + 2,
|
||||
position: section.yOffset + 2,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
direction === "down" &&
|
||||
(section.position === currentCategory.position + 2 || section.position === currentCategory.position + 3)
|
||||
(section.yOffset === currentCategory.yOffset + 2 || section.yOffset === currentCategory.yOffset + 3)
|
||||
) {
|
||||
return {
|
||||
...section,
|
||||
position: section.position - 2,
|
||||
position: section.yOffset - 2,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -188,12 +188,12 @@ export const useCategoryActions = () => {
|
||||
|
||||
const aboveWrapper = previous.sections.find(
|
||||
(section): section is EmptySection =>
|
||||
section.kind === "empty" && section.position === currentCategory.position - 1,
|
||||
section.kind === "empty" && section.yOffset === currentCategory.yOffset - 1,
|
||||
);
|
||||
|
||||
const removedWrapper = previous.sections.find(
|
||||
(section): section is EmptySection =>
|
||||
section.kind === "empty" && section.position === currentCategory.position + 1,
|
||||
section.kind === "empty" && section.yOffset === currentCategory.yOffset + 1,
|
||||
);
|
||||
|
||||
if (!aboveWrapper || !removedWrapper) return previous;
|
||||
@@ -214,19 +214,18 @@ export const useCategoryActions = () => {
|
||||
return {
|
||||
...previous,
|
||||
sections: [
|
||||
...previous.sections.filter((section) => section.position < currentCategory.position - 1),
|
||||
...previous.sections.filter((section) => section.yOffset < currentCategory.yOffset - 1),
|
||||
{
|
||||
...aboveWrapper,
|
||||
items: [...aboveWrapper.items, ...previousCategoryItems, ...previousBelowWrapperItems],
|
||||
},
|
||||
...previous.sections
|
||||
.filter(
|
||||
(section): section is CategorySection | EmptySection =>
|
||||
section.position >= currentCategory.position + 2,
|
||||
(section): section is CategorySection | EmptySection => section.yOffset >= currentCategory.yOffset + 2,
|
||||
)
|
||||
.map((section) => ({
|
||||
...section,
|
||||
position: section.position - 2,
|
||||
position: section.yOffset - 2,
|
||||
})),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
const { addCategory, moveCategory, removeCategory, renameCategory } = useCategoryActions();
|
||||
const t = useI18n();
|
||||
|
||||
const createCategoryAtPosition = useCallback(
|
||||
const createCategoryAtYOffset = useCallback(
|
||||
(position: number) => {
|
||||
openModal(
|
||||
{
|
||||
@@ -25,7 +25,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
onSuccess: (category) => {
|
||||
addCategory({
|
||||
name: category.name,
|
||||
position,
|
||||
yOffset: position,
|
||||
});
|
||||
},
|
||||
submitLabel: t("section.category.create.submit"),
|
||||
@@ -40,15 +40,15 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
|
||||
// creates a new category above the current
|
||||
const addCategoryAbove = useCallback(() => {
|
||||
const abovePosition = category.position;
|
||||
createCategoryAtPosition(abovePosition);
|
||||
}, [category.position, createCategoryAtPosition]);
|
||||
const aboveYOffset = category.yOffset;
|
||||
createCategoryAtYOffset(aboveYOffset);
|
||||
}, [category.yOffset, createCategoryAtYOffset]);
|
||||
|
||||
// creates a new category below the current
|
||||
const addCategoryBelow = useCallback(() => {
|
||||
const belowPosition = category.position + 2;
|
||||
createCategoryAtPosition(belowPosition);
|
||||
}, [category.position, createCategoryAtPosition]);
|
||||
const belowYOffset = category.yOffset + 2;
|
||||
createCategoryAtYOffset(belowYOffset);
|
||||
}, [category.yOffset, createCategoryAtYOffset]);
|
||||
|
||||
const moveCategoryUp = useCallback(() => {
|
||||
moveCategory({
|
||||
|
||||
@@ -1,231 +1,70 @@
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { ActionIcon, Card, Menu } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
||||
import combineClasses from "clsx";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import {
|
||||
loadWidgetDynamic,
|
||||
reduceWidgetOptionsWithDefaultValues,
|
||||
useServerDataFor,
|
||||
WidgetEditModal,
|
||||
widgetImports,
|
||||
} from "@homarr/widgets";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import type { DynamicSection, Item, Section } from "~/app/[locale]/boards/_types";
|
||||
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { BoardItemContent } from "../items/item-content";
|
||||
import { BoardDynamicSection } from "./dynamic-section";
|
||||
import { GridStackItem } from "./gridstack/gridstack-item";
|
||||
import { useSectionContext } from "./section-context";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import { useEditMode, useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { useItemActions } from "../items/item-actions";
|
||||
import type { UseGridstackRefs } from "./gridstack/use-gridstack";
|
||||
import classes from "./item.module.css";
|
||||
|
||||
interface Props {
|
||||
items: Item[];
|
||||
refs: UseGridstackRefs;
|
||||
}
|
||||
|
||||
export const SectionContent = ({ items, refs }: Props) => {
|
||||
export const SectionContent = () => {
|
||||
const { section, innerSections, refs } = useSectionContext();
|
||||
const board = useRequiredBoard();
|
||||
const sortedItems = useMemo(() => {
|
||||
return [
|
||||
...section.items.map((item) => ({ ...item, type: "item" as const })),
|
||||
...innerSections.map((section) => ({ ...section, type: "section" as const })),
|
||||
].sort((itemA, itemB) => {
|
||||
if (itemA.yOffset === itemB.yOffset) {
|
||||
return itemA.xOffset - itemB.xOffset;
|
||||
}
|
||||
|
||||
return itemA.yOffset - itemB.xOffset;
|
||||
});
|
||||
}, [section.items, innerSections]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map((item) => (
|
||||
<BoardItem key={item.id} refs={refs} item={item} opacity={board.opacity} />
|
||||
{sortedItems.map((item) => (
|
||||
<GridStackItem
|
||||
key={item.id}
|
||||
innerRef={refs.items.current[item.id]}
|
||||
width={item.width}
|
||||
height={item.height}
|
||||
xOffset={item.xOffset}
|
||||
yOffset={item.yOffset}
|
||||
kind={item.kind}
|
||||
id={item.id}
|
||||
type={item.type}
|
||||
minWidth={item.type === "section" ? getMinSize("x", item.items, board.sections, item.id) : undefined}
|
||||
minHeight={item.type === "section" ? getMinSize("y", item.items, board.sections, item.id) : undefined}
|
||||
>
|
||||
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
|
||||
</GridStackItem>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ItemProps {
|
||||
item: Item;
|
||||
refs: UseGridstackRefs;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
const BoardItem = ({ refs, item, opacity }: ItemProps) => {
|
||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="grid-stack-item"
|
||||
data-id={item.id}
|
||||
gs-x={item.xOffset}
|
||||
gs-y={item.yOffset}
|
||||
gs-w={item.width}
|
||||
gs-h={item.height}
|
||||
gs-min-w={1}
|
||||
gs-min-h={1}
|
||||
ref={refs.items.current[item.id] as RefObject<HTMLDivElement>}
|
||||
>
|
||||
<Card
|
||||
ref={ref}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"grid-stack-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
withBorder
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": opacity / 100,
|
||||
containerType: "size",
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
>
|
||||
<BoardItemContent item={item} width={width} height={height} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ItemContentProps {
|
||||
item: Item;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const BoardItemContent = ({ item, ...dimensions }: ItemContentProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const serverData = useServerDataFor(item.id);
|
||||
const Comp = loadWidgetDynamic(item.kind);
|
||||
const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options);
|
||||
const newItem = { ...item, options };
|
||||
|
||||
if (!serverData?.isReady) return null;
|
||||
|
||||
return (
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={reset}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
<ItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
|
||||
<WidgetError kind={item.kind} error={error as unknown} resetErrorBoundary={resetErrorBoundary} />
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<ItemMenu offset={4} item={newItem} />
|
||||
<Comp
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
serverData={serverData?.data as never}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
{...dimensions}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
const ItemMenu = ({
|
||||
offset,
|
||||
item,
|
||||
resetErrorBoundary,
|
||||
}: {
|
||||
offset: number;
|
||||
item: Item;
|
||||
resetErrorBoundary?: () => void;
|
||||
}) => {
|
||||
const refResetErrorBoundaryOnNextRender = useRef(false);
|
||||
const tItem = useScopedI18n("item");
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(WidgetEditModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [isEditMode] = useEditMode();
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
|
||||
useItemActions();
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
||||
|
||||
// Reset error boundary on next render if item has been edited
|
||||
useEffect(() => {
|
||||
if (refResetErrorBoundaryOnNextRender.current) {
|
||||
resetErrorBoundary?.();
|
||||
refResetErrorBoundaryOnNextRender.current = false;
|
||||
}
|
||||
}, [item, resetErrorBoundary]);
|
||||
|
||||
if (!isEditMode || isPending) return null;
|
||||
|
||||
const openEditModal = () => {
|
||||
openModal({
|
||||
kind: item.kind,
|
||||
value: {
|
||||
advancedOptions: item.advancedOptions,
|
||||
options: item.options,
|
||||
integrationIds: item.integrationIds,
|
||||
},
|
||||
onSuccessfulEdit: ({ options, integrationIds, advancedOptions }) => {
|
||||
updateItemOptions({
|
||||
itemId: item.id,
|
||||
newOptions: options,
|
||||
});
|
||||
updateItemAdvancedOptions({
|
||||
itemId: item.id,
|
||||
newAdvancedOptions: advancedOptions,
|
||||
});
|
||||
updateItemIntegrations({
|
||||
itemId: item.id,
|
||||
newIntegrations: integrationIds,
|
||||
});
|
||||
refResetErrorBoundaryOnNextRender.current = true;
|
||||
},
|
||||
integrationData: (integrationData ?? []).filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in currentDefinition &&
|
||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
),
|
||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||
});
|
||||
};
|
||||
|
||||
const openRemoveModal = () => {
|
||||
openConfirmModal({
|
||||
title: tItem("remove.title"),
|
||||
children: tItem("remove.message"),
|
||||
onConfirm: () => {
|
||||
removeItem({ itemId: item.id });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={offset} right={offset} style={{ zIndex: 10 }}>
|
||||
<IconDotsVertical size={"1rem"} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
|
||||
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
|
||||
{tItem("action.edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconLayoutKanban size={16} />}>{tItem("action.move")}</Menu.Item>
|
||||
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
|
||||
{tItem("action.duplicate")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
|
||||
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
|
||||
{tItem("action.remove")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
/**
|
||||
* Calculates the min width / height of a section by taking the maximum of
|
||||
* the sum of the offset and size of all items and dynamic sections inside.
|
||||
* @param direction either "x" or "y"
|
||||
* @param items items of the section
|
||||
* @param sections sections of the board to look for dynamic sections
|
||||
* @param parentSectionId the id of the section we want to calculate the min size for
|
||||
* @returns the min size
|
||||
*/
|
||||
const getMinSize = (direction: "x" | "y", items: Item[], sections: Section[], parentSectionId: string) => {
|
||||
const size = direction === "x" ? "width" : "height";
|
||||
return Math.max(
|
||||
...items.map((item) => item[`${direction}Offset`] + item[size]),
|
||||
...sections
|
||||
.filter(
|
||||
(section): section is DynamicSection =>
|
||||
section.kind === "dynamic" && section.parentSectionId === parentSectionId,
|
||||
)
|
||||
.map((item) => item[`${direction}Offset`] + item[size]),
|
||||
1, // Minimum size
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Box, Card } from "@mantine/core";
|
||||
|
||||
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
||||
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
|
||||
import { GridStack } from "./gridstack/gridstack";
|
||||
import classes from "./item.module.css";
|
||||
|
||||
interface Props {
|
||||
section: DynamicSection;
|
||||
}
|
||||
|
||||
export const BoardDynamicSection = ({ section }: Props) => {
|
||||
const board = useRequiredBoard();
|
||||
return (
|
||||
<Box className="grid-stack-item-content">
|
||||
<Card
|
||||
className={classes.itemCard}
|
||||
w="100%"
|
||||
h="100%"
|
||||
withBorder
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
overflow: "hidden",
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
>
|
||||
<GridStack section={section} className="min-row" />
|
||||
</Card>
|
||||
<BoardDynamicSectionMenu section={section} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { createId } from "@homarr/db/client";
|
||||
|
||||
import type { DynamicSection, EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
|
||||
|
||||
interface RemoveDynamicSection {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const useDynamicSectionActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const addDynamicSection = useCallback(() => {
|
||||
updateBoard((previous) => {
|
||||
const lastSection = previous.sections
|
||||
.filter((section): section is EmptySection => section.kind === "empty")
|
||||
.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset)[0];
|
||||
|
||||
if (!lastSection) return previous;
|
||||
|
||||
const newSection = {
|
||||
id: createId(),
|
||||
kind: "dynamic",
|
||||
height: 1,
|
||||
width: 1,
|
||||
items: [],
|
||||
parentSectionId: lastSection.id,
|
||||
// We omit xOffset and yOffset because gridstack will use the first available position
|
||||
} satisfies Omit<DynamicSection, "xOffset" | "yOffset">;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.concat(newSection as unknown as DynamicSection),
|
||||
};
|
||||
});
|
||||
}, [updateBoard]);
|
||||
|
||||
const removeDynamicSection = useCallback(
|
||||
({ id }: RemoveDynamicSection) => {
|
||||
updateBoard((previous) => {
|
||||
const sectionToRemove = previous.sections.find(
|
||||
(section): section is DynamicSection => section.id === id && section.kind === "dynamic",
|
||||
);
|
||||
if (!sectionToRemove) return previous;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections
|
||||
.filter((section) => section.id !== id)
|
||||
.map((section) => {
|
||||
if (section.id === sectionToRemove.parentSectionId) {
|
||||
return {
|
||||
...section,
|
||||
// Add items from the removed section to the parent section
|
||||
items: section.items.concat(
|
||||
sectionToRemove.items.map((item) => ({
|
||||
...item,
|
||||
xOffset: sectionToRemove.xOffset + item.xOffset,
|
||||
yOffset: sectionToRemove.yOffset + item.yOffset,
|
||||
})),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (section.kind === "dynamic" && section.parentSectionId === sectionToRemove.id) {
|
||||
// Change xOffset and yOffset of sections that were below the removed section and set parentSectionId to the parent of the removed section
|
||||
return {
|
||||
...section,
|
||||
parentSectionId: sectionToRemove.parentSectionId,
|
||||
yOffset: section.yOffset + sectionToRemove.yOffset,
|
||||
xOffset: section.xOffset + sectionToRemove.xOffset,
|
||||
};
|
||||
}
|
||||
|
||||
return section;
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
return {
|
||||
addDynamicSection,
|
||||
removeDynamicSection,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconDotsVertical, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
||||
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { useDynamicSectionActions } from "./dynamic-actions";
|
||||
|
||||
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSection }) => {
|
||||
const t = useI18n();
|
||||
const tDynamic = useScopedI18n("section.dynamic");
|
||||
const { removeDynamicSection } = useDynamicSectionActions();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const [isEditMode] = useEditMode();
|
||||
|
||||
if (!isEditMode) return null;
|
||||
|
||||
const openRemoveModal = () => {
|
||||
openConfirmModal({
|
||||
title: tDynamic("remove.title"),
|
||||
children: tDynamic("remove.message"),
|
||||
onConfirm: () => {
|
||||
removeDynamicSection({ id: section.id });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={4} right={4} style={{ zIndex: 10 }}>
|
||||
<IconDotsVertical size={"1rem"} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
|
||||
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
|
||||
{tDynamic("action.remove")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -1,30 +1,23 @@
|
||||
import type { RefObject } from "react";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { SectionContent } from "./content";
|
||||
import { useGridstack } from "./gridstack/use-gridstack";
|
||||
import { GridStack } from "./gridstack/gridstack";
|
||||
import { useSectionItems } from "./use-section-items";
|
||||
|
||||
interface Props {
|
||||
section: EmptySection;
|
||||
mainRef: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const defaultClasses = "grid-stack grid-stack-empty min-row";
|
||||
|
||||
export const BoardEmptySection = ({ section, mainRef }: Props) => {
|
||||
const { refs } = useGridstack({ section, mainRef });
|
||||
export const BoardEmptySection = ({ section }: Props) => {
|
||||
const { itemIds } = useSectionItems(section);
|
||||
const [isEditMode] = useEditMode();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={section.items.length > 0 || isEditMode ? defaultClasses : `${defaultClasses} gridstack-empty-wrapper`}
|
||||
<GridStack
|
||||
section={section}
|
||||
style={{ transitionDuration: "0s" }}
|
||||
data-empty
|
||||
data-section-id={section.id}
|
||||
ref={refs.wrapper}
|
||||
>
|
||||
<SectionContent items={section.items} refs={refs} />
|
||||
</div>
|
||||
className={combineClasses("min-row", itemIds.length > 0 || isEditMode ? undefined : "grid-stack-empty-wrapper")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect } from "react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { BoxProps } from "@mantine/core";
|
||||
import { Box } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import type { SectionKind, WidgetKind } from "@homarr/definitions";
|
||||
import type { GridItemHTMLElement } from "@homarr/gridstack";
|
||||
|
||||
interface Props extends BoxProps {
|
||||
id: string;
|
||||
type: "item" | "section";
|
||||
kind: WidgetKind | SectionKind;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
innerRef: React.RefObject<GridItemHTMLElement> | undefined;
|
||||
}
|
||||
|
||||
export const GridStackItem = ({
|
||||
id,
|
||||
type,
|
||||
kind,
|
||||
xOffset,
|
||||
yOffset,
|
||||
width,
|
||||
height,
|
||||
minWidth = 1,
|
||||
minHeight = 1,
|
||||
innerRef,
|
||||
children,
|
||||
...boxProps
|
||||
}: PropsWithChildren<Props>) => {
|
||||
useEffect(() => {
|
||||
if (!innerRef?.current?.gridstackNode) return;
|
||||
if (type !== "section") return;
|
||||
innerRef.current.gridstackNode.minW = minWidth;
|
||||
innerRef.current.gridstackNode.minH = minHeight;
|
||||
}, [minWidth, minHeight, innerRef]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...boxProps}
|
||||
className={combineClasses("grid-stack-item", boxProps.className)}
|
||||
data-id={id}
|
||||
data-type={type}
|
||||
data-kind={kind}
|
||||
gs-x={xOffset}
|
||||
gs-y={yOffset}
|
||||
gs-w={width}
|
||||
gs-h={height}
|
||||
gs-min-w={minWidth}
|
||||
gs-min-h={minHeight}
|
||||
ref={innerRef as React.RefObject<HTMLDivElement>}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import type { BoxProps } from "@mantine/core";
|
||||
import { Box } from "@mantine/core";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import type { Section } from "~/app/[locale]/boards/_types";
|
||||
import { SectionContent } from "../content";
|
||||
import { SectionProvider } from "../section-context";
|
||||
import { useSectionItems } from "../use-section-items";
|
||||
import { useGridstack } from "./use-gridstack";
|
||||
|
||||
interface Props extends BoxProps {
|
||||
section: Section;
|
||||
}
|
||||
|
||||
export const GridStack = ({ section, ...props }: Props) => {
|
||||
const { itemIds, innerSections } = useSectionItems(section);
|
||||
|
||||
const { refs } = useGridstack(section, itemIds);
|
||||
|
||||
return (
|
||||
<SectionProvider value={{ section, innerSections, refs }}>
|
||||
<Box
|
||||
{...props}
|
||||
data-kind={section.kind}
|
||||
data-section-id={section.id}
|
||||
className={combineClasses(`grid-stack grid-stack-${section.kind}`, props.className)}
|
||||
ref={refs.wrapper}
|
||||
>
|
||||
<SectionContent />
|
||||
</Box>
|
||||
</SectionProvider>
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,8 @@ import { GridStack } from "@homarr/gridstack";
|
||||
import type { Section } from "~/app/[locale]/boards/_types";
|
||||
|
||||
interface InitializeGridstackProps {
|
||||
section: Section;
|
||||
section: Omit<Section, "items">;
|
||||
itemIds: string[];
|
||||
refs: {
|
||||
wrapper: RefObject<HTMLDivElement>;
|
||||
items: MutableRefObject<Record<string, RefObject<GridItemHTMLElement>>>;
|
||||
@@ -15,20 +16,21 @@ interface InitializeGridstackProps {
|
||||
sectionColumnCount: number;
|
||||
}
|
||||
|
||||
export const initializeGridstack = ({ section, refs, sectionColumnCount }: InitializeGridstackProps) => {
|
||||
export const initializeGridstack = ({ section, itemIds, refs, sectionColumnCount }: InitializeGridstackProps) => {
|
||||
if (!refs.wrapper.current) return false;
|
||||
// initialize gridstack
|
||||
const newGrid = refs.gridstack;
|
||||
newGrid.current = GridStack.init(
|
||||
{
|
||||
column: sectionColumnCount,
|
||||
margin: Math.round(Math.max(Math.min(refs.wrapper.current.offsetWidth / 100, 10), 1)),
|
||||
margin: 10,
|
||||
cellHeight: 128,
|
||||
float: true,
|
||||
alwaysShowResizeHandle: true,
|
||||
acceptWidgets: true,
|
||||
staticGrid: true,
|
||||
minRow: 1,
|
||||
minRow: section.kind === "dynamic" && "height" in section ? (section.height as number) : 1,
|
||||
maxRow: section.kind === "dynamic" && "height" in section ? (section.height as number) : 0,
|
||||
animate: false,
|
||||
styleInHead: true,
|
||||
disableRemoveNodeOnDrop: true,
|
||||
@@ -43,7 +45,7 @@ export const initializeGridstack = ({ section, refs, sectionColumnCount }: Initi
|
||||
|
||||
grid.batchUpdate();
|
||||
grid.removeAll(false);
|
||||
section.items.forEach(({ id }) => {
|
||||
itemIds.forEach((id) => {
|
||||
const ref = refs.items.current[id]?.current;
|
||||
if (!ref) return;
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { MutableRefObject, RefObject } from "react";
|
||||
import { createRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { createRef, useCallback, useEffect, useRef } from "react";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
import type { GridItemHTMLElement, GridStack, GridStackNode } from "@homarr/gridstack";
|
||||
import type { GridHTMLElement, GridItemHTMLElement, GridStack, GridStackNode } from "@homarr/gridstack";
|
||||
|
||||
import type { Section } from "~/app/[locale]/boards/_types";
|
||||
import { useEditMode, useMarkSectionAsReady, useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { useItemActions } from "../../items/item-actions";
|
||||
import { useSectionActions } from "../section-actions";
|
||||
import { initializeGridstack } from "./init-gridstack";
|
||||
|
||||
export interface UseGridstackRefs {
|
||||
@@ -18,79 +20,171 @@ interface UseGristackReturnType {
|
||||
refs: UseGridstackRefs;
|
||||
}
|
||||
|
||||
interface UseGridstackProps {
|
||||
section: Section;
|
||||
mainRef?: RefObject<HTMLDivElement>;
|
||||
}
|
||||
/**
|
||||
* When the size of a gridstack changes we need to update the css variables
|
||||
* so the gridstack items are displayed correctly
|
||||
* @param wrapper gridstack wrapper
|
||||
* @param gridstack gridstack object
|
||||
* @param width width of the section (column count)
|
||||
* @param height height of the section (row count)
|
||||
* @param isDynamic if the section is dynamic
|
||||
*/
|
||||
const handleResizeChange = (
|
||||
wrapper: HTMLDivElement,
|
||||
gridstack: GridStack,
|
||||
width: number,
|
||||
height: number,
|
||||
isDynamic: boolean,
|
||||
) => {
|
||||
wrapper.style.setProperty("--gridstack-column-count", width.toString());
|
||||
wrapper.style.setProperty("--gridstack-row-count", height.toString());
|
||||
|
||||
export const useGridstack = ({ section, mainRef }: UseGridstackProps): UseGristackReturnType => {
|
||||
let cellHeight = wrapper.clientWidth / width;
|
||||
if (isDynamic) {
|
||||
cellHeight = wrapper.clientHeight / height;
|
||||
}
|
||||
|
||||
if (!isDynamic) {
|
||||
document.body.style.setProperty("--gridstack-cell-size", cellHeight.toString());
|
||||
}
|
||||
|
||||
gridstack.cellHeight(cellHeight);
|
||||
};
|
||||
|
||||
export const useGridstack = (section: Omit<Section, "items">, itemIds: string[]): UseGristackReturnType => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const markAsReady = useMarkSectionAsReady();
|
||||
const { moveAndResizeItem, moveItemToSection } = useItemActions();
|
||||
const { moveAndResizeInnerSection, moveInnerSectionToSection } = useSectionActions();
|
||||
|
||||
// define reference for wrapper - is used to calculate the width of the wrapper
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const { ref: wrapperRef, width, height } = useElementSize<HTMLDivElement>();
|
||||
// references to the diffrent items contained in the gridstack
|
||||
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement>>>({});
|
||||
// reference of the gridstack object for modifications after initialization
|
||||
const gridRef = useRef<GridStack>();
|
||||
|
||||
useCssVariableConfiguration({ mainRef, gridRef });
|
||||
|
||||
const board = useRequiredBoard();
|
||||
|
||||
const items = useMemo(() => section.items, [section.items]);
|
||||
const columnCount =
|
||||
section.kind === "dynamic" && "width" in section && typeof section.width === "number"
|
||||
? section.width
|
||||
: board.columnCount;
|
||||
|
||||
useCssVariableConfiguration({
|
||||
columnCount,
|
||||
gridRef,
|
||||
wrapperRef,
|
||||
width,
|
||||
height,
|
||||
isDynamic: section.kind === "dynamic",
|
||||
});
|
||||
|
||||
// define items in itemRefs for easy access and reference to items
|
||||
if (Object.keys(itemRefs.current).length !== items.length) {
|
||||
items.forEach(({ id }: { id: keyof typeof itemRefs.current }) => {
|
||||
if (Object.keys(itemRefs.current).length !== itemIds.length) {
|
||||
itemIds.forEach((id) => {
|
||||
itemRefs.current[id] = itemRefs.current[id] ?? createRef();
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle the gridstack to be static or not based on the edit mode
|
||||
useEffect(() => {
|
||||
gridRef.current?.setStatic(!isEditMode);
|
||||
}, [isEditMode]);
|
||||
|
||||
const onChange = useCallback(
|
||||
(changedNode: GridStackNode) => {
|
||||
const itemId = changedNode.el?.getAttribute("data-id");
|
||||
if (!itemId) return;
|
||||
const id = changedNode.el?.getAttribute("data-id");
|
||||
const type = changedNode.el?.getAttribute("data-type");
|
||||
|
||||
// Updates the react-query state
|
||||
moveAndResizeItem({
|
||||
itemId,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
xOffset: changedNode.x!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
yOffset: changedNode.y!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
width: changedNode.w!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
height: changedNode.h!,
|
||||
});
|
||||
if (!id || !type) return;
|
||||
|
||||
if (type === "item") {
|
||||
// Updates the react-query state
|
||||
moveAndResizeItem({
|
||||
itemId: id,
|
||||
// We want the following properties to be null by default
|
||||
// so the next free position is used from the gridstack
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
xOffset: changedNode.x!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
yOffset: changedNode.y!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
width: changedNode.w!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
height: changedNode.h!,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "section") {
|
||||
moveAndResizeInnerSection({
|
||||
innerSectionId: id,
|
||||
// We want the following properties to be null by default
|
||||
// so the next free position is used from the gridstack
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
xOffset: changedNode.x!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
yOffset: changedNode.y!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
width: changedNode.w!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
height: changedNode.h!,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`Unknown grid-stack-item type to move. type='${type}' id='${id}'`);
|
||||
},
|
||||
[moveAndResizeItem],
|
||||
[moveAndResizeItem, moveAndResizeInnerSection],
|
||||
);
|
||||
const onAdd = useCallback(
|
||||
(addedNode: GridStackNode) => {
|
||||
const itemId = addedNode.el?.getAttribute("data-id");
|
||||
if (!itemId) return;
|
||||
const id = addedNode.el?.getAttribute("data-id");
|
||||
const type = addedNode.el?.getAttribute("data-type");
|
||||
|
||||
// Updates the react-query state
|
||||
moveItemToSection({
|
||||
itemId,
|
||||
sectionId: section.id,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
xOffset: addedNode.x!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
yOffset: addedNode.y!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
width: addedNode.w!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
height: addedNode.h!,
|
||||
});
|
||||
if (!id || !type) return;
|
||||
|
||||
if (type === "item") {
|
||||
// Updates the react-query state
|
||||
moveItemToSection({
|
||||
itemId: id,
|
||||
sectionId: section.id,
|
||||
// We want the following properties to be null by default
|
||||
// so the next free position is used from the gridstack
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
xOffset: addedNode.x!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
yOffset: addedNode.y!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
width: addedNode.w!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
height: addedNode.h!,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "section") {
|
||||
moveInnerSectionToSection({
|
||||
innerSectionId: id,
|
||||
sectionId: section.id,
|
||||
// We want the following properties to be null by default
|
||||
// so the next free position is used from the gridstack
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
xOffset: addedNode.x!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
yOffset: addedNode.y!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
width: addedNode.w!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
height: addedNode.h!,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`Unknown grid-stack-item type to add. type='${type}' id='${id}'`);
|
||||
},
|
||||
[moveItemToSection, section.id],
|
||||
[moveItemToSection, moveInnerSectionToSection, section.id],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -99,6 +193,23 @@ export const useGridstack = ({ section, mainRef }: UseGridstackProps): UseGrista
|
||||
// Add listener for moving items around in a wrapper
|
||||
currentGrid?.on("change", (_, nodes) => {
|
||||
nodes.forEach(onChange);
|
||||
|
||||
// For all dynamic section items that changed we want to update the inner gridstack
|
||||
nodes
|
||||
.filter((node) => node.el?.getAttribute("data-type") === "section")
|
||||
.forEach((node) => {
|
||||
const dynamicInnerGrid = node.el?.querySelector<GridHTMLElement>('.grid-stack[data-kind="dynamic"]');
|
||||
|
||||
if (!dynamicInnerGrid?.gridstack) return;
|
||||
|
||||
handleResizeChange(
|
||||
dynamicInnerGrid as HTMLDivElement,
|
||||
dynamicInnerGrid.gridstack,
|
||||
node.w ?? 1,
|
||||
node.h ?? 1,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Add listener for moving items in config from one wrapper to another
|
||||
@@ -116,20 +227,31 @@ export const useGridstack = ({ section, mainRef }: UseGridstackProps): UseGrista
|
||||
useEffect(() => {
|
||||
const isReady = initializeGridstack({
|
||||
section,
|
||||
itemIds,
|
||||
refs: {
|
||||
items: itemRefs,
|
||||
wrapper: wrapperRef,
|
||||
gridstack: gridRef,
|
||||
},
|
||||
sectionColumnCount: board.columnCount,
|
||||
sectionColumnCount: columnCount,
|
||||
});
|
||||
|
||||
// If the section is ready mark it as ready
|
||||
// When all sections are ready the board is ready and will get visible
|
||||
if (isReady) {
|
||||
markAsReady(section.id);
|
||||
}
|
||||
|
||||
// Only run this effect when the section items change
|
||||
}, [items.length, section.items.length, board.columnCount]);
|
||||
}, [itemIds.length, columnCount]);
|
||||
|
||||
const sectionHeight = section.kind === "dynamic" && "height" in section ? (section.height as number) : null;
|
||||
|
||||
// We want the amount of rows in a dynamic section to be the height of the section in the outer gridstack
|
||||
useEffect(() => {
|
||||
if (!sectionHeight) return;
|
||||
gridRef.current?.row(sectionHeight);
|
||||
}, [sectionHeight]);
|
||||
|
||||
return {
|
||||
refs: {
|
||||
@@ -141,48 +263,80 @@ export const useGridstack = ({ section, mainRef }: UseGridstackProps): UseGrista
|
||||
};
|
||||
|
||||
interface UseCssVariableConfiguration {
|
||||
mainRef?: RefObject<HTMLDivElement>;
|
||||
gridRef: UseGridstackRefs["gridstack"];
|
||||
wrapperRef: UseGridstackRefs["wrapper"];
|
||||
width: number;
|
||||
height: number;
|
||||
columnCount: number;
|
||||
isDynamic: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* This hook is used to configure the css variables for the gridstack
|
||||
* Those css variables are used to define the size of the gridstack items
|
||||
* @see gridstack.scss
|
||||
* @param mainRef reference to the main div wrapping all sections
|
||||
* @param gridRef reference to the gridstack object
|
||||
* @param wrapperRef reference to the wrapper of the gridstack
|
||||
* @param width width of the section
|
||||
* @param height height of the section
|
||||
* @param columnCount column count of the gridstack
|
||||
*/
|
||||
const useCssVariableConfiguration = ({ mainRef, gridRef }: UseCssVariableConfiguration) => {
|
||||
const board = useRequiredBoard();
|
||||
const useCssVariableConfiguration = ({
|
||||
gridRef,
|
||||
wrapperRef,
|
||||
width,
|
||||
height,
|
||||
columnCount,
|
||||
isDynamic,
|
||||
}: UseCssVariableConfiguration) => {
|
||||
const onResize = useCallback(() => {
|
||||
if (!wrapperRef.current) return;
|
||||
if (!gridRef.current) return;
|
||||
handleResizeChange(
|
||||
wrapperRef.current,
|
||||
gridRef.current,
|
||||
gridRef.current.getColumn(),
|
||||
gridRef.current.getRow(),
|
||||
isDynamic,
|
||||
);
|
||||
}, [wrapperRef, gridRef, isDynamic]);
|
||||
|
||||
// Get reference to the :root element
|
||||
const typeofDocument = typeof document;
|
||||
const root = useMemo(() => {
|
||||
if (typeofDocument === "undefined") return;
|
||||
return document.documentElement;
|
||||
}, [typeofDocument]);
|
||||
useCallback(() => {
|
||||
if (!wrapperRef.current) return;
|
||||
if (!gridRef.current) return;
|
||||
|
||||
wrapperRef.current.style.setProperty("--gridstack-column-count", gridRef.current.getColumn().toString());
|
||||
wrapperRef.current.style.setProperty("--gridstack-row-count", gridRef.current.getRow().toString());
|
||||
|
||||
let cellHeight = wrapperRef.current.clientWidth / gridRef.current.getColumn();
|
||||
if (isDynamic) {
|
||||
cellHeight = wrapperRef.current.clientHeight / gridRef.current.getRow();
|
||||
}
|
||||
|
||||
gridRef.current.cellHeight(cellHeight);
|
||||
}, [wrapperRef, gridRef, isDynamic]);
|
||||
|
||||
// Define widget-width by calculating the width of one column with mainRef width and column count
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const onResize = () => {
|
||||
if (!mainRef?.current) return;
|
||||
const widgetWidth = mainRef.current.clientWidth / board.columnCount;
|
||||
// widget width is used to define sizes of gridstack items within global.scss
|
||||
root?.style.setProperty("--gridstack-widget-width", widgetWidth.toString());
|
||||
gridRef.current?.cellHeight(widgetWidth);
|
||||
};
|
||||
onResize();
|
||||
if (typeof window === "undefined") return;
|
||||
window.addEventListener("resize", onResize);
|
||||
const wrapper = wrapperRef.current;
|
||||
wrapper?.addEventListener("resize", onResize);
|
||||
return () => {
|
||||
if (typeof window === "undefined") return;
|
||||
window.removeEventListener("resize", onResize);
|
||||
wrapper?.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, [board.columnCount, mainRef, root, gridRef]);
|
||||
}, [wrapperRef, gridRef, onResize]);
|
||||
|
||||
// Handle resize of inner sections when there size changes
|
||||
useEffect(() => {
|
||||
onResize();
|
||||
}, [width, height, onResize]);
|
||||
|
||||
// Define column count by using the sectionColumnCount
|
||||
useEffect(() => {
|
||||
root?.style.setProperty("--gridstack-column-count", board.columnCount.toString());
|
||||
}, [board.columnCount, root]);
|
||||
wrapperRef.current?.style.setProperty("--gridstack-column-count", columnCount.toString());
|
||||
}, [columnCount, wrapperRef]);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
|
||||
|
||||
interface MoveAndResizeInnerSection {
|
||||
innerSectionId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
interface MoveInnerSectionToSection {
|
||||
innerSectionId: string;
|
||||
sectionId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const useSectionActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const moveAndResizeInnerSection = useCallback(
|
||||
({ innerSectionId, ...positionProps }: MoveAndResizeInnerSection) => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
// Return same section if section is not the one we're moving
|
||||
if (section.id !== innerSectionId) return section;
|
||||
return {
|
||||
...section,
|
||||
...positionProps,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const moveInnerSectionToSection = useCallback(
|
||||
({ innerSectionId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
|
||||
updateBoard((previous) => {
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
// Return section without changes when not the section we're moving
|
||||
if (section.id !== innerSectionId) return section;
|
||||
return {
|
||||
...section,
|
||||
...positionProps,
|
||||
parentSectionId: sectionId,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
return {
|
||||
moveAndResizeInnerSection,
|
||||
moveInnerSectionToSection,
|
||||
};
|
||||
};
|
||||
22
apps/nextjs/src/components/board/sections/section-context.ts
Normal file
22
apps/nextjs/src/components/board/sections/section-context.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { Section } from "~/app/[locale]/boards/_types";
|
||||
import type { UseGridstackRefs } from "./gridstack/use-gridstack";
|
||||
|
||||
interface SectionContextProps {
|
||||
section: Section;
|
||||
innerSections: Exclude<Section, { kind: "category" } | { kind: "empty" }>[];
|
||||
refs: UseGridstackRefs;
|
||||
}
|
||||
|
||||
const SectionContext = createContext<SectionContextProps | null>(null);
|
||||
|
||||
export const useSectionContext = () => {
|
||||
const context = useContext(SectionContext);
|
||||
if (!context) {
|
||||
throw new Error("useSectionContext must be used within a SectionContext");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const SectionProvider = SectionContext.Provider;
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Section } from "~/app/[locale]/boards/_types";
|
||||
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
||||
|
||||
export const useSectionItems = (section: Section) => {
|
||||
const board = useRequiredBoard();
|
||||
const innerSections = board.sections.filter(
|
||||
(innerSection): innerSection is Exclude<Section, { kind: "category" } | { kind: "empty" }> =>
|
||||
innerSection.kind === "dynamic" && innerSection.parentSectionId === section.id,
|
||||
);
|
||||
|
||||
return {
|
||||
innerSections,
|
||||
itemIds: section.items.map((item) => item.id).concat(innerSections.map((section) => section.id)),
|
||||
};
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
@import "@homarr/gridstack/dist/gridstack.min.css";
|
||||
|
||||
:root {
|
||||
--gridstack-widget-width: 64;
|
||||
--gridstack-column-count: 12;
|
||||
--gridstack-row-count: 1;
|
||||
--gridstack-cell-size: 0;
|
||||
}
|
||||
|
||||
.grid-stack-placeholder > .placeholder-content {
|
||||
@@ -20,7 +21,23 @@
|
||||
// Define min size for gridstack items
|
||||
.grid-stack > .grid-stack-item {
|
||||
min-width: calc(100% / var(--gridstack-column-count));
|
||||
min-height: calc(1px * var(--gridstack-widget-width));
|
||||
min-height: calc(100% / var(--gridstack-row-count));
|
||||
}
|
||||
|
||||
.grid-stack > .grid-stack-item.ui-draggable-dragging {
|
||||
min-width: calc(var(--gridstack-cell-size) * 1px) !important;
|
||||
min-height: calc(var(--gridstack-cell-size) * 1px) !important;
|
||||
}
|
||||
|
||||
// Define fix size while dragging
|
||||
@for $i from 1 to 96 {
|
||||
.grid-stack > .grid-stack-item.ui-draggable-dragging[gs-w="#{$i}"] {
|
||||
width: calc(var(--gridstack-cell-size) * #{$i} * 1px) !important;
|
||||
}
|
||||
|
||||
.grid-stack > .grid-stack-item.ui-draggable-dragging[gs-h="#{$i}"] {
|
||||
height: calc(var(--gridstack-cell-size) * #{$i} * 1px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Styling for grid-stack main area
|
||||
@@ -38,13 +55,13 @@
|
||||
|
||||
@for $i from 1 to 96 {
|
||||
.grid-stack > .grid-stack-item[gs-h="#{$i}"] {
|
||||
height: calc(#{$i}px * #{var(--gridstack-widget-width)});
|
||||
height: calc(100% / var(--gridstack-row-count) * #{$i});
|
||||
}
|
||||
.grid-stack > .grid-stack-item[gs-min-h="#{$i}"] {
|
||||
min-height: calc(#{$i}px * #{var(--gridstack-widget-width)});
|
||||
min-height: calc(100% / var(--gridstack-row-count) * #{$i});
|
||||
}
|
||||
.grid-stack > .grid-stack-item[gs-max-h="#{$i}"] {
|
||||
max-height: calc(#{$i}px * #{var(--gridstack-widget-width)});
|
||||
max-height: calc(100% / var(--gridstack-row-count) * #{$i});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,10 +73,14 @@
|
||||
|
||||
@for $i from 1 to 96 {
|
||||
.grid-stack > .grid-stack-item[gs-y="#{$i}"] {
|
||||
top: calc(#{$i}px * #{var(--gridstack-widget-width)});
|
||||
top: calc(100% / var(--gridstack-row-count) * #{$i});
|
||||
}
|
||||
}
|
||||
|
||||
.grid-stack[data-kind="dynamic"] {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
// General gridstack styling
|
||||
.grid-stack > .grid-stack-item > .grid-stack-item-content,
|
||||
.grid-stack > .grid-stack-item > .placeholder-content {
|
||||
|
||||
@@ -109,7 +109,8 @@ export const boardRouter = createTRPCRouter({
|
||||
await transaction.insert(sections).values({
|
||||
id: createId(),
|
||||
kind: "empty",
|
||||
position: 0,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
boardId,
|
||||
});
|
||||
});
|
||||
@@ -206,7 +207,11 @@ export const boardRouter = createTRPCRouter({
|
||||
addedSections.map((section) => ({
|
||||
id: section.id,
|
||||
kind: section.kind,
|
||||
position: section.position,
|
||||
yOffset: section.yOffset,
|
||||
xOffset: section.kind === "dynamic" ? section.xOffset : 0,
|
||||
height: "height" in section ? section.height : null,
|
||||
width: "width" in section ? section.width : null,
|
||||
parentSectionId: "parentSectionId" in section ? section.parentSectionId : null,
|
||||
name: "name" in section ? section.name : null,
|
||||
boardId: dbBoard.id,
|
||||
})),
|
||||
@@ -292,7 +297,11 @@ export const boardRouter = createTRPCRouter({
|
||||
await transaction
|
||||
.update(sections)
|
||||
.set({
|
||||
position: section.position,
|
||||
yOffset: section.yOffset,
|
||||
xOffset: section.xOffset,
|
||||
height: prev?.kind === "dynamic" && "height" in section ? section.height : null,
|
||||
width: prev?.kind === "dynamic" && "width" in section ? section.width : null,
|
||||
parentSectionId: prev?.kind === "dynamic" && "parentSectionId" in section ? section.parentSectionId : null,
|
||||
name: prev?.kind === "category" && "name" in section ? section.name : null,
|
||||
})
|
||||
.where(eq(sections.id, section.id));
|
||||
@@ -538,6 +547,7 @@ const outputItemSchema = zodUnionFromArray(widgetKinds.map((kind) => forKind(kin
|
||||
|
||||
const parseSection = (section: unknown) => {
|
||||
const result = createSectionSchema(outputItemSchema).safeParse(section);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.message);
|
||||
}
|
||||
|
||||
@@ -619,7 +619,8 @@ describe("saveBoard should save full board", () => {
|
||||
{
|
||||
id: createId(),
|
||||
kind: "empty",
|
||||
position: 0,
|
||||
yOffset: 0,
|
||||
xOffset: 0,
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
@@ -655,7 +656,8 @@ describe("saveBoard should save full board", () => {
|
||||
{
|
||||
id: sectionId,
|
||||
kind: "empty",
|
||||
position: 0,
|
||||
yOffset: 0,
|
||||
xOffset: 0,
|
||||
items: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -716,7 +718,8 @@ describe("saveBoard should save full board", () => {
|
||||
{
|
||||
id: sectionId,
|
||||
kind: "empty",
|
||||
position: 0,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
items: [
|
||||
{
|
||||
id: itemId,
|
||||
@@ -778,14 +781,16 @@ describe("saveBoard should save full board", () => {
|
||||
sections: [
|
||||
{
|
||||
id: newSectionId,
|
||||
position: 1,
|
||||
xOffset: 0,
|
||||
yOffset: 1,
|
||||
items: [],
|
||||
...partialSection,
|
||||
},
|
||||
{
|
||||
id: sectionId,
|
||||
kind: "empty",
|
||||
position: 0,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
@@ -808,7 +813,7 @@ describe("saveBoard should save full board", () => {
|
||||
expect(addedSection).toBeDefined();
|
||||
expect(addedSection.id).toBe(newSectionId);
|
||||
expect(addedSection.kind).toBe(partialSection.kind);
|
||||
expect(addedSection.position).toBe(1);
|
||||
expect(addedSection.yOffset).toBe(1);
|
||||
if ("name" in partialSection) {
|
||||
expect(addedSection.name).toBe(partialSection.name);
|
||||
}
|
||||
@@ -830,7 +835,8 @@ describe("saveBoard should save full board", () => {
|
||||
{
|
||||
id: sectionId,
|
||||
kind: "empty",
|
||||
position: 0,
|
||||
yOffset: 0,
|
||||
xOffset: 0,
|
||||
items: [
|
||||
{
|
||||
id: newItemId,
|
||||
@@ -899,7 +905,8 @@ describe("saveBoard should save full board", () => {
|
||||
{
|
||||
id: sectionId,
|
||||
kind: "empty",
|
||||
position: 0,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
items: [
|
||||
{
|
||||
id: itemId,
|
||||
@@ -956,7 +963,8 @@ describe("saveBoard should save full board", () => {
|
||||
id: newSectionId,
|
||||
kind: "category",
|
||||
name: "Before",
|
||||
position: 1,
|
||||
yOffset: 1,
|
||||
xOffset: 0,
|
||||
boardId,
|
||||
});
|
||||
|
||||
@@ -966,7 +974,8 @@ describe("saveBoard should save full board", () => {
|
||||
{
|
||||
id: sectionId,
|
||||
kind: "category",
|
||||
position: 1,
|
||||
yOffset: 1,
|
||||
xOffset: 0,
|
||||
name: "Test",
|
||||
items: [],
|
||||
},
|
||||
@@ -974,7 +983,8 @@ describe("saveBoard should save full board", () => {
|
||||
id: newSectionId,
|
||||
kind: "category",
|
||||
name: "After",
|
||||
position: 0,
|
||||
yOffset: 0,
|
||||
xOffset: 0,
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
@@ -992,12 +1002,12 @@ describe("saveBoard should save full board", () => {
|
||||
const firstSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === sectionId));
|
||||
expect(firstSection.id).toBe(sectionId);
|
||||
expect(firstSection.kind).toBe("empty");
|
||||
expect(firstSection.position).toBe(1);
|
||||
expect(firstSection.yOffset).toBe(1);
|
||||
expect(firstSection.name).toBe(null);
|
||||
const secondSection = expectToBeDefined(definedBoard.sections.find((section) => section.id === newSectionId));
|
||||
expect(secondSection.id).toBe(newSectionId);
|
||||
expect(secondSection.kind).toBe("category");
|
||||
expect(secondSection.position).toBe(0);
|
||||
expect(secondSection.yOffset).toBe(0);
|
||||
expect(secondSection.name).toBe("After");
|
||||
});
|
||||
it("should update item when present in input", async () => {
|
||||
@@ -1013,7 +1023,8 @@ describe("saveBoard should save full board", () => {
|
||||
{
|
||||
id: sectionId,
|
||||
kind: "empty",
|
||||
position: 0,
|
||||
yOffset: 0,
|
||||
xOffset: 0,
|
||||
items: [
|
||||
{
|
||||
id: itemId,
|
||||
@@ -1268,7 +1279,8 @@ const createFullBoardAsync = async (db: Database, name: string) => {
|
||||
await db.insert(sections).values({
|
||||
id: sectionId,
|
||||
kind: "empty",
|
||||
position: 0,
|
||||
yOffset: 0,
|
||||
xOffset: 0,
|
||||
boardId,
|
||||
});
|
||||
|
||||
|
||||
6
packages/db/migrations/mysql/0006_young_micromax.sql
Normal file
6
packages/db/migrations/mysql/0006_young_micromax.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE `section` RENAME COLUMN `position` TO `y_offset`;--> statement-breakpoint
|
||||
ALTER TABLE `section` ADD `x_offset` int NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `section` ADD `width` int;--> statement-breakpoint
|
||||
ALTER TABLE `section` ADD `height` int;--> statement-breakpoint
|
||||
ALTER TABLE `section` ADD `parent_section_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `section` ADD CONSTRAINT `section_parent_section_id_section_id_fk` FOREIGN KEY (`parent_section_id`) REFERENCES `section`(`id`) ON DELETE cascade ON UPDATE no action;
|
||||
1367
packages/db/migrations/mysql/meta/0006_snapshot.json
Normal file
1367
packages/db/migrations/mysql/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
||||
"when": 1722068832607,
|
||||
"tag": "0005_soft_microbe",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "5",
|
||||
"when": 1722517058725,
|
||||
"tag": "0006_young_micromax",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
35
packages/db/migrations/sqlite/0006_windy_doctor_faustus.sql
Normal file
35
packages/db/migrations/sqlite/0006_windy_doctor_faustus.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
COMMIT TRANSACTION;
|
||||
--> statement-breakpoint
|
||||
PRAGMA foreign_keys = OFF;
|
||||
--> statement-breakpoint
|
||||
BEGIN TRANSACTION;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `section` RENAME TO `__section_old`;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `section` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`board_id` text NOT NULL,
|
||||
`kind` text NOT NULL,
|
||||
`x_offset` integer NOT NULL,
|
||||
`y_offset` integer NOT NULL,
|
||||
`width` integer,
|
||||
`height` integer,
|
||||
`name` text,
|
||||
`parent_section_id` text,
|
||||
FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
FOREIGN KEY (`parent_section_id`) REFERENCES `section`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `section` SELECT `id`, `board_id`, `kind`, 0, `position`, null, null, `name`, null FROM `__section_old`;
|
||||
--> statement-breakpoint
|
||||
DROP TABLE `__section_old`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `section` RENAME TO `__section_old`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `__section_old` RENAME TO `section`;
|
||||
--> statement-breakpoint
|
||||
COMMIT TRANSACTION;
|
||||
--> statement-breakpoint
|
||||
PRAGMA foreign_keys = ON;
|
||||
--> statement-breakpoint
|
||||
BEGIN TRANSACTION;
|
||||
1310
packages/db/migrations/sqlite/meta/0006_snapshot.json
Normal file
1310
packages/db/migrations/sqlite/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
||||
"when": 1722014142492,
|
||||
"tag": "0005_lean_random",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1722517033483,
|
||||
"tag": "0006_windy_doctor_faustus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -269,8 +269,14 @@ export const sections = mysqlTable("section", {
|
||||
.notNull()
|
||||
.references(() => boards.id, { onDelete: "cascade" }),
|
||||
kind: text("kind").$type<SectionKind>().notNull(),
|
||||
position: int("position").notNull(),
|
||||
xOffset: int("x_offset").notNull(),
|
||||
yOffset: int("y_offset").notNull(),
|
||||
width: int("width"),
|
||||
height: int("height"),
|
||||
name: text("name"),
|
||||
parentSectionId: text("parent_section_id").references((): AnyMySqlColumn => sections.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
});
|
||||
|
||||
export const items = mysqlTable("item", {
|
||||
|
||||
@@ -272,8 +272,14 @@ export const sections = sqliteTable("section", {
|
||||
.notNull()
|
||||
.references(() => boards.id, { onDelete: "cascade" }),
|
||||
kind: text("kind").$type<SectionKind>().notNull(),
|
||||
position: int("position").notNull(),
|
||||
xOffset: int("x_offset").notNull(),
|
||||
yOffset: int("y_offset").notNull(),
|
||||
width: int("width"),
|
||||
height: int("height"),
|
||||
name: text("name"),
|
||||
parentSectionId: text("parent_section_id").references((): AnySQLiteColumn => sections.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
});
|
||||
|
||||
export const items = sqliteTable("item", {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const sectionKinds = ["category", "empty"] as const;
|
||||
export const sectionKinds = ["category", "empty", "dynamic"] as const;
|
||||
export type SectionKind = (typeof sectionKinds)[number];
|
||||
|
||||
@@ -634,6 +634,17 @@ export default {
|
||||
mantineReactTable: MRT_Localization_EN,
|
||||
},
|
||||
section: {
|
||||
dynamic: {
|
||||
action: {
|
||||
create: "New dynamic section",
|
||||
remove: "Remove dynamic section",
|
||||
},
|
||||
remove: {
|
||||
title: "Remove dynamic section",
|
||||
message:
|
||||
"Are you sure you want to remove this dynamic section? Items will be moved at the same location in the parent section.",
|
||||
},
|
||||
},
|
||||
category: {
|
||||
field: {
|
||||
name: {
|
||||
|
||||
@@ -41,7 +41,8 @@ const createCategorySchema = <TItemSchema extends z.ZodTypeAny>(itemSchema: TIte
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
kind: z.literal("category"),
|
||||
position: z.number(),
|
||||
yOffset: z.number(),
|
||||
xOffset: z.number(),
|
||||
items: z.array(itemSchema),
|
||||
});
|
||||
|
||||
@@ -49,9 +50,22 @@ const createEmptySchema = <TItemSchema extends z.ZodTypeAny>(itemSchema: TItemSc
|
||||
z.object({
|
||||
id: z.string(),
|
||||
kind: z.literal("empty"),
|
||||
position: z.number(),
|
||||
yOffset: z.number(),
|
||||
xOffset: z.number(),
|
||||
items: z.array(itemSchema),
|
||||
});
|
||||
|
||||
const createDynamicSchema = <TItemSchema extends z.ZodTypeAny>(itemSchema: TItemSchema) =>
|
||||
z.object({
|
||||
id: z.string(),
|
||||
kind: z.literal("dynamic"),
|
||||
yOffset: z.number(),
|
||||
xOffset: z.number(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
items: z.array(itemSchema),
|
||||
parentSectionId: z.string(),
|
||||
});
|
||||
|
||||
export const createSectionSchema = <TItemSchema extends z.ZodTypeAny>(itemSchema: TItemSchema) =>
|
||||
z.union([createCategorySchema(itemSchema), createEmptySchema(itemSchema)]);
|
||||
z.union([createCategorySchema(itemSchema), createEmptySchema(itemSchema), createDynamicSchema(itemSchema)]);
|
||||
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -75,8 +75,8 @@ importers:
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../packages/form
|
||||
'@homarr/gridstack':
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3
|
||||
'@homarr/integrations':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../packages/integrations
|
||||
@@ -2117,8 +2117,8 @@ packages:
|
||||
'@floating-ui/utils@0.2.1':
|
||||
resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==}
|
||||
|
||||
'@homarr/gridstack@1.0.0':
|
||||
resolution: {integrity: sha512-KM9024BipLD9BmtM6jHI8OKLZ1Iy4vZdTfU53ww4qEda/330XQYhIC2SBcQgkNnDB2MTkn/laNSO5gTy+lJg9Q==}
|
||||
'@homarr/gridstack@1.0.3':
|
||||
resolution: {integrity: sha512-qgBYQUQ75mO51YSm/02aRmfJMRz7bWEqFQAQii5lwKb73hlAtDHTuGBeEL5H/mqxFIKEbxPtjeL/Eax9UvXUhA==}
|
||||
|
||||
'@humanwhocodes/module-importer@1.0.1':
|
||||
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
|
||||
@@ -7222,7 +7222,7 @@ snapshots:
|
||||
|
||||
'@floating-ui/utils@0.2.1': {}
|
||||
|
||||
'@homarr/gridstack@1.0.0': {}
|
||||
'@homarr/gridstack@1.0.3': {}
|
||||
|
||||
'@humanwhocodes/module-importer@1.0.1': {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user