feat: board settings (#137)
* refactor: improve user feedback for general board settings section * wip: add board settings for background and colors, move danger zone to own file, refactor code * feat: add shade selector * feat: add slider for opacity * fix: issue with invalid hex values for color preview * refactor: add shared mutation hook for saving partial board settings with invalidate query * fix: add cleanup for not applied changes to logo and page title * feat: add layout settings * feat: add empty custom css section to board settings * refactor: improve layout of board logo on mobile * feat: add theme provider for board colors * refactor: add auto contrast for better contrast of buttons with low primary shade * feat: add background for boards * feat: add opacity for boards * feat: add rename board * feat: add visibility and delete of board settings * fix: issue that wrong data is updated with update board method * refactor: improve danger zone button placement for mobile * fix: board not revalidated when already in boards layout * refactor: improve board color preview * refactor: change save button color to teal, add placeholders for general board settings * chore: update initial migration * refactor: remove unnecessary div * chore: address pull request feedback * fix: ci issues * fix: deepsource issues * chore: address pull request feedback * fix: formatting issue * chore: address pull request feedback
This commit is contained in:
@@ -79,6 +79,24 @@ export const boardRouter = createTRPCRouter({
|
||||
});
|
||||
});
|
||||
}),
|
||||
rename: publicProcedure
|
||||
.input(validation.board.rename)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await noBoardWithSimilarName(ctx.db, input.name, [input.id]);
|
||||
|
||||
await ctx.db
|
||||
.update(boards)
|
||||
.set({ name: input.name })
|
||||
.where(eq(boards.id, input.id));
|
||||
}),
|
||||
changeVisibility: publicProcedure
|
||||
.input(validation.board.changeVisibility)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db
|
||||
.update(boards)
|
||||
.set({ isPublic: input.visibility === "public" })
|
||||
.where(eq(boards.id, input.id));
|
||||
}),
|
||||
delete: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -92,11 +110,11 @@ export const boardRouter = createTRPCRouter({
|
||||
.query(async ({ input, ctx }) => {
|
||||
return await getFullBoardWithWhere(ctx.db, eq(boards.name, input.name));
|
||||
}),
|
||||
saveGeneralSettings: publicProcedure
|
||||
.input(validation.board.saveGeneralSettings)
|
||||
savePartialSettings: publicProcedure
|
||||
.input(validation.board.savePartialSettings)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const board = await ctx.db.query.boards.findFirst({
|
||||
where: eq(boards.id, input.boardId),
|
||||
where: eq(boards.id, input.id),
|
||||
});
|
||||
|
||||
if (!board) {
|
||||
@@ -109,12 +127,30 @@ export const boardRouter = createTRPCRouter({
|
||||
await ctx.db
|
||||
.update(boards)
|
||||
.set({
|
||||
// general settings
|
||||
pageTitle: input.pageTitle,
|
||||
metaTitle: input.metaTitle,
|
||||
logoImageUrl: input.logoImageUrl,
|
||||
faviconImageUrl: input.faviconImageUrl,
|
||||
|
||||
// background settings
|
||||
backgroundImageUrl: input.backgroundImageUrl,
|
||||
backgroundImageAttachment: input.backgroundImageAttachment,
|
||||
backgroundImageRepeat: input.backgroundImageRepeat,
|
||||
backgroundImageSize: input.backgroundImageSize,
|
||||
|
||||
// color settings
|
||||
primaryColor: input.primaryColor,
|
||||
secondaryColor: input.secondaryColor,
|
||||
opacity: input.opacity,
|
||||
|
||||
// custom css
|
||||
customCss: input.customCss,
|
||||
|
||||
// layout settings
|
||||
columnCount: input.columnCount,
|
||||
})
|
||||
.where(eq(boards.id, input.boardId));
|
||||
.where(eq(boards.id, input.id));
|
||||
}),
|
||||
save: publicProcedure
|
||||
.input(validation.board.save)
|
||||
@@ -122,7 +158,7 @@ export const boardRouter = createTRPCRouter({
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
const dbBoard = await getFullBoardWithWhere(
|
||||
tx,
|
||||
eq(boards.id, input.boardId),
|
||||
eq(boards.id, input.id),
|
||||
);
|
||||
|
||||
const addedSections = filterAddedItems(
|
||||
@@ -276,6 +312,32 @@ export const boardRouter = createTRPCRouter({
|
||||
}),
|
||||
});
|
||||
|
||||
const noBoardWithSimilarName = async (
|
||||
db: Database,
|
||||
name: string,
|
||||
ignoredIds: string[] = [],
|
||||
) => {
|
||||
const boards = await db.query.boards.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
const board = boards.find(
|
||||
(board) =>
|
||||
board.name.toLowerCase() === name.toLowerCase() &&
|
||||
!ignoredIds.includes(board.id),
|
||||
);
|
||||
|
||||
if (board) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Board with similar name already exists",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getFullBoardWithWhere = async (db: Database, where: SQL<unknown>) => {
|
||||
const board = await db.query.boards.findFirst({
|
||||
where,
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("byName should return board by name", () => {
|
||||
it("should throw error when not present");
|
||||
});
|
||||
|
||||
describe("saveGeneralSettings should save general settings", () => {
|
||||
describe("savePartialSettings should save general settings", () => {
|
||||
it("should save general settings", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, session: null });
|
||||
@@ -78,12 +78,12 @@ describe("saveGeneralSettings should save general settings", () => {
|
||||
|
||||
const { boardId } = await createFullBoardAsync(db, "default");
|
||||
|
||||
await caller.saveGeneralSettings({
|
||||
await caller.savePartialSettings({
|
||||
pageTitle: newPageTitle,
|
||||
metaTitle: newMetaTitle,
|
||||
logoImageUrl: newLogoImageUrl,
|
||||
faviconImageUrl: newFaviconImageUrl,
|
||||
boardId,
|
||||
id: boardId,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,12 +92,12 @@ describe("saveGeneralSettings should save general settings", () => {
|
||||
const caller = boardRouter.createCaller({ db, session: null });
|
||||
|
||||
const act = async () =>
|
||||
await caller.saveGeneralSettings({
|
||||
await caller.savePartialSettings({
|
||||
pageTitle: "newPageTitle",
|
||||
metaTitle: "newMetaTitle",
|
||||
logoImageUrl: "http://logo.image/url.png",
|
||||
faviconImageUrl: "http://favicon.image/url.png",
|
||||
boardId: "nonExistentBoardId",
|
||||
id: "nonExistentBoardId",
|
||||
});
|
||||
|
||||
await expect(act()).rejects.toThrowError("Board not found");
|
||||
@@ -112,7 +112,7 @@ describe("save should save full board", () => {
|
||||
const { boardId, sectionId } = await createFullBoardAsync(db, "default");
|
||||
|
||||
await caller.save({
|
||||
boardId,
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -149,7 +149,7 @@ describe("save should save full board", () => {
|
||||
);
|
||||
|
||||
await caller.save({
|
||||
boardId,
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
id: sectionId,
|
||||
@@ -208,7 +208,7 @@ describe("save should save full board", () => {
|
||||
await db.insert(integrations).values(anotherIntegration);
|
||||
|
||||
await caller.save({
|
||||
boardId,
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
id: sectionId,
|
||||
@@ -269,7 +269,7 @@ describe("save should save full board", () => {
|
||||
|
||||
const newSectionId = createId();
|
||||
await caller.save({
|
||||
boardId,
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
id: newSectionId,
|
||||
@@ -319,7 +319,7 @@ describe("save should save full board", () => {
|
||||
|
||||
const newItemId = createId();
|
||||
await caller.save({
|
||||
boardId,
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
id: sectionId,
|
||||
@@ -392,7 +392,7 @@ describe("save should save full board", () => {
|
||||
await db.insert(integrations).values(integration);
|
||||
|
||||
await caller.save({
|
||||
boardId,
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
id: sectionId,
|
||||
@@ -459,7 +459,7 @@ describe("save should save full board", () => {
|
||||
});
|
||||
|
||||
await caller.save({
|
||||
boardId,
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
id: sectionId,
|
||||
@@ -512,7 +512,7 @@ describe("save should save full board", () => {
|
||||
);
|
||||
|
||||
await caller.save({
|
||||
boardId,
|
||||
id: boardId,
|
||||
sections: [
|
||||
{
|
||||
id: sectionId,
|
||||
@@ -569,7 +569,7 @@ describe("save should save full board", () => {
|
||||
|
||||
const act = async () =>
|
||||
await caller.save({
|
||||
boardId: "nonExistentBoardId",
|
||||
id: "nonExistentBoardId",
|
||||
sections: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -26,13 +26,10 @@ CREATE TABLE `board` (
|
||||
`background_image_attachment` text DEFAULT 'fixed' NOT NULL,
|
||||
`background_image_repeat` text DEFAULT 'no-repeat' NOT NULL,
|
||||
`background_image_size` text DEFAULT 'cover' NOT NULL,
|
||||
`primary_color` text DEFAULT 'red' NOT NULL,
|
||||
`secondary_color` text DEFAULT 'orange' NOT NULL,
|
||||
`primary_shade` integer DEFAULT 6 NOT NULL,
|
||||
`app_opacity` integer DEFAULT 100 NOT NULL,
|
||||
`primary_color` text DEFAULT '#fa5252' NOT NULL,
|
||||
`secondary_color` text DEFAULT '#fd7e14' NOT NULL,
|
||||
`opacity` integer DEFAULT 100 NOT NULL,
|
||||
`custom_css` text,
|
||||
`show_right_sidebar` integer DEFAULT false NOT NULL,
|
||||
`show_left_sidebar` integer DEFAULT false NOT NULL,
|
||||
`column_count` integer DEFAULT 10 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
@@ -106,6 +103,7 @@ CREATE TABLE `verificationToken` (
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `userId_idx` ON `account` (`userId`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `board_name_unique` ON `board` (`name`);--> statement-breakpoint
|
||||
CREATE INDEX `integration_secret__kind_idx` ON `integrationSecret` (`kind`);--> statement-breakpoint
|
||||
CREATE INDEX `integration_secret__updated_at_idx` ON `integrationSecret` (`updated_at`);--> statement-breakpoint
|
||||
CREATE INDEX `integration__kind_idx` ON `integration` (`kind`);--> statement-breakpoint
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "sqlite",
|
||||
"id": "c9ea435a-5bbf-4439-84a1-55d3e2581b13",
|
||||
"id": "7ba12cbf-d8eb-4469-b7c5-7f31acb7717d",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"account": {
|
||||
@@ -201,7 +201,7 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'red'"
|
||||
"default": "'#fa5252'"
|
||||
},
|
||||
"secondary_color": {
|
||||
"name": "secondary_color",
|
||||
@@ -209,18 +209,10 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'orange'"
|
||||
"default": "'#fd7e14'"
|
||||
},
|
||||
"primary_shade": {
|
||||
"name": "primary_shade",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 6
|
||||
},
|
||||
"app_opacity": {
|
||||
"name": "app_opacity",
|
||||
"opacity": {
|
||||
"name": "opacity",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
@@ -234,22 +226,6 @@
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"show_right_sidebar": {
|
||||
"name": "show_right_sidebar",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"show_left_sidebar": {
|
||||
"name": "show_left_sidebar",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"column_count": {
|
||||
"name": "column_count",
|
||||
"type": "integer",
|
||||
@@ -259,7 +235,13 @@
|
||||
"default": 10
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"indexes": {
|
||||
"board_name_unique": {
|
||||
"name": "board_name_unique",
|
||||
"columns": ["name"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "5",
|
||||
"when": 1707511343363,
|
||||
"tag": "0000_true_red_wolf",
|
||||
"when": 1709409142712,
|
||||
"tag": "0000_sloppy_bloodstorm",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { AdapterAccount } from "@auth/core/adapters";
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
@@ -11,6 +10,11 @@ import {
|
||||
text,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
|
||||
import {
|
||||
backgroundImageAttachments,
|
||||
backgroundImageRepeats,
|
||||
backgroundImageSizes,
|
||||
} from "@homarr/definitions";
|
||||
import type {
|
||||
BackgroundImageAttachment,
|
||||
BackgroundImageRepeat,
|
||||
@@ -125,37 +129,20 @@ export const boards = sqliteTable("board", {
|
||||
backgroundImageUrl: text("background_image_url"),
|
||||
backgroundImageAttachment: text("background_image_attachment")
|
||||
.$type<BackgroundImageAttachment>()
|
||||
.default("fixed")
|
||||
.default(backgroundImageAttachments.defaultValue)
|
||||
.notNull(),
|
||||
backgroundImageRepeat: text("background_image_repeat")
|
||||
.$type<BackgroundImageRepeat>()
|
||||
.default("no-repeat")
|
||||
.default(backgroundImageRepeats.defaultValue)
|
||||
.notNull(),
|
||||
backgroundImageSize: text("background_image_size")
|
||||
.$type<BackgroundImageSize>()
|
||||
.default("cover")
|
||||
.default(backgroundImageSizes.defaultValue)
|
||||
.notNull(),
|
||||
primaryColor: text("primary_color")
|
||||
.$type<MantineColor>()
|
||||
.default("red")
|
||||
.notNull(),
|
||||
secondaryColor: text("secondary_color")
|
||||
.$type<MantineColor>()
|
||||
.default("orange")
|
||||
.notNull(),
|
||||
primaryShade: int("primary_shade").default(6).notNull(),
|
||||
appOpacity: int("app_opacity").default(100).notNull(),
|
||||
primaryColor: text("primary_color").default("#fa5252").notNull(),
|
||||
secondaryColor: text("secondary_color").default("#fd7e14").notNull(),
|
||||
opacity: int("opacity").default(100).notNull(),
|
||||
customCss: text("custom_css"),
|
||||
showRightSidebar: int("show_right_sidebar", {
|
||||
mode: "boolean",
|
||||
})
|
||||
.default(false)
|
||||
.notNull(),
|
||||
showLeftSidebar: int("show_left_sidebar", {
|
||||
mode: "boolean",
|
||||
})
|
||||
.default(false)
|
||||
.notNull(),
|
||||
columnCount: int("column_count").default(10).notNull(),
|
||||
});
|
||||
|
||||
|
||||
20
packages/definitions/src/_definition.ts
Normal file
20
packages/definitions/src/_definition.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export const createDefinition = <
|
||||
const TKeys extends string[],
|
||||
TOptions extends { defaultValue: TKeys[number] } | void,
|
||||
>(
|
||||
values: TKeys,
|
||||
options: TOptions,
|
||||
) => ({
|
||||
values,
|
||||
defaultValue: options?.defaultValue as TOptions extends {
|
||||
defaultValue: infer T;
|
||||
}
|
||||
? T
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export type inferDefinitionType<TDefinition> = TDefinition extends {
|
||||
values: readonly (infer T)[];
|
||||
}
|
||||
? T
|
||||
: never;
|
||||
@@ -1,13 +1,24 @@
|
||||
export const backgroundImageAttachments = ["fixed", "scroll"] as const;
|
||||
export const backgroundImageRepeats = [
|
||||
"repeat",
|
||||
"repeat-x",
|
||||
"repeat-y",
|
||||
"no-repeat",
|
||||
] as const;
|
||||
export const backgroundImageSizes = ["cover", "contain"] as const;
|
||||
import type { inferDefinitionType } from "./_definition";
|
||||
import { createDefinition } from "./_definition";
|
||||
|
||||
export type BackgroundImageAttachment =
|
||||
(typeof backgroundImageAttachments)[number];
|
||||
export type BackgroundImageRepeat = (typeof backgroundImageRepeats)[number];
|
||||
export type BackgroundImageSize = (typeof backgroundImageSizes)[number];
|
||||
export const backgroundImageAttachments = createDefinition(
|
||||
["fixed", "scroll"],
|
||||
{ defaultValue: "fixed" },
|
||||
);
|
||||
export const backgroundImageRepeats = createDefinition(
|
||||
["repeat", "repeat-x", "repeat-y", "no-repeat"],
|
||||
{ defaultValue: "no-repeat" },
|
||||
);
|
||||
export const backgroundImageSizes = createDefinition(["cover", "contain"], {
|
||||
defaultValue: "cover",
|
||||
});
|
||||
|
||||
export type BackgroundImageAttachment = inferDefinitionType<
|
||||
typeof backgroundImageAttachments
|
||||
>;
|
||||
export type BackgroundImageRepeat = inferDefinitionType<
|
||||
typeof backgroundImageRepeats
|
||||
>;
|
||||
export type BackgroundImageSize = inferDefinitionType<
|
||||
typeof backgroundImageSizes
|
||||
>;
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const sectionKinds = ["category", "empty", "sidebar"] as const;
|
||||
export const sectionKinds = ["category", "empty"] as const;
|
||||
export type SectionKind = (typeof sectionKinds)[number];
|
||||
|
||||
@@ -153,6 +153,12 @@ export default {
|
||||
multiSelect: {
|
||||
placeholder: "Pick one or more values",
|
||||
},
|
||||
select: {
|
||||
placeholder: "Pick value",
|
||||
badge: {
|
||||
recommended: "Recommended",
|
||||
},
|
||||
},
|
||||
search: {
|
||||
placeholder: "Search for anything...",
|
||||
nothingFound: "Nothing found",
|
||||
@@ -172,6 +178,10 @@ export default {
|
||||
},
|
||||
},
|
||||
noResults: "No results found",
|
||||
preview: {
|
||||
show: "Show preview",
|
||||
hide: "Hide preview",
|
||||
},
|
||||
},
|
||||
section: {
|
||||
category: {
|
||||
@@ -299,18 +309,98 @@ export default {
|
||||
faviconImageUrl: {
|
||||
label: "Favicon image URL",
|
||||
},
|
||||
backgroundImageUrl: {
|
||||
label: "Background image URL",
|
||||
},
|
||||
backgroundImageAttachment: {
|
||||
label: "Background image attachment",
|
||||
option: {
|
||||
fixed: {
|
||||
label: "Fixed",
|
||||
description: "Background stays in the same position.",
|
||||
},
|
||||
scroll: {
|
||||
label: "Scroll",
|
||||
description: "Background scrolls with your mouse.",
|
||||
},
|
||||
},
|
||||
},
|
||||
backgroundImageRepeat: {
|
||||
label: "Background image repeat",
|
||||
option: {
|
||||
repeat: {
|
||||
label: "Repeat",
|
||||
description:
|
||||
"The image is repeated as much as needed to cover the whole background image painting area.",
|
||||
},
|
||||
"no-repeat": {
|
||||
label: "No repeat",
|
||||
description:
|
||||
"The image is not repeated and may not fill the entire space.",
|
||||
},
|
||||
"repeat-x": {
|
||||
label: "Repeat X",
|
||||
description: "Same as 'Repeat' but only on horizontal axis.",
|
||||
},
|
||||
"repeat-y": {
|
||||
label: "Repeat Y",
|
||||
description: "Same as 'Repeat' but only on vertical axis.",
|
||||
},
|
||||
},
|
||||
},
|
||||
backgroundImageSize: {
|
||||
label: "Background image size",
|
||||
option: {
|
||||
cover: {
|
||||
label: "Cover",
|
||||
description:
|
||||
"Scales the image as small as possible to cover the entire window by cropping excessive space.",
|
||||
},
|
||||
contain: {
|
||||
label: "Contain",
|
||||
description:
|
||||
"Scales the image as large as possible within its container without cropping or stretching the image.",
|
||||
},
|
||||
},
|
||||
},
|
||||
primaryColor: {
|
||||
label: "Primary color",
|
||||
},
|
||||
secondaryColor: {
|
||||
label: "Secondary color",
|
||||
},
|
||||
opacity: {
|
||||
label: "Opacity",
|
||||
},
|
||||
customCss: {
|
||||
label: "Custom CSS",
|
||||
},
|
||||
columnCount: {
|
||||
label: "Column count",
|
||||
},
|
||||
name: {
|
||||
label: "Name",
|
||||
},
|
||||
},
|
||||
setting: {
|
||||
title: "Settings for {boardName} board",
|
||||
section: {
|
||||
general: {
|
||||
title: "General",
|
||||
unrecognizedLink:
|
||||
"The provided link is not recognized and won't preview, it might still work.",
|
||||
},
|
||||
layout: {
|
||||
title: "Layout",
|
||||
},
|
||||
appearance: {
|
||||
title: "Appearance",
|
||||
background: {
|
||||
title: "Background",
|
||||
},
|
||||
color: {
|
||||
title: "Colors",
|
||||
},
|
||||
customCss: {
|
||||
title: "Custom css",
|
||||
},
|
||||
dangerZone: {
|
||||
title: "Danger Zone",
|
||||
@@ -320,6 +410,9 @@ export default {
|
||||
description:
|
||||
"Changing the name will break any links to this board.",
|
||||
button: "Change name",
|
||||
modal: {
|
||||
title: "Rename board",
|
||||
},
|
||||
},
|
||||
visibility: {
|
||||
label: "Change board visibility",
|
||||
@@ -331,12 +424,29 @@ export default {
|
||||
public: "Make private",
|
||||
private: "Make public",
|
||||
},
|
||||
confirm: {
|
||||
public: {
|
||||
title: "Make board private",
|
||||
description:
|
||||
"Are you sure you want to make this board private? This will hide the board from the public. Links for guest users will break.",
|
||||
},
|
||||
private: {
|
||||
title: "Make board public",
|
||||
description:
|
||||
"Are you sure you want to make this board public? This will make the board accessible to everyone.",
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
label: "Delete this board",
|
||||
description:
|
||||
"Once you delete a board, there is no going back. Please be certain.",
|
||||
button: "Delete this board",
|
||||
confirm: {
|
||||
title: "Delete board",
|
||||
description:
|
||||
"Are you sure you want to delete this board? This will permanently delete the board and all its content.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export * from "./count-badge";
|
||||
export * from "./select-with-description";
|
||||
export * from "./select-with-description-and-badge";
|
||||
|
||||
101
packages/ui/src/components/select-with-custom-items.tsx
Normal file
101
packages/ui/src/components/select-with-custom-items.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { SelectProps } from "@mantine/core";
|
||||
import { Combobox, Input, InputBase, useCombobox } from "@mantine/core";
|
||||
import { useUncontrolled } from "@mantine/hooks";
|
||||
|
||||
interface BaseSelectItem {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SelectWithCustomItemsProps<TSelectItem extends BaseSelectItem>
|
||||
extends Pick<
|
||||
SelectProps,
|
||||
"label" | "error" | "defaultValue" | "value" | "onChange" | "placeholder"
|
||||
> {
|
||||
data: TSelectItem[];
|
||||
onBlur?: (event: React.FocusEvent<HTMLButtonElement>) => void;
|
||||
onFocus?: (event: React.FocusEvent<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
||||
type Props<TSelectItem extends BaseSelectItem> =
|
||||
SelectWithCustomItemsProps<TSelectItem> & {
|
||||
SelectOption: React.ComponentType<TSelectItem>;
|
||||
};
|
||||
|
||||
export const SelectWithCustomItems = <TSelectItem extends BaseSelectItem>({
|
||||
data,
|
||||
onChange,
|
||||
value,
|
||||
defaultValue,
|
||||
placeholder,
|
||||
SelectOption,
|
||||
...props
|
||||
}: Props<TSelectItem>) => {
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
});
|
||||
|
||||
const [_value, setValue] = useUncontrolled({
|
||||
value,
|
||||
defaultValue,
|
||||
finalValue: null,
|
||||
onChange,
|
||||
});
|
||||
|
||||
const selectedOption = useMemo(
|
||||
() => data.find((item) => item.value === _value),
|
||||
[data, _value],
|
||||
);
|
||||
|
||||
const options = data.map((item) => (
|
||||
<Combobox.Option value={item.value} key={item.value}>
|
||||
<SelectOption {...item} />
|
||||
</Combobox.Option>
|
||||
));
|
||||
|
||||
const toggle = useCallback(() => combobox.toggleDropdown(), [combobox]);
|
||||
const onOptionSubmit = useCallback(
|
||||
(value: string) => {
|
||||
setValue(
|
||||
value,
|
||||
data.find((item) => item.value === value),
|
||||
);
|
||||
combobox.closeDropdown();
|
||||
},
|
||||
[setValue, data, combobox],
|
||||
);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
withinPortal={false}
|
||||
onOptionSubmit={onOptionSubmit}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
{...props}
|
||||
component="button"
|
||||
type="button"
|
||||
pointer
|
||||
rightSection={<Combobox.Chevron />}
|
||||
onClick={toggle}
|
||||
rightSectionPointerEvents="none"
|
||||
multiline
|
||||
>
|
||||
{selectedOption ? (
|
||||
<SelectOption {...selectedOption} />
|
||||
) : (
|
||||
<Input.Placeholder>{placeholder}</Input.Placeholder>
|
||||
)}
|
||||
</InputBase>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>{options}</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import { Badge, Group, Text } from "@mantine/core";
|
||||
|
||||
import type { SelectWithCustomItemsProps } from "./select-with-custom-items";
|
||||
import { SelectWithCustomItems } from "./select-with-custom-items";
|
||||
|
||||
export interface SelectItemWithDescriptionBadge {
|
||||
value: string;
|
||||
label: string;
|
||||
badge?: { label: string; color: MantineColor };
|
||||
description: string;
|
||||
}
|
||||
type Props = SelectWithCustomItemsProps<SelectItemWithDescriptionBadge>;
|
||||
|
||||
export const SelectWithDescriptionBadge = (props: Props) => {
|
||||
return (
|
||||
<SelectWithCustomItems<SelectItemWithDescriptionBadge>
|
||||
{...props}
|
||||
SelectOption={SelectOption}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SelectOption = ({
|
||||
label,
|
||||
description,
|
||||
badge,
|
||||
}: SelectItemWithDescriptionBadge) => {
|
||||
return (
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text fz="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz="xs" opacity={0.6}>
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{badge && (
|
||||
<Badge color={badge.color} variant="outline" size="sm">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
35
packages/ui/src/components/select-with-description.tsx
Normal file
35
packages/ui/src/components/select-with-description.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@mantine/core";
|
||||
|
||||
import type { SelectWithCustomItemsProps } from "./select-with-custom-items";
|
||||
import { SelectWithCustomItems } from "./select-with-custom-items";
|
||||
|
||||
export interface SelectItemWithDescription {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
type Props = SelectWithCustomItemsProps<SelectItemWithDescription>;
|
||||
|
||||
export const SelectWithDescription = (props: Props) => {
|
||||
return (
|
||||
<SelectWithCustomItems<SelectItemWithDescription>
|
||||
{...props}
|
||||
SelectOption={SelectOption}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SelectOption = ({ label, description }: SelectItemWithDescription) => {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz="xs" opacity={0.6}>
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,15 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
backgroundImageAttachments,
|
||||
backgroundImageRepeats,
|
||||
backgroundImageSizes,
|
||||
} from "@homarr/definitions";
|
||||
|
||||
import { commonItemSchema, createSectionSchema } from "./shared";
|
||||
|
||||
const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/);
|
||||
|
||||
const boardNameSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -12,36 +20,56 @@ const byNameSchema = z.object({
|
||||
name: boardNameSchema,
|
||||
});
|
||||
|
||||
const saveGeneralSettingsSchema = z.object({
|
||||
pageTitle: z
|
||||
.string()
|
||||
.nullable()
|
||||
.transform((value) => (value?.trim().length === 0 ? null : value)),
|
||||
metaTitle: z
|
||||
.string()
|
||||
.nullable()
|
||||
.transform((value) => (value?.trim().length === 0 ? null : value)),
|
||||
logoImageUrl: z
|
||||
.string()
|
||||
.nullable()
|
||||
.transform((value) => (value?.trim().length === 0 ? null : value)),
|
||||
faviconImageUrl: z
|
||||
.string()
|
||||
.nullable()
|
||||
.transform((value) => (value?.trim().length === 0 ? null : value)),
|
||||
boardId: z.string(),
|
||||
const renameSchema = z.object({
|
||||
id: z.string(),
|
||||
name: boardNameSchema,
|
||||
});
|
||||
|
||||
const changeVisibilitySchema = z.object({
|
||||
id: z.string(),
|
||||
visibility: z.enum(["public", "private"]),
|
||||
});
|
||||
|
||||
const trimmedNullableString = z
|
||||
.string()
|
||||
.nullable()
|
||||
.transform((value) => (value?.trim().length === 0 ? null : value));
|
||||
|
||||
const savePartialSettingsSchema = z
|
||||
.object({
|
||||
pageTitle: trimmedNullableString,
|
||||
metaTitle: trimmedNullableString,
|
||||
logoImageUrl: trimmedNullableString,
|
||||
faviconImageUrl: trimmedNullableString,
|
||||
backgroundImageUrl: trimmedNullableString,
|
||||
backgroundImageAttachment: z.enum(backgroundImageAttachments.values),
|
||||
backgroundImageRepeat: z.enum(backgroundImageRepeats.values),
|
||||
backgroundImageSize: z.enum(backgroundImageSizes.values),
|
||||
primaryColor: hexColorSchema,
|
||||
secondaryColor: hexColorSchema,
|
||||
opacity: z.number().min(0).max(100),
|
||||
customCss: z.string().max(16384),
|
||||
columnCount: z.number().min(1).max(24),
|
||||
})
|
||||
.partial()
|
||||
.and(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
);
|
||||
|
||||
const saveSchema = z.object({
|
||||
boardId: z.string(),
|
||||
id: z.string(),
|
||||
sections: z.array(createSectionSchema(commonItemSchema)),
|
||||
});
|
||||
|
||||
const createSchema = z.object({ name: z.string() });
|
||||
const createSchema = z.object({ name: boardNameSchema });
|
||||
|
||||
export const boardSchemas = {
|
||||
byName: byNameSchema,
|
||||
saveGeneralSettings: saveGeneralSettingsSchema,
|
||||
savePartialSettings: savePartialSettingsSchema,
|
||||
save: saveSchema,
|
||||
create: createSchema,
|
||||
rename: renameSchema,
|
||||
changeVisibility: changeVisibilitySchema,
|
||||
};
|
||||
|
||||
@@ -48,21 +48,6 @@ const createEmptySchema = <TItemSchema extends z.ZodTypeAny>(
|
||||
items: z.array(itemSchema),
|
||||
});
|
||||
|
||||
const createSidebarSchema = <TItemSchema extends z.ZodTypeAny>(
|
||||
itemSchema: TItemSchema,
|
||||
) =>
|
||||
z.object({
|
||||
id: z.string(),
|
||||
kind: z.literal("sidebar"),
|
||||
position: z.union([z.literal(0), z.literal(1)]),
|
||||
items: z.array(itemSchema),
|
||||
});
|
||||
|
||||
export const createSectionSchema = <TItemSchema extends z.ZodTypeAny>(
|
||||
itemSchema: TItemSchema,
|
||||
) =>
|
||||
z.union([
|
||||
createCategorySchema(itemSchema),
|
||||
createEmptySchema(itemSchema),
|
||||
createSidebarSchema(itemSchema),
|
||||
]);
|
||||
) => z.union([createCategorySchema(itemSchema), createEmptySchema(itemSchema)]);
|
||||
|
||||
Reference in New Issue
Block a user