diff --git a/public/locales/en/manage/boards.json b/public/locales/en/manage/boards.json
index 29b5a3012..7871919f9 100644
--- a/public/locales/en/manage/boards.json
+++ b/public/locales/en/manage/boards.json
@@ -15,6 +15,19 @@
"delete": {
"label": "Delete permanently",
"disabled": "Deletion disabled, because older Homarr components do not allow the deletion of the default config. Deletion will be possible in the future."
+ },
+ "duplicate": "Duplicate",
+ "rename": {
+ "label": "Rename",
+ "modal": {
+ "title": "Rename board {{name}}",
+ "fields": {
+ "name": {
+ "label": "New name",
+ "placeholder": "New board name"
+ }
+ }
+ }
}
},
"badges": {
diff --git a/src/components/Dashboard/Modals/RenameBoard/RenameBoardModal.tsx b/src/components/Dashboard/Modals/RenameBoard/RenameBoardModal.tsx
new file mode 100644
index 000000000..0d97a3f39
--- /dev/null
+++ b/src/components/Dashboard/Modals/RenameBoard/RenameBoardModal.tsx
@@ -0,0 +1,68 @@
+import { modals } from '@mantine/modals';
+import { Alert, Button, TextInput } from '@mantine/core';
+import { api } from '~/utils/api';
+import { useForm, zodResolver } from '@mantine/form';
+import { z } from 'zod';
+import { IconAlertCircle } from '@tabler/icons-react';
+import { useTranslation } from 'next-i18next';
+import { configNameSchema } from '~/validations/boards';
+
+type RenameBoardModalProps = {
+ boardName: string;
+ configNames: string[];
+ onClose: () => void;
+}
+
+export const RenameBoardModal = ({ boardName, configNames, onClose }: RenameBoardModalProps) => {
+ const { t } = useTranslation(['manage/boards', 'common']);
+
+ const utils = api.useUtils();
+ const { mutateAsync: mutateRenameBoardAsync, isLoading, isError, error } = api.boards.renameBoard.useMutation({
+ onSettled: () => {
+ void utils.boards.all.invalidate();
+ }
+ });
+
+ const form = useForm({
+ initialValues: {
+ newName: '',
+ },
+ validate: zodResolver(z.object({
+ newName: configNameSchema.refine(value => !configNames.includes(value)),
+ })),
+ validateInputOnBlur: true,
+ validateInputOnChange: true,
+ });
+
+ const handleSubmit = () => {
+ mutateRenameBoardAsync({
+ oldName: boardName,
+ newName: form.values.newName,
+ }).then(() => {
+ onClose();
+ });
+ };
+
+ return (
+
+ );
+};
diff --git a/src/modals.ts b/src/modals.ts
index 4faea9bd3..dee041ae1 100644
--- a/src/modals.ts
+++ b/src/modals.ts
@@ -30,7 +30,7 @@ export const modals = {
copyInviteModal: CopyInviteModal,
deleteBoardModal: DeleteBoardModal,
changeUserRoleModal: ChangeUserRoleModal,
- dockerSelectBoardModal: DockerSelectBoardModal,
+ dockerSelectBoardModal: DockerSelectBoardModal
};
declare module '@mantine/modals' {
diff --git a/src/pages/manage/boards/index.tsx b/src/pages/manage/boards/index.tsx
index 822bfeef1..e7a7a7fd4 100644
--- a/src/pages/manage/boards/index.tsx
+++ b/src/pages/manage/boards/index.tsx
@@ -5,16 +5,17 @@ import {
Card,
Group,
LoadingOverlay,
- Menu,
+ Menu, Modal,
SimpleGrid,
Stack,
Text,
Title,
} from '@mantine/core';
-import { useListState } from '@mantine/hooks';
+import { useDisclosure, useListState } from '@mantine/hooks';
import {
IconBox,
IconCategory,
+ IconCopy, IconCursorText,
IconDeviceFloppy,
IconDotsVertical,
IconFolderFilled,
@@ -39,19 +40,40 @@ import { getServerSideTranslations } from '~/tools/server/getServerSideTranslati
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
import { manageNamespaces } from '~/tools/server/translation-namespaces';
import { api } from '~/utils/api';
+import { modals } from '@mantine/modals';
+import { notifications } from '@mantine/notifications';
+import { RenameBoardModal } from '~/components/Dashboard/Modals/RenameBoard/RenameBoardModal';
+import { useState } from 'react';
// Infer return type from the `getServerSideProps` function
export default function BoardsPage({
- boards,
- session,
+ boards,
+ session,
}: InferGetServerSidePropsType) {
+ const [openedRenameBoardModal, { open: openRenameBoardModal, close: closeRenameBoardModal }] = useDisclosure(false);
+ const [renameBoardName, setRenameBoardName] = useState<{ boardName: string }>();
+
const { data, refetch } = api.boards.all.useQuery(undefined, {
initialData: boards,
cacheTime: 1000 * 60 * 5, // Cache for 5 minutes
});
const { mutateAsync } = api.user.makeDefaultDashboard.useMutation({
onSettled: () => {
- refetch();
+ void refetch();
+ },
+ });
+
+ const utils = api.useUtils();
+
+ const { mutateAsync: mutateDuplicateBoardAsync } = api.boards.duplicateBoard.useMutation({
+ onSettled: () => {
+ void utils.boards.all.invalidate();
+ },
+ onError: (error) => {
+ notifications.show({
+ title: 'An error occurred while duplicating',
+ message: error.message,
+ });
},
});
@@ -67,6 +89,12 @@ export default function BoardsPage({
{metaTitle}
+
+ board.name)}
+ onClose={closeRenameBoardModal} />
+
+
{t('pageTitle')}
{session?.user.isAdmin && (
@@ -165,6 +193,26 @@ export default function BoardsPage({
+ {
+ await mutateDuplicateBoardAsync({
+ boardName: board.name,
+ });
+ }}
+ icon={}>
+ {t('cards.menu.duplicate')}
+
+ {
+ setRenameBoardName({
+ boardName: board.name as string
+ });
+ openRenameBoardModal();
+ }}
+ icon={}
+ disabled={board.name === 'default'}>
+ {t('cards.menu.rename.label')}
+
}
onClick={async () => {
@@ -177,7 +225,6 @@ export default function BoardsPage({
{session?.user.isAdmin && (
<>
-
{
openDeleteBoardModal({
@@ -216,7 +263,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const result = checkForSessionOrAskForLogin(
context,
session,
- () => session?.user.isAdmin == true
+ () => session?.user.isAdmin == true,
);
if (result !== undefined) {
return result;
@@ -233,7 +280,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
manageNamespaces,
context.locale,
context.req,
- context.res
+ context.res,
);
return {
diff --git a/src/server/api/routers/board.ts b/src/server/api/routers/board.ts
index 72bab7eaf..e330ff3dd 100644
--- a/src/server/api/routers/board.ts
+++ b/src/server/api/routers/board.ts
@@ -1,6 +1,7 @@
import { TRPCError } from '@trpc/server';
import fs from 'fs';
import { z } from 'zod';
+import Consola from 'consola';
import { getDefaultBoardAsync } from '~/server/db/queries/userSettings';
import { configExists } from '~/tools/config/configExists';
import { getConfig } from '~/tools/config/getConfig';
@@ -8,7 +9,8 @@ import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
import { generateDefaultApp } from '~/tools/shared/app';
import { adminProcedure, createTRPCRouter, protectedProcedure } from '../trpc';
-import { configNameSchema } from './config';
+import { writeConfig } from '~/tools/config/writeConfig';
+import { configNameSchema } from '~/validations/boards';
export const boardRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => {
@@ -31,7 +33,7 @@ export const boardRouter = createTRPCRouter({
countCategories: config.categories.length,
isDefaultForUser: name === defaultBoard,
};
- })
+ }),
);
}),
addAppsForContainers: adminProcedure
@@ -43,18 +45,18 @@ export const boardRouter = createTRPCRouter({
name: z.string(),
icon: z.string().optional(),
port: z.number().optional(),
- })
+ }),
),
- })
+ }),
)
.mutation(async ({ input }) => {
- if (!(await configExists(input.boardName))) {
+ if (!configExists(input.boardName)) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Board not found',
});
}
- const config = await getConfig(input.boardName);
+ const config = getConfig(input.boardName);
const lowestWrapper = config?.wrappers.sort((a, b) => a.position - b.position)[0];
const newConfig = {
@@ -86,4 +88,96 @@ export const boardRouter = createTRPCRouter({
const targetPath = `data/configs/${input.boardName}.json`;
fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8');
}),
+ renameBoard: protectedProcedure
+ .input(z.object({
+ oldName: z.string(),
+ newName: z.string().min(1),
+ }))
+ .mutation(async ({ input }) => {
+ if (input.oldName === 'default') {
+ Consola.error(`Attempted to rename default configuration. Aborted deletion.`);
+ throw new TRPCError({
+ code: 'CONFLICT',
+ message: 'Cannot rename default board',
+ });
+ }
+
+ if (!configExists(input.oldName)) {
+ Consola.error(`Specified configuration ${input.oldName} does not exist on file system`);
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Board not found',
+ });
+ }
+
+ if (configExists(input.newName)) {
+ Consola.error(`Target name of rename conflicts with existing board`);
+ throw new TRPCError({
+ code: 'CONFLICT',
+ message: 'Board conflicts with existing board',
+ });
+ }
+
+ const config = getConfig(input.oldName);
+ config.configProperties.name = input.newName;
+ writeConfig(config);
+ Consola.info(`Deleting ${input.oldName} from the file system`);
+ const targetPath = `data/configs/${input.oldName}.json`;
+ fs.unlinkSync(targetPath);
+ Consola.info(`Deleted ${input.oldName} from file system`);
+ }),
+ duplicateBoard: protectedProcedure
+ .input(z.object({
+ boardName: z.string(),
+ }))
+ .mutation(async ({ input }) => {
+ if (!configExists(input.boardName)) {
+ Consola.error(`Tried to duplicate ${input.boardName} but this configuration does not exist.`);
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Board not found',
+ });
+ }
+
+ const targetName = attemptGenerateDuplicateName(input.boardName, 10);
+
+ Consola.info(`Target duplication name ${targetName} does not exist`);
+
+ const config = getConfig(input.boardName);
+ config.configProperties.name = targetName;
+ writeConfig(config);
+
+ Consola.info(`Wrote config to name '${targetName}'`)
+ }),
});
+
+const duplicationName = /^(\w+)\s{1}\(([0-9]+)\)$/;
+
+const attemptGenerateDuplicateName = (baseName: string, maxAttempts: number) => {
+ for (let i = 0; i < maxAttempts; i++) {
+ const newName = generateDuplicateName(baseName, i);
+ if (configExists(newName)) {
+ continue;
+ }
+
+ return newName;
+ }
+
+ Consola.error(`Duplication name ${baseName} conflicts with an existing configuration`);
+ throw new TRPCError({
+ code: 'CONFLICT',
+ message: 'Board conflicts with an existing board',
+ });
+}
+
+const generateDuplicateName = (baseName: string, increment: number) => {
+ const result = duplicationName.exec(baseName);
+
+ if (result && result.length === 3) {
+ const originalName = result.at(1);
+ const counter = Number(result.at(2));
+ return `${originalName} (${counter + 1 + increment})`;
+ }
+
+ return `${baseName} (2)`;
+}
diff --git a/src/server/api/routers/config.ts b/src/server/api/routers/config.ts
index be8d797d4..f7ab94174 100644
--- a/src/server/api/routers/config.ts
+++ b/src/server/api/routers/config.ts
@@ -8,15 +8,10 @@ import { configExists } from '~/tools/config/configExists';
import { getConfig } from '~/tools/config/getConfig';
import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
import { BackendConfigType, ConfigType } from '~/types/config';
-import { boardCustomizationSchema } from '~/validations/boards';
+import { boardCustomizationSchema, configNameSchema } from '~/validations/boards';
import { IRssWidget } from '~/widgets/rss/RssWidgetTile';
import { adminProcedure, createTRPCRouter, publicProcedure } from '../trpc';
-import { db } from '~/server/db';
-import { users } from '~/server/db/schema';
-import { sql } from 'drizzle-orm';
-
-export const configNameSchema = z.string().regex(/^[a-zA-Z0-9-_]+$/);
export const configRouter = createTRPCRouter({
delete: adminProcedure
diff --git a/src/validations/boards.ts b/src/validations/boards.ts
index 0fcedd7d5..be3347cc1 100644
--- a/src/validations/boards.ts
+++ b/src/validations/boards.ts
@@ -2,8 +2,10 @@ import { DEFAULT_THEME, MANTINE_COLORS, MantineColor } from '@mantine/core';
import { z } from 'zod';
import { BackgroundImageAttachment, BackgroundImageRepeat, BackgroundImageSize } from '~/types/settings';
+export const configNameSchema = z.string().regex(/^[a-zA-Z0-9-_\s()]+$/);
+
export const createBoardSchemaValidation = z.object({
- name: z.string().min(2).max(25),
+ name: configNameSchema,
});
export const boardCustomizationSchema = z.object({