Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6143e25686 | ||
|
|
384d83ef76 | ||
|
|
998161b1d9 | ||
|
|
a6e060265a | ||
|
|
d8b278d716 | ||
|
|
813a91cb01 | ||
|
|
990452a877 | ||
|
|
0165e2e415 | ||
|
|
514c6edc8f | ||
|
|
fdcfb775fa | ||
|
|
0843ccda99 | ||
|
|
2b6eb88a53 | ||
|
|
e65c3cd801 |
No files matched your search
@@ -17,7 +17,7 @@ import {
|
|||||||
IconSettings,
|
IconSettings,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { applyAutoLayoutToBoard, clientApi } from "@homarr/api/client";
|
||||||
import { useSession } from "@homarr/auth/client";
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
@@ -165,7 +165,7 @@ const EditModeMenu = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
if (isEditMode) return saveBoard(board);
|
if (isEditMode) return saveBoard(applyAutoLayoutToBoard(board));
|
||||||
open();
|
open();
|
||||||
}, [board, isEditMode, saveBoard, open]);
|
}, [board, isEditMode, saveBoard, open]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core";
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
ChipGroup,
|
||||||
|
Fieldset,
|
||||||
|
Grid,
|
||||||
|
Group,
|
||||||
|
Input,
|
||||||
|
InputWrapper,
|
||||||
|
NumberInput,
|
||||||
|
RadioGroup,
|
||||||
|
SimpleGrid,
|
||||||
|
Slider,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { createId } from "@homarr/common";
|
import { createId } from "@homarr/common";
|
||||||
|
import { boardLayoutModes } from "@homarr/definitions";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { CustomRadioCard } from "@homarr/ui";
|
||||||
import { boardSaveLayoutsSchema } from "@homarr/validation/board";
|
import { boardSaveLayoutsSchema } from "@homarr/validation/board";
|
||||||
|
|
||||||
import type { Board } from "../../_types";
|
import type { Board } from "../../_types";
|
||||||
@@ -25,6 +45,8 @@ export const LayoutSettingsContent = ({ board }: Props) => {
|
|||||||
const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), {
|
const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), {
|
||||||
initialValues: {
|
initialValues: {
|
||||||
layouts: board.layouts,
|
layouts: board.layouts,
|
||||||
|
layoutMode: board.layoutMode,
|
||||||
|
baseLayoutId: board.baseLayoutId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,6 +60,53 @@ export const LayoutSettingsContent = ({ board }: Props) => {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
|
<RadioGroup {...form.getInputProps("layoutMode")} label={t("board.field.layoutMode.label")}>
|
||||||
|
<SimpleGrid cols={{ sm: 2, xs: 1 }} spacing="md">
|
||||||
|
{boardLayoutModes.values.map((layoutMode) => (
|
||||||
|
<Box w="100%" key={layoutMode} pos="relative">
|
||||||
|
<CustomRadioCard
|
||||||
|
value={layoutMode}
|
||||||
|
label={t(`board.field.layoutMode.option.${layoutMode}.label`)}
|
||||||
|
description={t(`board.field.layoutMode.option.${layoutMode}.description`)}
|
||||||
|
/>
|
||||||
|
{layoutMode === boardLayoutModes.defaultValue && (
|
||||||
|
<Badge pos="absolute" top="8px" right="8px" variant="light" size="sm">
|
||||||
|
{t("common.select.badge.recommended")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
{form.values.layoutMode === "auto" && (
|
||||||
|
<InputWrapper label={t("board.field.baseLayout.label")} description={t("board.field.baseLayout.description")}>
|
||||||
|
<ChipGroup {...form.getInputProps("baseLayoutId")} multiple={false}>
|
||||||
|
<SimpleGrid
|
||||||
|
cols={{
|
||||||
|
lg: Math.min(6, form.values.layouts.length),
|
||||||
|
sm: Math.min(4, form.values.layouts.length),
|
||||||
|
xs: Math.min(2, form.values.layouts.length),
|
||||||
|
}}
|
||||||
|
spacing="md"
|
||||||
|
mt="sm"
|
||||||
|
>
|
||||||
|
{form.values.layouts.map((layout) => (
|
||||||
|
<Chip
|
||||||
|
key={layout.id}
|
||||||
|
value={layout.id}
|
||||||
|
w="100%"
|
||||||
|
size="sm"
|
||||||
|
styles={{ label: { width: "100%", justifyContent: "center" } }}
|
||||||
|
>
|
||||||
|
{layout.name}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</ChipGroup>
|
||||||
|
</InputWrapper>
|
||||||
|
)}
|
||||||
|
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
|
||||||
@@ -82,18 +151,21 @@ export const LayoutSettingsContent = ({ board }: Props) => {
|
|||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
{form.values.layouts.length >= 2 && (
|
{form.values.layouts.length >= 2 && form.values.baseLayoutId !== layout.id && (
|
||||||
<Group justify="end">
|
<Group justify="end">
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
form.setValues((previous) =>
|
form.setValues((previous) => {
|
||||||
previous.layouts !== undefined && previous.layouts.length >= 2
|
if (!previous.layouts) return previous;
|
||||||
? {
|
if (previous.layouts.length < 2) return previous;
|
||||||
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
|
if (previous.baseLayoutId === layout.id) return previous;
|
||||||
}
|
|
||||||
: previous,
|
return {
|
||||||
);
|
...previous,
|
||||||
|
layouts: previous.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
|
||||||
|
};
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("common.action.remove")}
|
{t("common.action.remove")}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export class BoardMockBuilder {
|
|||||||
private readonly board: Board;
|
private readonly board: Board;
|
||||||
|
|
||||||
constructor(board?: Partial<Omit<Board, "groupPermissions" | "userPermissions" | "sections" | "items" | "layouts">>) {
|
constructor(board?: Partial<Omit<Board, "groupPermissions" | "userPermissions" | "sections" | "items" | "layouts">>) {
|
||||||
|
const baseLayoutId = createId();
|
||||||
this.board = {
|
this.board = {
|
||||||
id: createId(),
|
id: createId(),
|
||||||
backgroundImageRepeat: "no-repeat",
|
backgroundImageRepeat: "no-repeat",
|
||||||
@@ -41,12 +42,14 @@ export class BoardMockBuilder {
|
|||||||
items: [],
|
items: [],
|
||||||
layouts: [
|
layouts: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: baseLayoutId,
|
||||||
name: "Base",
|
name: "Base",
|
||||||
columnCount: 12,
|
columnCount: 12,
|
||||||
breakpoint: 0,
|
breakpoint: 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
baseLayoutId,
|
||||||
|
layoutMode: "auto",
|
||||||
...board,
|
...board,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,3 +17,5 @@ export const fetchApi = createTRPCClient<AppRouter>({
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export { applyAutoLayoutToBoard } from "./router/board/board-layout";
|
||||||
@@ -5,7 +5,7 @@ import { z } from "zod/v4";
|
|||||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||||
import { createId } from "@homarr/common";
|
import { createId } from "@homarr/common";
|
||||||
import type { DeviceType } from "@homarr/common/server";
|
import type { DeviceType } from "@homarr/common/server";
|
||||||
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
|
import type { Database, InferInsertModel, InferSelectModel } from "@homarr/db";
|
||||||
import { and, asc, eq, handleTransactionsAsync, inArray, isNull, like, not, or, sql } from "@homarr/db";
|
import { and, asc, eq, handleTransactionsAsync, inArray, isNull, like, not, or, sql } from "@homarr/db";
|
||||||
import { createDbInsertCollectionWithoutTransaction } from "@homarr/db/collection";
|
import { createDbInsertCollectionWithoutTransaction } from "@homarr/db/collection";
|
||||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||||
@@ -27,13 +27,11 @@ import {
|
|||||||
sections,
|
sections,
|
||||||
users,
|
users,
|
||||||
} from "@homarr/db/schema";
|
} from "@homarr/db/schema";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
|
||||||
import {
|
import {
|
||||||
emptySuperJSON,
|
emptySuperJSON,
|
||||||
everyoneGroup,
|
everyoneGroup,
|
||||||
getPermissionsWithChildren,
|
getPermissionsWithChildren,
|
||||||
getPermissionsWithParents,
|
getPermissionsWithParents,
|
||||||
widgetKinds,
|
|
||||||
} from "@homarr/definitions";
|
} from "@homarr/definitions";
|
||||||
import { importOldmarrAsync } from "@homarr/old-import";
|
import { importOldmarrAsync } from "@homarr/old-import";
|
||||||
import { importJsonFileSchema } from "@homarr/old-import/shared";
|
import { importJsonFileSchema } from "@homarr/old-import/shared";
|
||||||
@@ -50,13 +48,11 @@ import {
|
|||||||
boardSaveSchema,
|
boardSaveSchema,
|
||||||
} from "@homarr/validation/board";
|
} from "@homarr/validation/board";
|
||||||
import { byIdSchema } from "@homarr/validation/common";
|
import { byIdSchema } from "@homarr/validation/common";
|
||||||
import { zodUnionFromArray } from "@homarr/validation/enums";
|
|
||||||
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
|
|
||||||
import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
|
|
||||||
|
|
||||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||||
import { generateResponsiveGridFor } from "./board/grid-algorithm";
|
import { applyAutoLayoutToBoard, getUpdatedBoardLayout } from "./board/board-layout";
|
||||||
|
import { getFullBoardWithWhereAsync } from "./board/full-board-query";
|
||||||
|
|
||||||
export const boardRouter = createTRPCRouter({
|
export const boardRouter = createTRPCRouter({
|
||||||
exists: permissionRequiredProcedure
|
exists: permissionRequiredProcedure
|
||||||
@@ -279,6 +275,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
isPublic: input.isPublic,
|
isPublic: input.isPublic,
|
||||||
creatorId: ctx.session.user.id,
|
creatorId: ctx.session.user.id,
|
||||||
|
layoutMode: input.layoutMode,
|
||||||
});
|
});
|
||||||
createBoardCollection.sections.push({
|
createBoardCollection.sections.push({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -287,8 +284,9 @@ export const boardRouter = createTRPCRouter({
|
|||||||
yOffset: 0,
|
yOffset: 0,
|
||||||
boardId,
|
boardId,
|
||||||
});
|
});
|
||||||
|
const baseLayoutId = createId();
|
||||||
createBoardCollection.layouts.push({
|
createBoardCollection.layouts.push({
|
||||||
id: createId(),
|
id: baseLayoutId,
|
||||||
name: "Base",
|
name: "Base",
|
||||||
columnCount: input.columnCount,
|
columnCount: input.columnCount,
|
||||||
breakpoint: 0,
|
breakpoint: 0,
|
||||||
@@ -296,6 +294,10 @@ export const boardRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
await createBoardCollection.insertAllAsync(ctx.db);
|
await createBoardCollection.insertAllAsync(ctx.db);
|
||||||
|
// Define the base layout (only for automatic layout mode)
|
||||||
|
if (input.layoutMode === "auto") {
|
||||||
|
await ctx.db.update(boards).set({ baseLayoutId }).where(eq(boards.id, boardId));
|
||||||
|
}
|
||||||
|
|
||||||
if (!user?.homeBoardId) {
|
if (!user?.homeBoardId) {
|
||||||
await ctx.db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, ctx.session.user.id));
|
await ctx.db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, ctx.session.user.id));
|
||||||
@@ -582,13 +584,15 @@ export const boardRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
|
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
|
||||||
|
|
||||||
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
|
const board = await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
|
||||||
|
return applyAutoLayoutToBoard(board);
|
||||||
}),
|
}),
|
||||||
getBoardByName: publicProcedure.input(boardByNameSchema).query(async ({ input, ctx }) => {
|
getBoardByName: publicProcedure.input(boardByNameSchema).query(async ({ input, ctx }) => {
|
||||||
const boardWhere = eq(sql`UPPER(${boards.name})`, input.name.toUpperCase());
|
const boardWhere = eq(sql`UPPER(${boards.name})`, input.name.toUpperCase());
|
||||||
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
|
await throwIfActionForbiddenAsync(ctx, boardWhere, "view");
|
||||||
|
|
||||||
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
|
const board = await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
|
||||||
|
return applyAutoLayoutToBoard(board);
|
||||||
}),
|
}),
|
||||||
saveLayouts: protectedProcedure.input(boardSaveLayoutsSchema).mutation(async ({ ctx, input }) => {
|
saveLayouts: protectedProcedure.input(boardSaveLayoutsSchema).mutation(async ({ ctx, input }) => {
|
||||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
|
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
|
||||||
@@ -596,13 +600,32 @@ export const boardRouter = createTRPCRouter({
|
|||||||
const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
|
const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
|
||||||
|
|
||||||
const addedLayouts = filterAddedItems(input.layouts, board.layouts);
|
const addedLayouts = filterAddedItems(input.layouts, board.layouts);
|
||||||
|
const removedLayouts = filterRemovedItems(input.layouts, board.layouts);
|
||||||
|
|
||||||
|
if (input.layoutMode === "auto" && input.baseLayoutId === null) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Base layout must be set when using automatic layout mode",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedLayouts.some((layout) => layout.id === input.baseLayoutId)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Cannot remove base layout",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const layoutsToInsert: InferInsertModel<typeof layouts>[] = [];
|
const layoutsToInsert: InferInsertModel<typeof layouts>[] = [];
|
||||||
const itemSectionLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = [];
|
const itemSectionLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = [];
|
||||||
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = [];
|
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = [];
|
||||||
|
|
||||||
|
let baseLayoutId = input.baseLayoutId;
|
||||||
for (const addedLayout of addedLayouts) {
|
for (const addedLayout of addedLayouts) {
|
||||||
const layoutId = createId();
|
const layoutId = createId();
|
||||||
|
if (addedLayout.id === input.baseLayoutId) {
|
||||||
|
baseLayoutId = layoutId;
|
||||||
|
}
|
||||||
|
|
||||||
layoutsToInsert.push({
|
layoutsToInsert.push({
|
||||||
id: layoutId,
|
id: layoutId,
|
||||||
@@ -709,11 +732,18 @@ export const boardRouter = createTRPCRouter({
|
|||||||
.where(eq(layouts.id, updatedLayout.id));
|
.where(eq(layouts.id, updatedLayout.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
const removedLayouts = filterRemovedItems(input.layouts, board.layouts);
|
|
||||||
const removedLayoutIds = removedLayouts.map((layout) => layout.id);
|
const removedLayoutIds = removedLayouts.map((layout) => layout.id);
|
||||||
if (removedLayoutIds.length > 0) {
|
if (removedLayoutIds.length > 0) {
|
||||||
await ctx.db.delete(layouts).where(inArray(layouts.id, removedLayoutIds));
|
await ctx.db.delete(layouts).where(inArray(layouts.id, removedLayoutIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ctx.db
|
||||||
|
.update(boards)
|
||||||
|
.set({
|
||||||
|
baseLayoutId,
|
||||||
|
layoutMode: input.layoutMode,
|
||||||
|
})
|
||||||
|
.where(eq(boards.id, input.id));
|
||||||
}),
|
}),
|
||||||
savePartialBoardSettings: protectedProcedure
|
savePartialBoardSettings: protectedProcedure
|
||||||
.input(boardSavePartialSettingsSchema.and(z.object({ id: z.string() })))
|
.input(boardSavePartialSettingsSchema.and(z.object({ id: z.string() })))
|
||||||
@@ -1408,244 +1438,6 @@ const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredId
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUpdatedBoardLayout = (
|
|
||||||
board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>,
|
|
||||||
options: {
|
|
||||||
previous: {
|
|
||||||
layoutId: string;
|
|
||||||
columnCount: number;
|
|
||||||
};
|
|
||||||
current: {
|
|
||||||
layoutId: string;
|
|
||||||
columnCount: number;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
const itemSectionLayoutsCollection: InferInsertModel<typeof itemLayouts>[] = [];
|
|
||||||
const sectionLayoutsCollection: InferInsertModel<typeof sectionLayouts>[] = [];
|
|
||||||
|
|
||||||
const elements = getElementsForLayout(board, options.previous.layoutId);
|
|
||||||
const rootSections = board.sections.filter((section) => section.kind !== "dynamic");
|
|
||||||
|
|
||||||
for (const rootSection of rootSections) {
|
|
||||||
const result = generateResponsiveGridFor({
|
|
||||||
items: elements,
|
|
||||||
previousWidth: options.previous.columnCount,
|
|
||||||
width: options.current.columnCount,
|
|
||||||
sectionId: rootSection.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
itemSectionLayoutsCollection.push(
|
|
||||||
...board.items
|
|
||||||
.map((item): InferInsertModel<typeof itemLayouts> | null => {
|
|
||||||
const currentElement = result.items.find((element) => element.type === "item" && element.id === item.id);
|
|
||||||
|
|
||||||
if (!currentElement) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
itemId: item.id,
|
|
||||||
layoutId: options.current.layoutId,
|
|
||||||
sectionId: currentElement.sectionId,
|
|
||||||
height: currentElement.height,
|
|
||||||
width: currentElement.width,
|
|
||||||
xOffset: currentElement.xOffset,
|
|
||||||
yOffset: currentElement.yOffset,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((item) => item !== null),
|
|
||||||
);
|
|
||||||
|
|
||||||
sectionLayoutsCollection.push(
|
|
||||||
...board.sections
|
|
||||||
.filter((section) => section.kind === "dynamic")
|
|
||||||
.map((section): InferInsertModel<typeof sectionLayouts> | null => {
|
|
||||||
const currentElement = result.items.find(
|
|
||||||
(element) => element.type === "section" && element.id === section.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!currentElement) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
layoutId: options.current.layoutId,
|
|
||||||
sectionId: section.id,
|
|
||||||
parentSectionId: currentElement.sectionId,
|
|
||||||
height: currentElement.height,
|
|
||||||
width: currentElement.width,
|
|
||||||
xOffset: currentElement.xOffset,
|
|
||||||
yOffset: currentElement.yOffset,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((section) => section !== null),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
itemSectionLayouts: itemSectionLayoutsCollection,
|
|
||||||
sectionLayouts: sectionLayoutsCollection,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getElementsForLayout = (board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>, layoutId: string) => {
|
|
||||||
const sectionElements = board.sections
|
|
||||||
.filter((section) => section.kind === "dynamic")
|
|
||||||
.map((section) => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
const clonedLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layoutId)!;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: section.id,
|
|
||||||
type: "section" as const,
|
|
||||||
height: clonedLayout.height,
|
|
||||||
width: clonedLayout.width,
|
|
||||||
xOffset: clonedLayout.xOffset,
|
|
||||||
yOffset: clonedLayout.yOffset,
|
|
||||||
sectionId: clonedLayout.parentSectionId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const itemElements = board.items.map((item) => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
const clonedLayout = item.layouts.find((itemLayout) => itemLayout.layoutId === layoutId)!;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: item.id,
|
|
||||||
type: "item" as const,
|
|
||||||
height: clonedLayout.height,
|
|
||||||
width: clonedLayout.width,
|
|
||||||
xOffset: clonedLayout.xOffset,
|
|
||||||
yOffset: clonedLayout.yOffset,
|
|
||||||
sectionId: clonedLayout.sectionId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...itemElements, ...sectionElements];
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, userId: string | null) => {
|
|
||||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
|
||||||
where: eq(groupMembers.userId, userId ?? ""),
|
|
||||||
});
|
|
||||||
const board = await db.query.boards.findFirst({
|
|
||||||
where,
|
|
||||||
with: {
|
|
||||||
creator: {
|
|
||||||
columns: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
image: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
sections: {
|
|
||||||
with: {
|
|
||||||
collapseStates: {
|
|
||||||
where: eq(sectionCollapseStates.userId, userId ?? ""),
|
|
||||||
},
|
|
||||||
layouts: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
items: {
|
|
||||||
with: {
|
|
||||||
integrations: {
|
|
||||||
with: {
|
|
||||||
integration: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layouts: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layouts: true,
|
|
||||||
userPermissions: {
|
|
||||||
where: eq(boardUserPermissions.userId, userId ?? ""),
|
|
||||||
columns: {
|
|
||||||
permission: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
groupPermissions: {
|
|
||||||
where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!board) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "NOT_FOUND",
|
|
||||||
message: "Board not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { sections, items, layouts, ...otherBoardProperties } = board;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...otherBoardProperties,
|
|
||||||
layouts: layouts
|
|
||||||
.map(({ boardId: _, ...layout }) => layout)
|
|
||||||
.sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint),
|
|
||||||
sections: sections.map(({ collapseStates, ...section }) =>
|
|
||||||
parseSection({
|
|
||||||
...section,
|
|
||||||
xOffset: section.xOffset,
|
|
||||||
yOffset: section.yOffset,
|
|
||||||
options: superjson.parse(section.options ?? emptySuperJSON),
|
|
||||||
layouts: section.layouts.map((layout) => ({
|
|
||||||
xOffset: layout.xOffset,
|
|
||||||
yOffset: layout.yOffset,
|
|
||||||
width: layout.width,
|
|
||||||
height: layout.height,
|
|
||||||
parentSectionId: layout.parentSectionId,
|
|
||||||
layoutId: layout.layoutId,
|
|
||||||
})),
|
|
||||||
collapsed: collapseStates.at(0)?.collapsed ?? false,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
items: items.map(({ integrations: itemIntegrations, ...item }) =>
|
|
||||||
parseItem({
|
|
||||||
...item,
|
|
||||||
layouts: item.layouts.map((layout) => ({
|
|
||||||
xOffset: layout.xOffset,
|
|
||||||
yOffset: layout.yOffset,
|
|
||||||
width: layout.width,
|
|
||||||
height: layout.height,
|
|
||||||
layoutId: layout.layoutId,
|
|
||||||
sectionId: layout.sectionId,
|
|
||||||
})),
|
|
||||||
integrationIds: itemIntegrations.map((item) => item.integration.id),
|
|
||||||
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
|
|
||||||
options: superjson.parse<Record<string, unknown>>(item.options),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const forKind = <T extends WidgetKind>(kind: T) =>
|
|
||||||
z.object({
|
|
||||||
kind: z.literal(kind),
|
|
||||||
options: z.record(z.string(), z.unknown()),
|
|
||||||
});
|
|
||||||
|
|
||||||
const outputItemSchema = zodUnionFromArray(widgetKinds.map((kind) => forKind(kind))).and(sharedItemSchema);
|
|
||||||
|
|
||||||
const parseItem = (item: unknown) => {
|
|
||||||
const result = outputItemSchema.safeParse(item);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error.message);
|
|
||||||
}
|
|
||||||
return result.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseSection = (section: unknown) => {
|
|
||||||
const result = sectionSchema.safeParse(section);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error.message);
|
|
||||||
}
|
|
||||||
return result.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const filterAddedItems = <TInput extends { id: string }>(inputArray: TInput[], dbArray: TInput[]) =>
|
const filterAddedItems = <TInput extends { id: string }>(inputArray: TInput[], dbArray: TInput[]) =>
|
||||||
inputArray.filter((inputItem) => !dbArray.some((dbItem) => dbItem.id === inputItem.id));
|
inputArray.filter((inputItem) => !dbArray.some((dbItem) => dbItem.id === inputItem.id));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import type { Modify } from "@homarr/common/types";
|
||||||
|
import type { InferInsertModel } from "@homarr/db";
|
||||||
|
import type { itemLayouts, sectionLayouts } from "@homarr/db/schema";
|
||||||
|
|
||||||
|
import type { getFullBoardWithWhereAsync } from "./full-board-query";
|
||||||
|
import { generateResponsiveGridFor } from "./grid-algorithm";
|
||||||
|
|
||||||
|
type DynamicSectionLayout = Modify<InferInsertModel<typeof sectionLayouts>, { parentSectionId: string }>;
|
||||||
|
|
||||||
|
export const getUpdatedBoardLayout = (
|
||||||
|
board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>,
|
||||||
|
options: {
|
||||||
|
previous: {
|
||||||
|
layoutId: string;
|
||||||
|
columnCount: number;
|
||||||
|
};
|
||||||
|
current: {
|
||||||
|
layoutId: string;
|
||||||
|
columnCount: number;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const itemSectionLayoutsCollection: InferInsertModel<typeof itemLayouts>[] = [];
|
||||||
|
const sectionLayoutsCollection: DynamicSectionLayout[] = [];
|
||||||
|
|
||||||
|
const elements = getElementsForLayout(board, options.previous.layoutId);
|
||||||
|
const rootSections = board.sections.filter((section) => section.kind !== "dynamic");
|
||||||
|
|
||||||
|
for (const rootSection of rootSections) {
|
||||||
|
const result = generateResponsiveGridFor({
|
||||||
|
items: elements,
|
||||||
|
previousWidth: options.previous.columnCount,
|
||||||
|
width: options.current.columnCount,
|
||||||
|
sectionId: rootSection.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
itemSectionLayoutsCollection.push(
|
||||||
|
...board.items
|
||||||
|
.map((item): InferInsertModel<typeof itemLayouts> | null => {
|
||||||
|
const currentElement = result.items.find((element) => element.type === "item" && element.id === item.id);
|
||||||
|
|
||||||
|
if (!currentElement) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
itemId: item.id,
|
||||||
|
layoutId: options.current.layoutId,
|
||||||
|
sectionId: currentElement.sectionId,
|
||||||
|
height: currentElement.height,
|
||||||
|
width: currentElement.width,
|
||||||
|
xOffset: currentElement.xOffset,
|
||||||
|
yOffset: currentElement.yOffset,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item) => item !== null),
|
||||||
|
);
|
||||||
|
|
||||||
|
sectionLayoutsCollection.push(
|
||||||
|
...board.sections
|
||||||
|
.filter((section) => section.kind === "dynamic")
|
||||||
|
.map((section): DynamicSectionLayout | null => {
|
||||||
|
const currentElement = result.items.find(
|
||||||
|
(element) => element.type === "section" && element.id === section.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!currentElement) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
layoutId: options.current.layoutId,
|
||||||
|
sectionId: section.id,
|
||||||
|
parentSectionId: currentElement.sectionId,
|
||||||
|
height: currentElement.height,
|
||||||
|
width: currentElement.width,
|
||||||
|
xOffset: currentElement.xOffset,
|
||||||
|
yOffset: currentElement.yOffset,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((section) => section !== null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
itemSectionLayouts: itemSectionLayoutsCollection,
|
||||||
|
sectionLayouts: sectionLayoutsCollection,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getElementsForLayout = (board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>, layoutId: string) => {
|
||||||
|
const sectionElements = board.sections
|
||||||
|
.filter((section) => section.kind === "dynamic")
|
||||||
|
.map((section) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const clonedLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layoutId)!;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: section.id,
|
||||||
|
type: "section" as const,
|
||||||
|
height: clonedLayout.height,
|
||||||
|
width: clonedLayout.width,
|
||||||
|
xOffset: clonedLayout.xOffset,
|
||||||
|
yOffset: clonedLayout.yOffset,
|
||||||
|
sectionId: clonedLayout.parentSectionId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const itemElements = board.items.map((item) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const clonedLayout = item.layouts.find((itemLayout) => itemLayout.layoutId === layoutId)!;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
type: "item" as const,
|
||||||
|
height: clonedLayout.height,
|
||||||
|
width: clonedLayout.width,
|
||||||
|
xOffset: clonedLayout.xOffset,
|
||||||
|
yOffset: clonedLayout.yOffset,
|
||||||
|
sectionId: clonedLayout.sectionId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...itemElements, ...sectionElements];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const applyAutoLayoutToBoard = (
|
||||||
|
board: Awaited<ReturnType<typeof getFullBoardWithWhereAsync>>,
|
||||||
|
): Awaited<ReturnType<typeof getFullBoardWithWhereAsync>> => {
|
||||||
|
if (board.layoutMode === "custom") return board;
|
||||||
|
|
||||||
|
const baseLayout = board.layouts.find((layout) => layout.id === board.baseLayoutId) ?? null;
|
||||||
|
if (!baseLayout) return board;
|
||||||
|
|
||||||
|
const updatedLayoutMap = new Map<string, ReturnType<typeof getUpdatedBoardLayout>>();
|
||||||
|
for (const layout of board.layouts) {
|
||||||
|
if (layout.id === baseLayout.id) continue;
|
||||||
|
|
||||||
|
updatedLayoutMap.set(
|
||||||
|
layout.id,
|
||||||
|
getUpdatedBoardLayout(board, {
|
||||||
|
previous: {
|
||||||
|
layoutId: baseLayout.id,
|
||||||
|
columnCount: baseLayout.columnCount,
|
||||||
|
},
|
||||||
|
current: {
|
||||||
|
layoutId: layout.id,
|
||||||
|
columnCount: layout.columnCount,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...board,
|
||||||
|
items: board.items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
layouts: item.layouts.map(
|
||||||
|
(layoutSection) =>
|
||||||
|
updatedLayoutMap
|
||||||
|
.get(layoutSection.layoutId)
|
||||||
|
?.itemSectionLayouts.find(
|
||||||
|
(updatedLayoutSection) =>
|
||||||
|
updatedLayoutSection.itemId === item.id && updatedLayoutSection.layoutId === layoutSection.layoutId,
|
||||||
|
) ?? layoutSection,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
sections: board.sections.map((section) =>
|
||||||
|
"layouts" in section
|
||||||
|
? {
|
||||||
|
...section,
|
||||||
|
layouts: section.layouts.map(
|
||||||
|
(sectionLayout) =>
|
||||||
|
updatedLayoutMap
|
||||||
|
.get(sectionLayout.layoutId)
|
||||||
|
?.sectionLayouts.find(
|
||||||
|
(updatedSectionLayout) =>
|
||||||
|
updatedSectionLayout.sectionId === section.id &&
|
||||||
|
updatedSectionLayout.layoutId === sectionLayout.layoutId,
|
||||||
|
) ?? sectionLayout,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: section,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import superjson from "superjson";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
import type { Database, SQL } from "@homarr/db";
|
||||||
|
import { eq, inArray } from "@homarr/db";
|
||||||
|
import { boardGroupPermissions, boardUserPermissions, groupMembers, sectionCollapseStates } from "@homarr/db/schema";
|
||||||
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
|
import { emptySuperJSON, widgetKinds } from "@homarr/definitions";
|
||||||
|
import { zodUnionFromArray } from "@homarr/validation/enums";
|
||||||
|
import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
|
||||||
|
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
|
||||||
|
|
||||||
|
export const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, userId: string | null) => {
|
||||||
|
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||||
|
where: eq(groupMembers.userId, userId ?? ""),
|
||||||
|
});
|
||||||
|
const board = await db.query.boards.findFirst({
|
||||||
|
where,
|
||||||
|
with: {
|
||||||
|
creator: {
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sections: {
|
||||||
|
with: {
|
||||||
|
collapseStates: {
|
||||||
|
where: eq(sectionCollapseStates.userId, userId ?? ""),
|
||||||
|
},
|
||||||
|
layouts: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
with: {
|
||||||
|
integrations: {
|
||||||
|
with: {
|
||||||
|
integration: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layouts: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layouts: true,
|
||||||
|
userPermissions: {
|
||||||
|
where: eq(boardUserPermissions.userId, userId ?? ""),
|
||||||
|
columns: {
|
||||||
|
permission: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
groupPermissions: {
|
||||||
|
where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!board) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Board not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sections, items, layouts, ...otherBoardProperties } = board;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...otherBoardProperties,
|
||||||
|
layouts: layouts
|
||||||
|
.map(({ boardId: _, ...layout }) => layout)
|
||||||
|
.sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint),
|
||||||
|
sections: sections.map(({ collapseStates, ...section }) =>
|
||||||
|
parseSection({
|
||||||
|
...section,
|
||||||
|
xOffset: section.xOffset,
|
||||||
|
yOffset: section.yOffset,
|
||||||
|
options: superjson.parse(section.options ?? emptySuperJSON),
|
||||||
|
layouts: section.layouts.map((layout) => ({
|
||||||
|
xOffset: layout.xOffset,
|
||||||
|
yOffset: layout.yOffset,
|
||||||
|
width: layout.width,
|
||||||
|
height: layout.height,
|
||||||
|
parentSectionId: layout.parentSectionId,
|
||||||
|
layoutId: layout.layoutId,
|
||||||
|
})),
|
||||||
|
collapsed: collapseStates.at(0)?.collapsed ?? false,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
items: items.map(({ integrations: itemIntegrations, ...item }) =>
|
||||||
|
parseItem({
|
||||||
|
...item,
|
||||||
|
layouts: item.layouts.map((layout) => ({
|
||||||
|
xOffset: layout.xOffset,
|
||||||
|
yOffset: layout.yOffset,
|
||||||
|
width: layout.width,
|
||||||
|
height: layout.height,
|
||||||
|
layoutId: layout.layoutId,
|
||||||
|
sectionId: layout.sectionId,
|
||||||
|
})),
|
||||||
|
integrationIds: itemIntegrations.map((item) => item.integration.id),
|
||||||
|
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
|
||||||
|
options: superjson.parse<Record<string, unknown>>(item.options),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const forKind = <T extends WidgetKind>(kind: T) =>
|
||||||
|
z.object({
|
||||||
|
kind: z.literal(kind),
|
||||||
|
options: z.record(z.string(), z.unknown()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputItemSchema = zodUnionFromArray(widgetKinds.map((kind) => forKind(kind))).and(sharedItemSchema);
|
||||||
|
|
||||||
|
const parseItem = (item: unknown) => {
|
||||||
|
const result = outputItemSchema.safeParse(item);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error.message);
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseSection = (section: unknown) => {
|
||||||
|
const result = sectionSchema.safeParse(section);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error.message);
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
@@ -56,8 +56,7 @@ export const generateResponsiveGridFor = ({
|
|||||||
dynamicSectionHeightMap.set(dynamicSection.id, result.height);
|
dynamicSectionHeightMap.set(dynamicSection.id, result.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return same positions for items in the current section
|
if (width === previousWidth) {
|
||||||
if (width >= previousWidth) {
|
|
||||||
return {
|
return {
|
||||||
height: Math.max(...itemsOfCurrentSection.map((item) => item.yOffset + item.height)),
|
height: Math.max(...itemsOfCurrentSection.map((item) => item.yOffset + item.height)),
|
||||||
items: newItems.concat(normalizedItems),
|
items: newItems.concat(normalizedItems),
|
||||||
|
|||||||
@@ -99,11 +99,12 @@ qlllmmmmmnnrrrst
|
|||||||
ulllmmmmmnnrrrvw
|
ulllmmmmmnnrrrvw
|
||||||
xyz äö`;
|
xyz äö`;
|
||||||
|
|
||||||
// Just add two empty columns to the right
|
const eighteenColumns = `
|
||||||
const eighteenColumns = sixteenColumns
|
abbccccddddeeefghi
|
||||||
.split("\n")
|
jbbccccddddeeeklll
|
||||||
.map((line, index) => (index === 0 ? line : `${line} `))
|
mmmmmnnopqseeetlll
|
||||||
.join("\n");
|
mmmmmnnrrruvwxylll
|
||||||
|
mmmmmnnrrrzäö `;
|
||||||
|
|
||||||
const tenColumns = `
|
const tenColumns = `
|
||||||
abbcccceee
|
abbcccceee
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { usePathname } from "next/navigation";
|
|||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
|
||||||
|
import { useEditMode } from "./edit-mode";
|
||||||
import { updateBoardName } from "./updater";
|
import { updateBoardName } from "./updater";
|
||||||
|
|
||||||
const BoardContext = createContext<{
|
const BoardContext = createContext<{
|
||||||
@@ -83,10 +84,12 @@ export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]
|
|||||||
export const useCurrentLayout = () => {
|
export const useCurrentLayout = () => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const [currentLayout, setCurrentLayout] = useState(getCurrentLayout(board));
|
const [currentLayout, setCurrentLayout] = useState(getCurrentLayout(board));
|
||||||
|
const [isEditMode] = useEditMode();
|
||||||
|
|
||||||
const onResize = useCallback(() => {
|
const onResize = useCallback(() => {
|
||||||
|
if (isEditMode && board.layoutMode === "auto") return;
|
||||||
setCurrentLayout(getCurrentLayout(board));
|
setCurrentLayout(getCurrentLayout(board));
|
||||||
}, [board]);
|
}, [board, isEditMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
@@ -97,6 +100,10 @@ export const useCurrentLayout = () => {
|
|||||||
};
|
};
|
||||||
}, [onResize]);
|
}, [onResize]);
|
||||||
|
|
||||||
|
if (board.layoutMode === "auto" && isEditMode && board.baseLayoutId !== null) {
|
||||||
|
return board.baseLayoutId;
|
||||||
|
}
|
||||||
|
|
||||||
return currentLayout;
|
return currentLayout;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE `board` ADD `layout_mode` varchar(32) DEFAULT 'custom' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `board` ADD `base_layout_id` varchar(64);--> statement-breakpoint
|
||||||
|
ALTER TABLE `board` ADD CONSTRAINT `board_base_layout_id_layout_id_fk` FOREIGN KEY (`base_layout_id`) REFERENCES `layout`(`id`) ON DELETE set null ON UPDATE no action;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -260,6 +260,13 @@
|
|||||||
"when": 1760968518445,
|
"when": 1760968518445,
|
||||||
"tag": "0036_add_app_reference_to_integration",
|
"tag": "0036_add_app_reference_to_integration",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 37,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1764190331048,
|
||||||
|
"tag": "0037_add_auto_layout_mode",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "board" ADD COLUMN "layout_mode" varchar(32) DEFAULT 'custom' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "board" ADD COLUMN "base_layout_id" varchar(64);--> statement-breakpoint
|
||||||
|
ALTER TABLE "board" ADD CONSTRAINT "board_base_layout_id_layout_id_fk" FOREIGN KEY ("base_layout_id") REFERENCES "public"."layout"("id") ON DELETE set null ON UPDATE no action;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -22,6 +22,13 @@
|
|||||||
"when": 1760968530084,
|
"when": 1760968530084,
|
||||||
"tag": "0002_add_app_reference_to_integration",
|
"tag": "0002_add_app_reference_to_integration",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1764190350662,
|
||||||
|
"tag": "0003_add_auto_layout_mode",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `board` ADD `layout_mode` text DEFAULT 'custom' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `board` ADD `base_layout_id` text REFERENCES layout(id);
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -246,6 +246,13 @@
|
|||||||
"when": 1760968503571,
|
"when": 1760968503571,
|
||||||
"tag": "0034_add_app_reference_to_integration",
|
"tag": "0034_add_app_reference_to_integration",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 35,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1764190313293,
|
||||||
|
"tag": "0035_add_auto_layout_mode",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -17,16 +17,11 @@ import {
|
|||||||
varchar,
|
varchar,
|
||||||
} from "drizzle-orm/mysql-core";
|
} from "drizzle-orm/mysql-core";
|
||||||
|
|
||||||
import {
|
|
||||||
backgroundImageAttachments,
|
|
||||||
backgroundImageRepeats,
|
|
||||||
backgroundImageSizes,
|
|
||||||
emptySuperJSON,
|
|
||||||
} from "@homarr/definitions";
|
|
||||||
import type {
|
import type {
|
||||||
BackgroundImageAttachment,
|
BackgroundImageAttachment,
|
||||||
BackgroundImageRepeat,
|
BackgroundImageRepeat,
|
||||||
BackgroundImageSize,
|
BackgroundImageSize,
|
||||||
|
BoardLayoutMode,
|
||||||
BoardPermission,
|
BoardPermission,
|
||||||
ColorScheme,
|
ColorScheme,
|
||||||
GroupPermissionKey,
|
GroupPermissionKey,
|
||||||
@@ -39,6 +34,12 @@ import type {
|
|||||||
SupportedAuthProvider,
|
SupportedAuthProvider,
|
||||||
WidgetKind,
|
WidgetKind,
|
||||||
} from "@homarr/definitions";
|
} from "@homarr/definitions";
|
||||||
|
import {
|
||||||
|
backgroundImageAttachments,
|
||||||
|
backgroundImageRepeats,
|
||||||
|
backgroundImageSizes,
|
||||||
|
emptySuperJSON,
|
||||||
|
} from "@homarr/definitions";
|
||||||
|
|
||||||
const customBlob = customType<{ data: Buffer }>({
|
const customBlob = customType<{ data: Buffer }>({
|
||||||
dataType() {
|
dataType() {
|
||||||
@@ -289,6 +290,11 @@ export const boards = mysqlTable("board", {
|
|||||||
iconColor: text(),
|
iconColor: text(),
|
||||||
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
||||||
disableStatus: boolean().default(false).notNull(),
|
disableStatus: boolean().default(false).notNull(),
|
||||||
|
// This has to be custom as the previous behaviour was custom
|
||||||
|
layoutMode: varchar({ length: 32 }).$type<BoardLayoutMode>().default("custom").notNull(),
|
||||||
|
baseLayoutId: varchar({ length: 64 }).references((): AnyMySqlColumn => layouts.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const boardUserPermissions = mysqlTable(
|
export const boardUserPermissions = mysqlTable(
|
||||||
@@ -678,6 +684,10 @@ export const boardRelations = relations(boards, ({ many, one }) => ({
|
|||||||
userPermissions: many(boardUserPermissions),
|
userPermissions: many(boardUserPermissions),
|
||||||
groupPermissions: many(boardGroupPermissions),
|
groupPermissions: many(boardGroupPermissions),
|
||||||
layouts: many(layouts),
|
layouts: many(layouts),
|
||||||
|
baseLayout: one(layouts, {
|
||||||
|
fields: [boards.baseLayoutId],
|
||||||
|
references: [layouts.id],
|
||||||
|
}),
|
||||||
groupHomes: many(groups, {
|
groupHomes: many(groups, {
|
||||||
relationName: "groupRelations__board__homeBoardId",
|
relationName: "groupRelations__board__homeBoardId",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -16,16 +16,11 @@ import {
|
|||||||
varchar,
|
varchar,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
import {
|
|
||||||
backgroundImageAttachments,
|
|
||||||
backgroundImageRepeats,
|
|
||||||
backgroundImageSizes,
|
|
||||||
emptySuperJSON,
|
|
||||||
} from "@homarr/definitions";
|
|
||||||
import type {
|
import type {
|
||||||
BackgroundImageAttachment,
|
BackgroundImageAttachment,
|
||||||
BackgroundImageRepeat,
|
BackgroundImageRepeat,
|
||||||
BackgroundImageSize,
|
BackgroundImageSize,
|
||||||
|
BoardLayoutMode,
|
||||||
BoardPermission,
|
BoardPermission,
|
||||||
ColorScheme,
|
ColorScheme,
|
||||||
GroupPermissionKey,
|
GroupPermissionKey,
|
||||||
@@ -38,6 +33,12 @@ import type {
|
|||||||
SupportedAuthProvider,
|
SupportedAuthProvider,
|
||||||
WidgetKind,
|
WidgetKind,
|
||||||
} from "@homarr/definitions";
|
} from "@homarr/definitions";
|
||||||
|
import {
|
||||||
|
backgroundImageAttachments,
|
||||||
|
backgroundImageRepeats,
|
||||||
|
backgroundImageSizes,
|
||||||
|
emptySuperJSON,
|
||||||
|
} from "@homarr/definitions";
|
||||||
|
|
||||||
const customBlob = customType<{ data: Buffer }>({
|
const customBlob = customType<{ data: Buffer }>({
|
||||||
dataType() {
|
dataType() {
|
||||||
@@ -288,6 +289,11 @@ export const boards = pgTable("board", {
|
|||||||
iconColor: text(),
|
iconColor: text(),
|
||||||
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
||||||
disableStatus: boolean().default(false).notNull(),
|
disableStatus: boolean().default(false).notNull(),
|
||||||
|
// This has to be custom as the previous behaviour was custom
|
||||||
|
layoutMode: varchar({ length: 32 }).$type<BoardLayoutMode>().default("custom").notNull(),
|
||||||
|
baseLayoutId: varchar({ length: 64 }).references((): AnyPgColumn => layouts.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const boardUserPermissions = pgTable(
|
export const boardUserPermissions = pgTable(
|
||||||
@@ -677,6 +683,10 @@ export const boardRelations = relations(boards, ({ many, one }) => ({
|
|||||||
userPermissions: many(boardUserPermissions),
|
userPermissions: many(boardUserPermissions),
|
||||||
groupPermissions: many(boardGroupPermissions),
|
groupPermissions: many(boardGroupPermissions),
|
||||||
layouts: many(layouts),
|
layouts: many(layouts),
|
||||||
|
baseLayout: one(layouts, {
|
||||||
|
fields: [boards.baseLayoutId],
|
||||||
|
references: [layouts.id],
|
||||||
|
}),
|
||||||
groupHomes: many(groups, {
|
groupHomes: many(groups, {
|
||||||
relationName: "groupRelations__board__homeBoardId",
|
relationName: "groupRelations__board__homeBoardId",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -5,16 +5,11 @@ import { relations, sql } from "drizzle-orm";
|
|||||||
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||||
import { blob, index, int, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
import { blob, index, int, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
import {
|
|
||||||
backgroundImageAttachments,
|
|
||||||
backgroundImageRepeats,
|
|
||||||
backgroundImageSizes,
|
|
||||||
emptySuperJSON,
|
|
||||||
} from "@homarr/definitions";
|
|
||||||
import type {
|
import type {
|
||||||
BackgroundImageAttachment,
|
BackgroundImageAttachment,
|
||||||
BackgroundImageRepeat,
|
BackgroundImageRepeat,
|
||||||
BackgroundImageSize,
|
BackgroundImageSize,
|
||||||
|
BoardLayoutMode,
|
||||||
BoardPermission,
|
BoardPermission,
|
||||||
ColorScheme,
|
ColorScheme,
|
||||||
GroupPermissionKey,
|
GroupPermissionKey,
|
||||||
@@ -27,6 +22,12 @@ import type {
|
|||||||
SupportedAuthProvider,
|
SupportedAuthProvider,
|
||||||
WidgetKind,
|
WidgetKind,
|
||||||
} from "@homarr/definitions";
|
} from "@homarr/definitions";
|
||||||
|
import {
|
||||||
|
backgroundImageAttachments,
|
||||||
|
backgroundImageRepeats,
|
||||||
|
backgroundImageSizes,
|
||||||
|
emptySuperJSON,
|
||||||
|
} from "@homarr/definitions";
|
||||||
|
|
||||||
export const apiKeys = sqliteTable("apiKey", {
|
export const apiKeys = sqliteTable("apiKey", {
|
||||||
id: text().notNull().primaryKey(),
|
id: text().notNull().primaryKey(),
|
||||||
@@ -274,6 +275,11 @@ export const boards = sqliteTable("board", {
|
|||||||
iconColor: text(),
|
iconColor: text(),
|
||||||
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
||||||
disableStatus: int({ mode: "boolean" }).default(false).notNull(),
|
disableStatus: int({ mode: "boolean" }).default(false).notNull(),
|
||||||
|
// This has to be custom as the previous behaviour was custom
|
||||||
|
layoutMode: text().$type<BoardLayoutMode>().default("custom").notNull(),
|
||||||
|
baseLayoutId: text().references((): AnySQLiteColumn => layouts.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const boardUserPermissions = sqliteTable(
|
export const boardUserPermissions = sqliteTable(
|
||||||
@@ -663,6 +669,10 @@ export const boardRelations = relations(boards, ({ many, one }) => ({
|
|||||||
userPermissions: many(boardUserPermissions),
|
userPermissions: many(boardUserPermissions),
|
||||||
groupPermissions: many(boardGroupPermissions),
|
groupPermissions: many(boardGroupPermissions),
|
||||||
layouts: many(layouts),
|
layouts: many(layouts),
|
||||||
|
baseLayout: one(layouts, {
|
||||||
|
fields: [boards.baseLayoutId],
|
||||||
|
references: [layouts.id],
|
||||||
|
}),
|
||||||
groupHomes: many(groups, {
|
groupHomes: many(groups, {
|
||||||
relationName: "groupRelations__board__homeBoardId",
|
relationName: "groupRelations__board__homeBoardId",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ export const backgroundImageRepeats = createDefinition(["repeat", "repeat-x", "r
|
|||||||
export const backgroundImageSizes = createDefinition(["cover", "contain"], {
|
export const backgroundImageSizes = createDefinition(["cover", "contain"], {
|
||||||
defaultValue: "cover",
|
defaultValue: "cover",
|
||||||
});
|
});
|
||||||
|
export const boardLayoutModes = createDefinition(["auto", "custom"], { defaultValue: "auto" });
|
||||||
|
|
||||||
export type BackgroundImageAttachment = inferDefinitionType<typeof backgroundImageAttachments>;
|
export type BackgroundImageAttachment = inferDefinitionType<typeof backgroundImageAttachments>;
|
||||||
export type BackgroundImageRepeat = inferDefinitionType<typeof backgroundImageRepeats>;
|
export type BackgroundImageRepeat = inferDefinitionType<typeof backgroundImageRepeats>;
|
||||||
export type BackgroundImageSize = inferDefinitionType<typeof backgroundImageSizes>;
|
export type BackgroundImageSize = inferDefinitionType<typeof backgroundImageSizes>;
|
||||||
|
export type BoardLayoutMode = inferDefinitionType<typeof boardLayoutModes>;
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/api": "workspace:^0.1.0",
|
"@homarr/api": "workspace:^0.1.0",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
"@homarr/form": "workspace:^0.1.0",
|
"@homarr/form": "workspace:^0.1.0",
|
||||||
"@homarr/forms-collection": "workspace:^0.1.0",
|
"@homarr/forms-collection": "workspace:^0.1.0",
|
||||||
"@homarr/modals": "workspace:^0.1.0",
|
"@homarr/modals": "workspace:^0.1.0",
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
import { Button, Group, InputWrapper, Slider, Stack, Switch, TextInput } from "@mantine/core";
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Fieldset,
|
||||||
|
Group,
|
||||||
|
InputWrapper,
|
||||||
|
RadioGroup,
|
||||||
|
SimpleGrid,
|
||||||
|
Slider,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { IconAlertTriangle, IconCircleCheck } from "@tabler/icons-react";
|
import { IconAlertTriangle, IconCircleCheck } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { boardLayoutModes } from "@homarr/definitions";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { createModal } from "@homarr/modals";
|
import { createModal } from "@homarr/modals";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { CustomRadioCard } from "@homarr/ui";
|
||||||
import { boardColumnCountSchema, boardCreateSchema, boardNameSchema } from "@homarr/validation/board";
|
import { boardColumnCountSchema, boardCreateSchema, boardNameSchema } from "@homarr/validation/board";
|
||||||
|
|
||||||
export const AddBoardModal = createModal(({ actions }) => {
|
export const AddBoardModal = createModal(({ actions }) => {
|
||||||
@@ -18,6 +33,7 @@ export const AddBoardModal = createModal(({ actions }) => {
|
|||||||
name: "",
|
name: "",
|
||||||
columnCount: 10,
|
columnCount: 10,
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
|
layoutMode: boardLayoutModes.defaultValue,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { mutate, isPending } = clientApi.board.createBoard.useMutation({
|
const { mutate, isPending } = clientApi.board.createBoard.useMutation({
|
||||||
@@ -64,14 +80,38 @@ export const AddBoardModal = createModal(({ actions }) => {
|
|||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<InputWrapper label={t("board.field.columnCount.label")} {...form.getInputProps("columnCount")}>
|
|
||||||
<Slider
|
<Fieldset legend={t("board.setting.section.layout.title")}>
|
||||||
min={boardColumnCountSchema.minValue ?? undefined}
|
<Stack gap="sm">
|
||||||
max={boardColumnCountSchema.maxValue ?? undefined}
|
<RadioGroup {...form.getInputProps("layoutMode")}>
|
||||||
step={1}
|
<SimpleGrid cols={{ sm: 2, xs: 1 }} spacing="md">
|
||||||
{...form.getInputProps("columnCount")}
|
{boardLayoutModes.values.map((mode) => (
|
||||||
/>
|
<Box w="100%" key={mode} pos="relative">
|
||||||
</InputWrapper>
|
<CustomRadioCard
|
||||||
|
value={mode}
|
||||||
|
label={t(`board.field.layoutMode.option.${mode}.label`)}
|
||||||
|
description={t(`board.field.layoutMode.option.${mode}.description`)}
|
||||||
|
/>
|
||||||
|
{mode === boardLayoutModes.defaultValue && (
|
||||||
|
<Badge pos="absolute" top="8px" right="8px" variant="light" size="sm">
|
||||||
|
{t("common.select.badge.recommended")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<InputWrapper label={t("board.field.columnCount.label")} {...form.getInputProps("columnCount")}>
|
||||||
|
<Slider
|
||||||
|
min={boardColumnCountSchema.minValue ?? undefined}
|
||||||
|
max={boardColumnCountSchema.maxValue ?? undefined}
|
||||||
|
step={1}
|
||||||
|
{...form.getInputProps("columnCount")}
|
||||||
|
/>
|
||||||
|
</InputWrapper>
|
||||||
|
</Stack>
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
<Switch
|
<Switch
|
||||||
label={t("board.field.isPublic.label")}
|
label={t("board.field.isPublic.label")}
|
||||||
@@ -92,6 +132,7 @@ export const AddBoardModal = createModal(({ actions }) => {
|
|||||||
);
|
);
|
||||||
}).withOptions({
|
}).withOptions({
|
||||||
defaultTitle: (t) => t("management.page.board.action.new.label"),
|
defaultTitle: (t) => t("management.page.board.action.new.label"),
|
||||||
|
size: "lg",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useBoardNameStatus = (name: string) => {
|
export const useBoardNameStatus = (name: string) => {
|
||||||
|
|||||||
@@ -2793,6 +2793,23 @@
|
|||||||
"label": "Disable app status",
|
"label": "Disable app status",
|
||||||
"description": "Disables the status check for all apps on this board"
|
"description": "Disables the status check for all apps on this board"
|
||||||
},
|
},
|
||||||
|
"layoutMode": {
|
||||||
|
"label": "Layout mode",
|
||||||
|
"option": {
|
||||||
|
"auto": {
|
||||||
|
"label": "Auto",
|
||||||
|
"description": "Position and size of all layouts are calculated from the base layout."
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"label": "Custom",
|
||||||
|
"description": "Position and size must be configured for all layouts separately."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"baseLayout": {
|
||||||
|
"label": "Base layout",
|
||||||
|
"description": "The layout will be used to calculate positions and sizes for other layouts."
|
||||||
|
},
|
||||||
"columnCount": {
|
"columnCount": {
|
||||||
"label": "Column count"
|
"label": "Column count"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,3 +16,4 @@ export { MaskedImage } from "./masked-image";
|
|||||||
export { MaskedOrNormalImage } from "./masked-or-normal-image";
|
export { MaskedOrNormalImage } from "./masked-or-normal-image";
|
||||||
export { LanguageIcon } from "./language-icon";
|
export { LanguageIcon } from "./language-icon";
|
||||||
export { Link } from "./link";
|
export { Link } from "./link";
|
||||||
|
export * from "./radio";
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
.root {
|
||||||
|
position: relative;
|
||||||
|
padding: var(--mantine-spacing-md);
|
||||||
|
transition: border-color 150ms ease;
|
||||||
|
|
||||||
|
&[data-checked] {
|
||||||
|
border-color: var(--mantine-primary-color-filled);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin hover {
|
||||||
|
@mixin light {
|
||||||
|
background-color: var(--mantine-color-gray-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-family: var(--mantine-font-family-monospace);
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: var(--mantine-font-size-md);
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--mantine-color-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
font-size: var(--mantine-font-size-xs);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { RadioCardProps } from "@mantine/core";
|
||||||
|
import { Group, RadioCard, RadioIndicator, Text } from "@mantine/core";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import classes from "./radio.module.css";
|
||||||
|
|
||||||
|
interface CustomRadioCardProps extends RadioCardProps {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomRadioCard = (props: CustomRadioCardProps) => {
|
||||||
|
return (
|
||||||
|
<RadioCard className={clsx(classes.root, props.className)} {...props}>
|
||||||
|
<Group wrap="nowrap" align="flex-start">
|
||||||
|
<RadioIndicator />
|
||||||
|
<div>
|
||||||
|
<Text className={classes.label}>{props.label}</Text>
|
||||||
|
<Text className={classes.description}>{props.description}</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</RadioCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
backgroundImageAttachments,
|
backgroundImageAttachments,
|
||||||
backgroundImageRepeats,
|
backgroundImageRepeats,
|
||||||
backgroundImageSizes,
|
backgroundImageSizes,
|
||||||
|
boardLayoutModes,
|
||||||
boardPermissions,
|
boardPermissions,
|
||||||
} from "@homarr/definitions";
|
} from "@homarr/definitions";
|
||||||
|
|
||||||
@@ -79,6 +80,9 @@ export const boardSaveLayoutsSchema = z.object({
|
|||||||
breakpoint: z.number().min(0).max(32767),
|
breakpoint: z.number().min(0).max(32767),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
// If not send it should fallback to the legacy behavior which is "custom"
|
||||||
|
layoutMode: z.enum(boardLayoutModes.values).default("custom"),
|
||||||
|
baseLayoutId: z.string().nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const boardSaveSchema = z.object({
|
export const boardSaveSchema = z.object({
|
||||||
@@ -90,6 +94,7 @@ export const boardSaveSchema = z.object({
|
|||||||
export const boardCreateSchema = z.object({
|
export const boardCreateSchema = z.object({
|
||||||
name: boardNameSchema,
|
name: boardNameSchema,
|
||||||
columnCount: boardColumnCountSchema,
|
columnCount: boardColumnCountSchema,
|
||||||
|
layoutMode: z.enum(boardLayoutModes.values).default(boardLayoutModes.defaultValue),
|
||||||
isPublic: z.boolean(),
|
isPublic: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Generated
+3
@@ -1616,6 +1616,9 @@ importers:
|
|||||||
'@homarr/common':
|
'@homarr/common':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../common
|
version: link:../common
|
||||||
|
'@homarr/definitions':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../definitions
|
||||||
'@homarr/form':
|
'@homarr/form':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../form
|
version: link:../form
|
||||||
|
|||||||
Reference in new issue
Block a user