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 ( +
+ {isError && error && ( + } mb={"md"}> + {error.message} + + )} + + + + ); +}; 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({