mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-15 17:56:21 +01:00
feature: board operations (#1800)
This commit is contained in:
@@ -15,6 +15,19 @@
|
|||||||
"delete": {
|
"delete": {
|
||||||
"label": "Delete permanently",
|
"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."
|
"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": {
|
"badges": {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
{isError && error && (
|
||||||
|
<Alert icon={<IconAlertCircle size={"1rem"} />} mb={"md"}>
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<TextInput
|
||||||
|
label={t('cards.menu.rename.modal.fields.name.label')}
|
||||||
|
placeholder={t('cards.menu.rename.modal.fields.name.placeholder') as string}
|
||||||
|
data-autofocus
|
||||||
|
{...form.getInputProps('newName')} />
|
||||||
|
<Button
|
||||||
|
loading={isLoading}
|
||||||
|
fullWidth
|
||||||
|
mt="md"
|
||||||
|
type={'submit'}
|
||||||
|
variant={"light"}>
|
||||||
|
{t('common:confirm')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -30,7 +30,7 @@ export const modals = {
|
|||||||
copyInviteModal: CopyInviteModal,
|
copyInviteModal: CopyInviteModal,
|
||||||
deleteBoardModal: DeleteBoardModal,
|
deleteBoardModal: DeleteBoardModal,
|
||||||
changeUserRoleModal: ChangeUserRoleModal,
|
changeUserRoleModal: ChangeUserRoleModal,
|
||||||
dockerSelectBoardModal: DockerSelectBoardModal,
|
dockerSelectBoardModal: DockerSelectBoardModal
|
||||||
};
|
};
|
||||||
|
|
||||||
declare module '@mantine/modals' {
|
declare module '@mantine/modals' {
|
||||||
|
|||||||
@@ -5,16 +5,17 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
LoadingOverlay,
|
LoadingOverlay,
|
||||||
Menu,
|
Menu, Modal,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useListState } from '@mantine/hooks';
|
import { useDisclosure, useListState } from '@mantine/hooks';
|
||||||
import {
|
import {
|
||||||
IconBox,
|
IconBox,
|
||||||
IconCategory,
|
IconCategory,
|
||||||
|
IconCopy, IconCursorText,
|
||||||
IconDeviceFloppy,
|
IconDeviceFloppy,
|
||||||
IconDotsVertical,
|
IconDotsVertical,
|
||||||
IconFolderFilled,
|
IconFolderFilled,
|
||||||
@@ -39,19 +40,40 @@ import { getServerSideTranslations } from '~/tools/server/getServerSideTranslati
|
|||||||
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||||
import { manageNamespaces } from '~/tools/server/translation-namespaces';
|
import { manageNamespaces } from '~/tools/server/translation-namespaces';
|
||||||
import { api } from '~/utils/api';
|
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
|
// Infer return type from the `getServerSideProps` function
|
||||||
export default function BoardsPage({
|
export default function BoardsPage({
|
||||||
boards,
|
boards,
|
||||||
session,
|
session,
|
||||||
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||||
|
const [openedRenameBoardModal, { open: openRenameBoardModal, close: closeRenameBoardModal }] = useDisclosure(false);
|
||||||
|
const [renameBoardName, setRenameBoardName] = useState<{ boardName: string }>();
|
||||||
|
|
||||||
const { data, refetch } = api.boards.all.useQuery(undefined, {
|
const { data, refetch } = api.boards.all.useQuery(undefined, {
|
||||||
initialData: boards,
|
initialData: boards,
|
||||||
cacheTime: 1000 * 60 * 5, // Cache for 5 minutes
|
cacheTime: 1000 * 60 * 5, // Cache for 5 minutes
|
||||||
});
|
});
|
||||||
const { mutateAsync } = api.user.makeDefaultDashboard.useMutation({
|
const { mutateAsync } = api.user.makeDefaultDashboard.useMutation({
|
||||||
onSettled: () => {
|
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({
|
|||||||
<title>{metaTitle}</title>
|
<title>{metaTitle}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
|
<Modal opened={openedRenameBoardModal} onClose={closeRenameBoardModal}
|
||||||
|
title={t('cards.menu.rename.modal.title', { name: renameBoardName?.boardName })}>
|
||||||
|
<RenameBoardModal boardName={renameBoardName?.boardName as string} configNames={data.map(board => board.name)}
|
||||||
|
onClose={closeRenameBoardModal} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Group position="apart">
|
<Group position="apart">
|
||||||
<Title mb="xl">{t('pageTitle')}</Title>
|
<Title mb="xl">{t('pageTitle')}</Title>
|
||||||
{session?.user.isAdmin && (
|
{session?.user.isAdmin && (
|
||||||
@@ -165,6 +193,26 @@ export default function BoardsPage({
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={async () => {
|
||||||
|
await mutateDuplicateBoardAsync({
|
||||||
|
boardName: board.name,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
icon={<IconCopy size={'1rem'} />}>
|
||||||
|
{t('cards.menu.duplicate')}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={() => {
|
||||||
|
setRenameBoardName({
|
||||||
|
boardName: board.name as string
|
||||||
|
});
|
||||||
|
openRenameBoardModal();
|
||||||
|
}}
|
||||||
|
icon={<IconCursorText size={'1rem'} />}
|
||||||
|
disabled={board.name === 'default'}>
|
||||||
|
{t('cards.menu.rename.label')}
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
icon={<IconDeviceFloppy size="1rem" />}
|
icon={<IconDeviceFloppy size="1rem" />}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -177,7 +225,6 @@ export default function BoardsPage({
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
{session?.user.isAdmin && (
|
{session?.user.isAdmin && (
|
||||||
<>
|
<>
|
||||||
<Menu.Divider />
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
openDeleteBoardModal({
|
openDeleteBoardModal({
|
||||||
@@ -216,7 +263,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
const result = checkForSessionOrAskForLogin(
|
const result = checkForSessionOrAskForLogin(
|
||||||
context,
|
context,
|
||||||
session,
|
session,
|
||||||
() => session?.user.isAdmin == true
|
() => session?.user.isAdmin == true,
|
||||||
);
|
);
|
||||||
if (result !== undefined) {
|
if (result !== undefined) {
|
||||||
return result;
|
return result;
|
||||||
@@ -233,7 +280,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
manageNamespaces,
|
manageNamespaces,
|
||||||
context.locale,
|
context.locale,
|
||||||
context.req,
|
context.req,
|
||||||
context.res
|
context.res,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import Consola from 'consola';
|
||||||
import { getDefaultBoardAsync } from '~/server/db/queries/userSettings';
|
import { getDefaultBoardAsync } from '~/server/db/queries/userSettings';
|
||||||
import { configExists } from '~/tools/config/configExists';
|
import { configExists } from '~/tools/config/configExists';
|
||||||
import { getConfig } from '~/tools/config/getConfig';
|
import { getConfig } from '~/tools/config/getConfig';
|
||||||
@@ -8,7 +9,8 @@ import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
|
|||||||
import { generateDefaultApp } from '~/tools/shared/app';
|
import { generateDefaultApp } from '~/tools/shared/app';
|
||||||
|
|
||||||
import { adminProcedure, createTRPCRouter, protectedProcedure } from '../trpc';
|
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({
|
export const boardRouter = createTRPCRouter({
|
||||||
all: protectedProcedure.query(async ({ ctx }) => {
|
all: protectedProcedure.query(async ({ ctx }) => {
|
||||||
@@ -31,7 +33,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
countCategories: config.categories.length,
|
countCategories: config.categories.length,
|
||||||
isDefaultForUser: name === defaultBoard,
|
isDefaultForUser: name === defaultBoard,
|
||||||
};
|
};
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
addAppsForContainers: adminProcedure
|
addAppsForContainers: adminProcedure
|
||||||
@@ -43,18 +45,18 @@ export const boardRouter = createTRPCRouter({
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
icon: z.string().optional(),
|
icon: z.string().optional(),
|
||||||
port: z.number().optional(),
|
port: z.number().optional(),
|
||||||
})
|
}),
|
||||||
),
|
),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
if (!(await configExists(input.boardName))) {
|
if (!configExists(input.boardName)) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'NOT_FOUND',
|
code: 'NOT_FOUND',
|
||||||
message: 'Board 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 lowestWrapper = config?.wrappers.sort((a, b) => a.position - b.position)[0];
|
||||||
|
|
||||||
const newConfig = {
|
const newConfig = {
|
||||||
@@ -86,4 +88,96 @@ export const boardRouter = createTRPCRouter({
|
|||||||
const targetPath = `data/configs/${input.boardName}.json`;
|
const targetPath = `data/configs/${input.boardName}.json`;
|
||||||
fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8');
|
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)`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,15 +8,10 @@ import { configExists } from '~/tools/config/configExists';
|
|||||||
import { getConfig } from '~/tools/config/getConfig';
|
import { getConfig } from '~/tools/config/getConfig';
|
||||||
import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
|
import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
|
||||||
import { BackendConfigType, ConfigType } from '~/types/config';
|
import { BackendConfigType, ConfigType } from '~/types/config';
|
||||||
import { boardCustomizationSchema } from '~/validations/boards';
|
import { boardCustomizationSchema, configNameSchema } from '~/validations/boards';
|
||||||
import { IRssWidget } from '~/widgets/rss/RssWidgetTile';
|
import { IRssWidget } from '~/widgets/rss/RssWidgetTile';
|
||||||
|
|
||||||
import { adminProcedure, createTRPCRouter, publicProcedure } from '../trpc';
|
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({
|
export const configRouter = createTRPCRouter({
|
||||||
delete: adminProcedure
|
delete: adminProcedure
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { DEFAULT_THEME, MANTINE_COLORS, MantineColor } from '@mantine/core';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { BackgroundImageAttachment, BackgroundImageRepeat, BackgroundImageSize } from '~/types/settings';
|
import { BackgroundImageAttachment, BackgroundImageRepeat, BackgroundImageSize } from '~/types/settings';
|
||||||
|
|
||||||
|
export const configNameSchema = z.string().regex(/^[a-zA-Z0-9-_\s()]+$/);
|
||||||
|
|
||||||
export const createBoardSchemaValidation = z.object({
|
export const createBoardSchemaValidation = z.object({
|
||||||
name: z.string().min(2).max(25),
|
name: configNameSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const boardCustomizationSchema = z.object({
|
export const boardCustomizationSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user