feat: add board access settings (#249)
* wip: add board access settings * wip: add user access control * wip: add user access control * feat: add user access control * refactor: move away from mantine-modal-manager * fix: ci issues and failing tests * fix: lint issue * fix: format issue * fix: deepsource issues * chore: address pull request feedback
This commit is contained in:
@@ -4,6 +4,7 @@ import { useCallback } from "react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
@@ -12,7 +13,6 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { ActionIcon, IconTrash } from "@homarr/ui";
|
||||
|
||||
import { revalidatePathAction } from "../../../revalidatePathAction";
|
||||
import { modalEvents } from "../../modals";
|
||||
|
||||
interface AppDeleteButtonProps {
|
||||
app: RouterOutputs["app"]["all"][number];
|
||||
@@ -20,10 +20,11 @@ interface AppDeleteButtonProps {
|
||||
|
||||
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||
const t = useScopedI18n("app.page.delete");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { mutate, isPending } = clientApi.app.delete.useMutation();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
modalEvents.openConfirmModal({
|
||||
openConfirmModal({
|
||||
title: t("title"),
|
||||
children: t("message", app),
|
||||
onConfirm: () => {
|
||||
@@ -47,7 +48,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
|
||||
);
|
||||
},
|
||||
});
|
||||
}, [app, mutate, t]);
|
||||
}, [app, mutate, t, openConfirmModal]);
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
@@ -11,7 +12,6 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { ActionIcon, IconTrash } from "@homarr/ui";
|
||||
|
||||
import { revalidatePathAction } from "../../../revalidatePathAction";
|
||||
import { modalEvents } from "../../modals";
|
||||
|
||||
interface DeleteIntegrationActionButtonProps {
|
||||
count: number;
|
||||
@@ -24,6 +24,7 @@ export const DeleteIntegrationActionButton = ({
|
||||
}: DeleteIntegrationActionButtonProps) => {
|
||||
const t = useScopedI18n("integration.page.delete");
|
||||
const router = useRouter();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { mutateAsync, isPending } = clientApi.integration.delete.useMutation();
|
||||
|
||||
return (
|
||||
@@ -32,7 +33,7 @@ export const DeleteIntegrationActionButton = ({
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
modalEvents.openConfirmModal({
|
||||
openConfirmModal({
|
||||
title: t("title"),
|
||||
children: t("message", integration),
|
||||
onConfirm: () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getDefaultSecretKinds,
|
||||
} from "@homarr/definitions";
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
@@ -19,7 +20,6 @@ import { Button, Fieldset, Group, Stack, TextInput } from "@homarr/ui";
|
||||
import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { modalEvents } from "~/app/[locale]/modals";
|
||||
import { SecretCard } from "../../_integration-secret-card";
|
||||
import { IntegrationSecretInput } from "../../_integration-secret-inputs";
|
||||
import {
|
||||
@@ -35,9 +35,10 @@ interface EditIntegrationForm {
|
||||
|
||||
export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
const t = useI18n();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const secretsKinds =
|
||||
getAllSecretKindOptions(integration.kind).find((x) =>
|
||||
integration.secrets.every((y) => x.includes(y.kind)),
|
||||
getAllSecretKindOptions(integration.kind).find((secretKinds) =>
|
||||
integration.secrets.every((secret) => secretKinds.includes(secret.kind)),
|
||||
) ?? getDefaultSecretKinds(integration.kind);
|
||||
const initialFormValues = {
|
||||
name: integration.name,
|
||||
@@ -99,7 +100,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((v) => void handleSubmit(v))}>
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
|
||||
<Stack>
|
||||
<TestConnectionNoticeAlert />
|
||||
|
||||
@@ -128,7 +129,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
) {
|
||||
return res(true);
|
||||
}
|
||||
modalEvents.openConfirmModal({
|
||||
openConfirmModal({
|
||||
title: t("integration.secrets.reset.title"),
|
||||
children: t("integration.secrets.reset.message"),
|
||||
onCancel: () => res(false),
|
||||
|
||||
@@ -33,7 +33,7 @@ export const IntegrationCreateDropdownContent = () => {
|
||||
leftSection={<IconSearch stroke={1.5} size={20} />}
|
||||
placeholder={t("integration.page.list.search")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
|
||||
{filteredKinds.length > 0 ? (
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ModalsManager } from "../modals";
|
||||
|
||||
export const ModalsProvider = ({ children }: PropsWithChildren) => {
|
||||
const t = useScopedI18n("common.action");
|
||||
return (
|
||||
<ModalsManager
|
||||
labels={{
|
||||
cancel: t("cancel"),
|
||||
confirm: t("confirm"),
|
||||
}}
|
||||
modalProps={{
|
||||
styles: {
|
||||
title: {
|
||||
fontSize: "1.25rem",
|
||||
fontWeight: 500,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ModalsManager>
|
||||
);
|
||||
};
|
||||
@@ -54,7 +54,7 @@ export const LoginForm = () => {
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<form onSubmit={form.onSubmit((v) => void handleSubmit(v))}>
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
|
||||
<Stack gap="lg">
|
||||
<TextInput
|
||||
label={t("field.username.label")}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
@@ -21,10 +23,11 @@ import {
|
||||
Menu,
|
||||
} from "@homarr/ui";
|
||||
|
||||
import { modalEvents } from "~/app/[locale]/modals";
|
||||
import { revalidatePathAction } from "~/app/revalidatePathAction";
|
||||
import { editModeAtom } from "~/components/board/editMode";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
import { useRequiredBoard } from "../../_context";
|
||||
|
||||
@@ -46,9 +49,36 @@ export default function BoardViewHeaderActions() {
|
||||
}
|
||||
|
||||
const AddMenu = () => {
|
||||
const { openModal: openCategoryEditModal } =
|
||||
useModalAction(CategoryEditModal);
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { addCategoryToEnd } = useCategoryActions();
|
||||
const t = useI18n();
|
||||
|
||||
const handleAddCategory = useCallback(
|
||||
() =>
|
||||
openCategoryEditModal(
|
||||
{
|
||||
category: {
|
||||
id: "new",
|
||||
name: "",
|
||||
},
|
||||
onSuccess({ name }) {
|
||||
addCategoryToEnd({ name });
|
||||
},
|
||||
submitLabel: t("section.category.create.submit"),
|
||||
},
|
||||
{
|
||||
title: (t) => t("section.category.create.title"),
|
||||
},
|
||||
),
|
||||
[addCategoryToEnd, openCategoryEditModal, t],
|
||||
);
|
||||
|
||||
const handleSelectItem = useCallback(() => {
|
||||
openItemSelectModal();
|
||||
}, [openItemSelectModal]);
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end" withArrow>
|
||||
<Menu.Target>
|
||||
@@ -62,14 +92,7 @@ const AddMenu = () => {
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item
|
||||
leftSection={<IconBox size={20} />}
|
||||
onClick={() =>
|
||||
modalEvents.openManagedModal({
|
||||
title: t("item.create.title"),
|
||||
size: "xl",
|
||||
modal: "itemSelectModal",
|
||||
innerProps: {},
|
||||
})
|
||||
}
|
||||
onClick={handleSelectItem}
|
||||
>
|
||||
{t("item.action.create")}
|
||||
</Menu.Item>
|
||||
@@ -81,22 +104,7 @@ const AddMenu = () => {
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconBoxAlignTop size={20} />}
|
||||
onClick={() =>
|
||||
modalEvents.openManagedModal({
|
||||
title: t("section.category.create.title"),
|
||||
modal: "categoryEditModal",
|
||||
innerProps: {
|
||||
submitLabel: t("section.category.create.submit"),
|
||||
category: {
|
||||
id: "new",
|
||||
name: "",
|
||||
},
|
||||
onSuccess({ name }) {
|
||||
addCategoryToEnd({ name });
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
onClick={handleAddCategory}
|
||||
>
|
||||
{t("section.category.action.create")}
|
||||
</Menu.Item>
|
||||
|
||||
293
apps/nextjs/src/app/[locale]/boards/[name]/settings/_access.tsx
Normal file
293
apps/nextjs/src/app/[locale]/boards/[name]/settings/_access.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { BoardPermission } from "@homarr/definitions";
|
||||
import { boardPermissions } from "@homarr/definitions";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { SelectProps, TablerIcon } from "@homarr/ui";
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
Group,
|
||||
IconCheck,
|
||||
IconEye,
|
||||
IconPencil,
|
||||
IconPlus,
|
||||
IconSettings,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
} from "@homarr/ui";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
|
||||
interface Props {
|
||||
board: Board;
|
||||
initialPermissions: RouterOutputs["board"]["permissions"];
|
||||
}
|
||||
|
||||
export const AccessSettingsContent = ({ board, initialPermissions }: Props) => {
|
||||
const { data: permissions } = clientApi.board.permissions.useQuery(
|
||||
{
|
||||
id: board.id,
|
||||
},
|
||||
{
|
||||
initialData: initialPermissions,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
);
|
||||
|
||||
const t = useI18n();
|
||||
const form = useForm<FormType>({
|
||||
initialValues: {
|
||||
permissions: permissions.sort((permissionA, permissionB) => {
|
||||
if (permissionA.user.id === board.creatorId) return -1;
|
||||
if (permissionB.user.id === board.creatorId) return 1;
|
||||
return permissionA.user.name.localeCompare(permissionB.user.name);
|
||||
}),
|
||||
},
|
||||
});
|
||||
const { mutate, isPending } = clientApi.board.savePermissions.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
const { openModal } = useModalAction(UserSelectModal);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(v: FormType) => {
|
||||
mutate(
|
||||
{
|
||||
id: board.id,
|
||||
permissions: v.permissions,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
void utils.board.permissions.invalidate();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[board.id, mutate, utils.board.permissions],
|
||||
);
|
||||
|
||||
const handleAddUser = useCallback(() => {
|
||||
const presentUserIds = form.values.permissions.map(
|
||||
(permission) => permission.user.id,
|
||||
);
|
||||
|
||||
openModal({
|
||||
presentUserIds: board.creatorId
|
||||
? presentUserIds.concat(board.creatorId)
|
||||
: presentUserIds,
|
||||
onSelect: (user) => {
|
||||
form.setFieldValue("permissions", [
|
||||
...form.values.permissions,
|
||||
{
|
||||
user,
|
||||
permission: "board-view",
|
||||
},
|
||||
]);
|
||||
},
|
||||
});
|
||||
}, [form, openModal, board.creatorId]);
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<Table>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>
|
||||
{t("board.setting.section.access.permission.field.user.label")}
|
||||
</TableTh>
|
||||
<TableTh>
|
||||
{t(
|
||||
"board.setting.section.access.permission.field.permission.label",
|
||||
)}
|
||||
</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{board.creator && <CreatorRow user={board.creator} />}
|
||||
{form.values.permissions.map((row, index) => {
|
||||
const Icon = icons[row.permission];
|
||||
return (
|
||||
<TableTr key={row.user.id}>
|
||||
<TableTd>{row.user.name}</TableTd>
|
||||
<TableTd>
|
||||
<Group wrap="nowrap">
|
||||
<Select
|
||||
flex="1"
|
||||
leftSection={<Icon size="1rem" />}
|
||||
renderOption={RenderOption}
|
||||
variant="unstyled"
|
||||
data={boardPermissions.map((permission) => ({
|
||||
value: permission,
|
||||
label: t(
|
||||
`board.setting.section.access.permission.item.${permission}.label`,
|
||||
),
|
||||
}))}
|
||||
{...form.getInputProps(
|
||||
`permissions.${index}.permission`,
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
form.setFieldValue(
|
||||
"permissions",
|
||||
form.values.permissions.filter(
|
||||
(_, i) => i !== index,
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("common.action.remove")}
|
||||
</Button>
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
})}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
rightSection={<IconPlus size="1rem" />}
|
||||
variant="light"
|
||||
onClick={handleAddUser}
|
||||
>
|
||||
{t("common.action.add")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface CreatorRowProps {
|
||||
user: Exclude<Board["creator"], null>;
|
||||
}
|
||||
|
||||
const CreatorRow = ({ user }: CreatorRowProps) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<TableTr>
|
||||
<TableTd>{user.name}</TableTd>
|
||||
<TableTd>
|
||||
<Group gap={0}>
|
||||
<Flex w={34} h={34} align="center" justify="center">
|
||||
<IconSettings
|
||||
size="1rem"
|
||||
color="var(--input-section-color, var(--mantine-color-dimmed))"
|
||||
/>
|
||||
</Flex>
|
||||
<Text size="sm">
|
||||
{t("board.setting.section.access.permission.item.board-full.label")}
|
||||
</Text>
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
};
|
||||
|
||||
const icons = {
|
||||
"board-change": IconPencil,
|
||||
"board-view": IconEye,
|
||||
} satisfies Record<BoardPermission, TablerIcon>;
|
||||
|
||||
const iconProps = {
|
||||
stroke: 1.5,
|
||||
color: "currentColor",
|
||||
opacity: 0.6,
|
||||
size: "1rem",
|
||||
};
|
||||
|
||||
const RenderOption: SelectProps["renderOption"] = ({ option, checked }) => {
|
||||
const Icon = icons[option.value as BoardPermission];
|
||||
return (
|
||||
<Group flex="1" gap="xs">
|
||||
<Icon {...iconProps} />
|
||||
{option.label}
|
||||
{checked && (
|
||||
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface FormType {
|
||||
permissions: RouterOutputs["board"]["permissions"];
|
||||
}
|
||||
|
||||
interface InnerProps {
|
||||
presentUserIds: string[];
|
||||
onSelect: (props: { id: string; name: string }) => void;
|
||||
}
|
||||
|
||||
interface UserSelectFormType {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export const UserSelectModal = createModal<InnerProps>(
|
||||
({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const { data: users } = clientApi.user.selectable.useQuery();
|
||||
const form = useForm<UserSelectFormType>();
|
||||
const handleSubmit = (v: UserSelectFormType) => {
|
||||
const currentUser = users?.find((user) => user.id === v.userId);
|
||||
if (!currentUser) return;
|
||||
innerProps.onSelect({
|
||||
id: currentUser.id,
|
||||
name: currentUser.name ?? "",
|
||||
});
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<Select
|
||||
{...form.getInputProps("userId")}
|
||||
label={t(
|
||||
"board.setting.section.access.permission.userSelect.label",
|
||||
)}
|
||||
searchable
|
||||
nothingFoundMessage={t(
|
||||
"board.setting.section.access.permission.userSelect.notFound",
|
||||
)}
|
||||
limit={5}
|
||||
data={users
|
||||
?.filter((user) => !innerProps.presentUserIds.includes(user.id))
|
||||
.map((user) => ({ value: user.id, label: user.name ?? "" }))}
|
||||
/>
|
||||
<Group justify="end">
|
||||
<Button onClick={actions.closeModal}>
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{t("common.action.add")}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
).withOptions({
|
||||
defaultTitle: (t) =>
|
||||
t("board.setting.section.access.permission.userSelect.title"),
|
||||
});
|
||||
@@ -4,10 +4,11 @@ import { useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Button, Divider, Group, Stack, Text } from "@homarr/ui";
|
||||
|
||||
import { modalEvents } from "~/app/[locale]/modals";
|
||||
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
|
||||
import { useRequiredBoard } from "../../_context";
|
||||
import classes from "./danger.module.css";
|
||||
|
||||
@@ -15,6 +16,8 @@ export const DangerZoneSettingsContent = () => {
|
||||
const board = useRequiredBoard();
|
||||
const t = useScopedI18n("board.setting");
|
||||
const router = useRouter();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { openModal } = useModalAction(BoardRenameModal);
|
||||
const { mutate: changeVisibility, isPending: isChangeVisibilityPending } =
|
||||
clientApi.board.changeVisibility.useMutation();
|
||||
const { mutate: deleteBoard, isPending: isDeletePending } =
|
||||
@@ -24,31 +27,22 @@ export const DangerZoneSettingsContent = () => {
|
||||
|
||||
const onRenameClick = useCallback(
|
||||
() =>
|
||||
modalEvents.openManagedModal({
|
||||
modal: "boardRenameModal",
|
||||
title: t("section.dangerZone.action.rename.modal.title"),
|
||||
innerProps: {
|
||||
id: board.id,
|
||||
previousName: board.name,
|
||||
onSuccess: (name) => {
|
||||
router.push(`/boards/${name}/settings`);
|
||||
},
|
||||
},
|
||||
openModal({
|
||||
id: board.id,
|
||||
previousName: board.name,
|
||||
onSuccess: (name) => router.push(`/boards/${name}/settings`),
|
||||
}),
|
||||
[board.id, board.name, router, t],
|
||||
[board.id, board.name, router, openModal],
|
||||
);
|
||||
|
||||
const onVisibilityClick = useCallback(() => {
|
||||
modalEvents.openConfirmModal({
|
||||
openConfirmModal({
|
||||
title: t(
|
||||
`section.dangerZone.action.visibility.confirm.${visibility}.title`,
|
||||
),
|
||||
children: t(
|
||||
`section.dangerZone.action.visibility.confirm.${visibility}.description`,
|
||||
),
|
||||
confirmProps: {
|
||||
color: "red.9",
|
||||
},
|
||||
onConfirm: () => {
|
||||
changeVisibility(
|
||||
{
|
||||
@@ -72,15 +66,13 @@ export const DangerZoneSettingsContent = () => {
|
||||
utils.board.byName,
|
||||
utils.board.default,
|
||||
visibility,
|
||||
openConfirmModal,
|
||||
]);
|
||||
|
||||
const onDeleteClick = useCallback(() => {
|
||||
modalEvents.openConfirmModal({
|
||||
openConfirmModal({
|
||||
title: t("section.dangerZone.action.delete.confirm.title"),
|
||||
children: t("section.dangerZone.action.delete.confirm.description"),
|
||||
confirmProps: {
|
||||
color: "red.9",
|
||||
},
|
||||
onConfirm: () => {
|
||||
deleteBoard(
|
||||
{ id: board.id },
|
||||
@@ -92,7 +84,7 @@ export const DangerZoneSettingsContent = () => {
|
||||
);
|
||||
},
|
||||
});
|
||||
}, [board.id, deleteBoard, router, t]);
|
||||
}, [board.id, deleteBoard, router, t, openConfirmModal]);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
|
||||
@@ -16,12 +16,14 @@ import {
|
||||
IconLayout,
|
||||
IconPhoto,
|
||||
IconSettings,
|
||||
IconUser,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@homarr/ui";
|
||||
|
||||
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
||||
import { AccessSettingsContent } from "./_access";
|
||||
import { BackgroundSettingsContent } from "./_background";
|
||||
import { ColorSettingsContent } from "./_colors";
|
||||
import { CustomCssSettingsContent } from "./_customCss";
|
||||
@@ -43,6 +45,7 @@ export default async function BoardSettingsPage({
|
||||
searchParams,
|
||||
}: Props) {
|
||||
const board = await api.board.byName({ name: params.name });
|
||||
const permissions = await api.board.permissions({ id: board.id });
|
||||
const t = await getScopedI18n("board.setting");
|
||||
|
||||
return (
|
||||
@@ -68,6 +71,12 @@ export default async function BoardSettingsPage({
|
||||
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
|
||||
<CustomCssSettingsContent />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="access" icon={IconUser}>
|
||||
<AccessSettingsContent
|
||||
board={board}
|
||||
initialPermissions={permissions}
|
||||
/>
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor
|
||||
value="dangerZone"
|
||||
icon={IconAlertTriangle}
|
||||
|
||||
@@ -45,7 +45,9 @@ export const ClientBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const isReady = useIsBoardReady();
|
||||
|
||||
const sortedSections = board.sections.sort((a, b) => a.position - b.position);
|
||||
const sortedSections = board.sections.sort(
|
||||
(sectionA, sectionB) => sectionA.position - sectionB.position,
|
||||
);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ import type { Board } from "./_types";
|
||||
// This is placed here because it's used in the layout and the page and because it's here it's not needed to load it everywhere
|
||||
import "../../../styles/gridstack.scss";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { auth } from "@homarr/auth";
|
||||
import { and, db, eq, schema } from "@homarr/db";
|
||||
import { GlobalItemServerDataRunner } from "@homarr/widgets";
|
||||
|
||||
import { BoardMantineProvider } from "./_theme";
|
||||
@@ -51,10 +55,14 @@ export const createBoardPage = <TParams extends Record<string, unknown>>({
|
||||
</GlobalItemServerDataRunner>
|
||||
);
|
||||
},
|
||||
page: () => {
|
||||
// TODO: Add check if board is private and user is not logged in
|
||||
page: async ({ params }: { params: TParams }) => {
|
||||
const board = await getInitialBoard(params);
|
||||
|
||||
return <ClientBoard />;
|
||||
if (await canAccessBoardAsync(board)) {
|
||||
return <ClientBoard />;
|
||||
}
|
||||
|
||||
return notFound();
|
||||
},
|
||||
generateMetadata: async ({
|
||||
params,
|
||||
@@ -63,6 +71,10 @@ export const createBoardPage = <TParams extends Record<string, unknown>>({
|
||||
}): Promise<Metadata> => {
|
||||
const board = await getInitialBoard(params);
|
||||
|
||||
if (!(await canAccessBoardAsync(board))) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
title: board.metaTitle ?? `${capitalize(board.name)} board | Homarr`,
|
||||
icons: {
|
||||
@@ -72,3 +84,30 @@ export const createBoardPage = <TParams extends Record<string, unknown>>({
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const canAccessBoardAsync = async (board: Board) => {
|
||||
const session = await auth();
|
||||
|
||||
if (board.isPublic) {
|
||||
return true; // Public boards can be accessed by anyone
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return false; // Not logged in users can't access private boards
|
||||
}
|
||||
|
||||
if (board.creatorId === session?.user.id) {
|
||||
return true; // Creators can access their own private boards
|
||||
}
|
||||
|
||||
const permissions = await db.query.boardPermissions.findMany({
|
||||
where: and(
|
||||
eq(schema.boardPermissions.userId, session.user.id),
|
||||
eq(schema.boardPermissions.boardId, board.id),
|
||||
),
|
||||
});
|
||||
|
||||
return ["board-view", "board-change"].some((key) =>
|
||||
permissions.some(({ permission }) => key === permission),
|
||||
); // Allow access for all with any board permission
|
||||
};
|
||||
|
||||
@@ -51,7 +51,7 @@ export const InitUserForm = () => {
|
||||
<Stack gap="xl">
|
||||
<form
|
||||
onSubmit={form.onSubmit(
|
||||
(v) => void handleSubmit(v),
|
||||
(values) => void handleSubmit(values),
|
||||
(err) => console.log(err),
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -5,11 +5,11 @@ import "@homarr/notifications/styles.css";
|
||||
import "@homarr/spotlight/styles.css";
|
||||
import "@homarr/ui/styles.css";
|
||||
|
||||
import { ModalProvider } from "@homarr/modals";
|
||||
import { Notifications } from "@homarr/notifications";
|
||||
import { ColorSchemeScript, createTheme, MantineProvider } from "@homarr/ui";
|
||||
|
||||
import { JotaiProvider } from "./_client-providers/jotai";
|
||||
import { ModalsProvider } from "./_client-providers/modals";
|
||||
import { NextInternationalProvider } from "./_client-providers/next-international";
|
||||
import { TRPCReactProvider } from "./_client-providers/trpc";
|
||||
import { composeWrappers } from "./compose";
|
||||
@@ -67,7 +67,7 @@ export default function Layout(props: {
|
||||
})}
|
||||
/>
|
||||
),
|
||||
(innerProps) => <ModalsProvider {...innerProps} />,
|
||||
(innerProps) => <ModalProvider {...innerProps} />,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Button, IconCategoryPlus } from "@homarr/ui";
|
||||
|
||||
import { modalEvents } from "~/app/[locale]/modals";
|
||||
import { revalidatePathAction } from "~/app/revalidatePathAction";
|
||||
import { AddBoardModal } from "~/components/manage/boards/add-board-modal";
|
||||
|
||||
interface CreateBoardButtonProps {
|
||||
boardNames: string[];
|
||||
@@ -15,6 +16,7 @@ interface CreateBoardButtonProps {
|
||||
|
||||
export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(AddBoardModal);
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.board.create.useMutation({
|
||||
onSettled: async () => {
|
||||
@@ -22,20 +24,16 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
const onClick = React.useCallback(() => {
|
||||
modalEvents.openManagedModal({
|
||||
modal: "addBoardModal",
|
||||
title: t("management.page.board.button.create"),
|
||||
innerProps: {
|
||||
onSuccess: async (values) => {
|
||||
await mutateAsync({
|
||||
name: values.name,
|
||||
});
|
||||
},
|
||||
boardNames,
|
||||
const onClick = useCallback(() => {
|
||||
openModal({
|
||||
onSuccess: async (values) => {
|
||||
await mutateAsync({
|
||||
name: values.name,
|
||||
});
|
||||
},
|
||||
boardNames,
|
||||
});
|
||||
}, [mutateAsync, t, boardNames]);
|
||||
}, [mutateAsync, boardNames, openModal]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createModalManager } from "mantine-modal-manager";
|
||||
|
||||
import { WidgetEditModal } from "@homarr/widgets";
|
||||
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
|
||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||
import { AddBoardModal } from "~/components/manage/boards/add-board-modal";
|
||||
import { PreviewDimensionsModal } from "./widgets/[kind]/_dimension-modal";
|
||||
|
||||
export const [ModalsManager, modalEvents] = createModalManager({
|
||||
categoryEditModal: CategoryEditModal,
|
||||
widgetEditModal: WidgetEditModal,
|
||||
itemSelectModal: ItemSelectModal,
|
||||
addBoardModal: AddBoardModal,
|
||||
boardRenameModal: BoardRenameModal,
|
||||
dimensionsModal: PreviewDimensionsModal,
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import {
|
||||
@@ -17,10 +18,11 @@ import {
|
||||
import {
|
||||
loadWidgetDynamic,
|
||||
reduceWidgetOptionsWithDefaultValues,
|
||||
WidgetEditModal,
|
||||
widgetImports,
|
||||
} from "@homarr/widgets";
|
||||
|
||||
import { modalEvents } from "../../modals";
|
||||
import { PreviewDimensionsModal } from "./_dimension-modal";
|
||||
import type { Dimensions } from "./_dimension-modal";
|
||||
|
||||
interface WidgetPreviewPageContentProps {
|
||||
@@ -38,6 +40,10 @@ export const WidgetPreviewPageContent = ({
|
||||
integrationData,
|
||||
}: WidgetPreviewPageContentProps) => {
|
||||
const t = useScopedI18n("widgetPreview");
|
||||
const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal);
|
||||
const { openModal: openPreviewDimensionsModal } = useModalAction(
|
||||
PreviewDimensionsModal,
|
||||
);
|
||||
const currentDefinition = useMemo(
|
||||
() => widgetImports[kind].definition,
|
||||
[kind],
|
||||
@@ -55,28 +61,25 @@ export const WidgetPreviewPageContent = ({
|
||||
integrations: [],
|
||||
});
|
||||
|
||||
const Comp = loadWidgetDynamic(kind);
|
||||
|
||||
const openWitgetEditModal = useCallback(() => {
|
||||
return modalEvents.openManagedModal({
|
||||
modal: "widgetEditModal",
|
||||
innerProps: {
|
||||
kind,
|
||||
value: state,
|
||||
onSuccessfulEdit: (value) => {
|
||||
setState(value);
|
||||
},
|
||||
integrationData: integrationData.filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in currentDefinition &&
|
||||
(currentDefinition.supportedIntegrations as string[]).some(
|
||||
(kind) => kind === integration.kind,
|
||||
),
|
||||
),
|
||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||
const handleOpenEditWidgetModal = useCallback(() => {
|
||||
openWidgetEditModal({
|
||||
kind,
|
||||
value: state,
|
||||
onSuccessfulEdit: (value) => {
|
||||
setState(value);
|
||||
},
|
||||
integrationData: integrationData.filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in currentDefinition &&
|
||||
(currentDefinition.supportedIntegrations as string[]).some(
|
||||
(kind) => kind === integration.kind,
|
||||
),
|
||||
),
|
||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||
});
|
||||
}, [kind, state, integrationData, currentDefinition]);
|
||||
}, [currentDefinition, integrationData, kind, openWidgetEditModal, state]);
|
||||
|
||||
const Comp = loadWidgetDynamic(kind);
|
||||
|
||||
const toggleEditMode = useCallback(() => {
|
||||
setEditMode((editMode) => !editMode);
|
||||
@@ -86,15 +89,11 @@ export const WidgetPreviewPageContent = ({
|
||||
}, [editMode, t]);
|
||||
|
||||
const openDimensionsModal = useCallback(() => {
|
||||
modalEvents.openManagedModal({
|
||||
modal: "dimensionsModal",
|
||||
title: t("dimensions.title"),
|
||||
innerProps: {
|
||||
dimensions,
|
||||
setDimensions,
|
||||
},
|
||||
openPreviewDimensionsModal({
|
||||
dimensions,
|
||||
setDimensions,
|
||||
});
|
||||
}, [dimensions, t]);
|
||||
}, [dimensions, openPreviewDimensionsModal]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -107,7 +106,8 @@ export const WidgetPreviewPageContent = ({
|
||||
<Comp
|
||||
options={state.options as never}
|
||||
integrations={state.integrations.map(
|
||||
(id) => integrationData.find((x) => x.id === id)!,
|
||||
(id) =>
|
||||
integrationData.find((integration) => integration.id === id)!,
|
||||
)}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
@@ -119,7 +119,7 @@ export const WidgetPreviewPageContent = ({
|
||||
size={48}
|
||||
variant="default"
|
||||
radius="xl"
|
||||
onClick={openWitgetEditModal}
|
||||
onClick={handleOpenEditWidgetModal}
|
||||
>
|
||||
<IconPencil size={24} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ManagedModal } from "mantine-modal-manager";
|
||||
|
||||
import { useForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Button, Group, InputWrapper, Slider, Stack } from "@homarr/ui";
|
||||
|
||||
@@ -11,49 +10,50 @@ interface InnerProps {
|
||||
setDimensions: (dimensions: Dimensions) => void;
|
||||
}
|
||||
|
||||
export const PreviewDimensionsModal: ManagedModal<InnerProps> = ({
|
||||
actions,
|
||||
innerProps,
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const form = useForm({
|
||||
initialValues: innerProps.dimensions,
|
||||
});
|
||||
export const PreviewDimensionsModal = createModal<InnerProps>(
|
||||
({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const form = useForm({
|
||||
initialValues: innerProps.dimensions,
|
||||
});
|
||||
|
||||
const handleSubmit = (values: Dimensions) => {
|
||||
innerProps.setDimensions(values);
|
||||
actions.closeModal();
|
||||
};
|
||||
const handleSubmit = (values: Dimensions) => {
|
||||
innerProps.setDimensions(values);
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<InputWrapper label={t("item.move.field.width.label")}>
|
||||
<Slider
|
||||
min={64}
|
||||
max={1024}
|
||||
step={64}
|
||||
{...form.getInputProps("width")}
|
||||
/>
|
||||
</InputWrapper>
|
||||
<InputWrapper label={t("item.move.field.height.label")}>
|
||||
<Slider
|
||||
min={64}
|
||||
max={1024}
|
||||
step={64}
|
||||
{...form.getInputProps("height")}
|
||||
/>
|
||||
</InputWrapper>
|
||||
<Group justify="end">
|
||||
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{t("common.action.confirm")}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<InputWrapper label={t("item.move.field.width.label")}>
|
||||
<Slider
|
||||
min={64}
|
||||
max={1024}
|
||||
step={64}
|
||||
{...form.getInputProps("width")}
|
||||
/>
|
||||
</InputWrapper>
|
||||
<InputWrapper label={t("item.move.field.height.label")}>
|
||||
<Slider
|
||||
min={64}
|
||||
max={1024}
|
||||
step={64}
|
||||
{...form.getInputProps("height")}
|
||||
/>
|
||||
</InputWrapper>
|
||||
<Group justify="end">
|
||||
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{t("common.action.confirm")}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
).withOptions({
|
||||
defaultTitle: (t) => t("widgetPreview.dimensions.title"),
|
||||
});
|
||||
|
||||
export interface Dimensions {
|
||||
width: number;
|
||||
|
||||
@@ -41,8 +41,12 @@ export const useItemActions = () => {
|
||||
({ kind }: CreateItem) => {
|
||||
updateBoard((previous) => {
|
||||
const lastSection = previous.sections
|
||||
.filter((s): s is EmptySection => s.kind === "empty")
|
||||
.sort((a, b) => b.position - a.position)[0];
|
||||
.filter(
|
||||
(section): section is EmptySection => section.kind === "empty",
|
||||
)
|
||||
.sort(
|
||||
(sectionA, sectionB) => sectionB.position - sectionA.position,
|
||||
)[0];
|
||||
|
||||
if (!lastSection) return previous;
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ManagedModal } from "mantine-modal-manager";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Button, Card, Center, Grid, Stack, Text } from "@homarr/ui";
|
||||
|
||||
@@ -9,9 +8,7 @@ import { widgetImports } from "../../../../../../packages/widgets/src";
|
||||
import type { WidgetDefinition } from "../../../../../../packages/widgets/src/definition";
|
||||
import { useItemActions } from "./item-actions";
|
||||
|
||||
export const ItemSelectModal: ManagedModal<Record<string, never>> = ({
|
||||
actions,
|
||||
}) => {
|
||||
export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
return (
|
||||
<Grid>
|
||||
{objectEntries(widgetImports).map(([key, value]) => {
|
||||
@@ -26,7 +23,10 @@ export const ItemSelectModal: ManagedModal<Record<string, never>> = ({
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("item.create.title"),
|
||||
size: "xl",
|
||||
});
|
||||
|
||||
const WidgetItem = ({
|
||||
kind,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { ManagedModal } from "mantine-modal-manager";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Button, Group, Stack, TextInput } from "@homarr/ui";
|
||||
import type { validation, z } from "@homarr/validation";
|
||||
@@ -14,58 +13,60 @@ interface InnerProps {
|
||||
onSuccess?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const BoardRenameModal: ManagedModal<InnerProps> = ({
|
||||
actions,
|
||||
innerProps,
|
||||
}) => {
|
||||
const utils = clientApi.useUtils();
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.board.rename.useMutation({
|
||||
onSettled() {
|
||||
void utils.board.byName.invalidate({ name: innerProps.previousName });
|
||||
void utils.board.default.invalidate();
|
||||
},
|
||||
});
|
||||
const form = useForm<FormType>({
|
||||
initialValues: {
|
||||
name: innerProps.previousName,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: FormType) => {
|
||||
mutate(
|
||||
{
|
||||
id: innerProps.id,
|
||||
name: values.name,
|
||||
export const BoardRenameModal = createModal<InnerProps>(
|
||||
({ actions, innerProps }) => {
|
||||
const utils = clientApi.useUtils();
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.board.rename.useMutation({
|
||||
onSettled() {
|
||||
void utils.board.byName.invalidate({ name: innerProps.previousName });
|
||||
void utils.board.default.invalidate();
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
actions.closeModal();
|
||||
innerProps.onSuccess?.(values.name);
|
||||
});
|
||||
const form = useForm<FormType>({
|
||||
initialValues: {
|
||||
name: innerProps.previousName,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: FormType) => {
|
||||
mutate(
|
||||
{
|
||||
id: innerProps.id,
|
||||
name: values.name,
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
{
|
||||
onSuccess: () => {
|
||||
actions.closeModal();
|
||||
innerProps.onSuccess?.(values.name);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t("board.field.name.label")}
|
||||
{...form.getInputProps("name")}
|
||||
data-autofocus
|
||||
/>
|
||||
<Group justify="end">
|
||||
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.confirm")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t("board.field.name.label")}
|
||||
{...form.getInputProps("name")}
|
||||
data-autofocus
|
||||
/>
|
||||
<Group justify="end">
|
||||
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.confirm")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
).withOptions({
|
||||
defaultTitle: (t) =>
|
||||
t("board.setting.section.dangerZone.action.rename.modal.title"),
|
||||
});
|
||||
|
||||
type FormType = Omit<z.infer<(typeof validation)["board"]["rename"]>, "id">;
|
||||
|
||||
@@ -80,10 +80,10 @@ export const useCategoryActions = () => {
|
||||
updateBoard((previous) => {
|
||||
const lastSection = previous.sections
|
||||
.filter(
|
||||
(x): x is CategorySection | EmptySection =>
|
||||
x.kind === "empty" || x.kind === "category",
|
||||
(section): section is CategorySection | EmptySection =>
|
||||
section.kind === "empty" || section.kind === "category",
|
||||
)
|
||||
.sort((a, b) => b.position - a.position)
|
||||
.sort((sectionA, sectionB) => sectionB.position - sectionA.position)
|
||||
.at(0);
|
||||
|
||||
if (!lastSection) return previous;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ManagedModal } from "mantine-modal-manager";
|
||||
|
||||
import { useForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Button, Group, Stack, TextInput } from "@homarr/ui";
|
||||
|
||||
@@ -15,42 +14,41 @@ interface InnerProps {
|
||||
onSuccess: (category: Category) => void;
|
||||
}
|
||||
|
||||
export const CategoryEditModal: ManagedModal<InnerProps> = ({
|
||||
actions,
|
||||
innerProps,
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: innerProps.category.name,
|
||||
},
|
||||
});
|
||||
export const CategoryEditModal = createModal<InnerProps>(
|
||||
({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: innerProps.category.name,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((v) => {
|
||||
void innerProps.onSuccess({
|
||||
...innerProps.category,
|
||||
name: v.name,
|
||||
});
|
||||
actions.closeModal();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t("section.category.field.name.label")}
|
||||
data-autofocus
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<Group justify="right">
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" color="teal">
|
||||
{innerProps.submitLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
void innerProps.onSuccess({
|
||||
...innerProps.category,
|
||||
name: values.name,
|
||||
});
|
||||
actions.closeModal();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t("section.category.field.name.label")}
|
||||
data-autofocus
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<Group justify="right">
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" color="teal">
|
||||
{innerProps.submitLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
).withOptions({});
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { createId } from "@homarr/db/client";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
import { modalEvents } from "~/app/[locale]/modals";
|
||||
import { useCategoryActions } from "./category-actions";
|
||||
import { CategoryEditModal } from "./category-edit-modal";
|
||||
|
||||
export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
const { openModal } = useModalAction(CategoryEditModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { addCategory, moveCategory, removeCategory, renameCategory } =
|
||||
useCategoryActions();
|
||||
const t = useI18n();
|
||||
|
||||
const createCategoryAtPosition = useCallback(
|
||||
(position: number) => {
|
||||
modalEvents.openManagedModal({
|
||||
title: t("section.category.create.title"),
|
||||
modal: "categoryEditModal",
|
||||
innerProps: {
|
||||
openModal(
|
||||
{
|
||||
category: {
|
||||
id: createId(),
|
||||
name: t("section.category.create.title"),
|
||||
@@ -30,9 +31,12 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
},
|
||||
submitLabel: t("section.category.create.submit"),
|
||||
},
|
||||
});
|
||||
{
|
||||
title: (t) => t("section.category.create.title"),
|
||||
},
|
||||
);
|
||||
},
|
||||
[addCategory, t],
|
||||
[addCategory, t, openModal],
|
||||
);
|
||||
|
||||
// creates a new category above the current
|
||||
@@ -63,7 +67,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
|
||||
// Removes the current category
|
||||
const remove = useCallback(() => {
|
||||
modalEvents.openConfirmModal({
|
||||
openConfirmModal({
|
||||
title: t("section.category.remove.title"),
|
||||
children: t("section.category.remove.message", {
|
||||
name: category.name,
|
||||
@@ -73,17 +77,12 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
id: category.id,
|
||||
});
|
||||
},
|
||||
confirmProps: {
|
||||
color: "red",
|
||||
},
|
||||
});
|
||||
}, [category.id, category.name, removeCategory, t]);
|
||||
}, [category.id, category.name, removeCategory, t, openConfirmModal]);
|
||||
|
||||
const edit = () => {
|
||||
modalEvents.openManagedModal({
|
||||
modal: "categoryEditModal",
|
||||
title: t("section.category.edit.title"),
|
||||
innerProps: {
|
||||
const edit = useCallback(() => {
|
||||
openModal(
|
||||
{
|
||||
category,
|
||||
submitLabel: t("section.category.edit.submit"),
|
||||
onSuccess: (category) => {
|
||||
@@ -93,8 +92,11 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
{
|
||||
title: (t) => t("section.category.edit.title"),
|
||||
},
|
||||
);
|
||||
}, [category, openModal, renameCategory, t]);
|
||||
|
||||
return {
|
||||
addCategoryAbove,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useElementSize } from "@mantine/hooks";
|
||||
import cx from "clsx";
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -20,11 +21,11 @@ import {
|
||||
loadWidgetDynamic,
|
||||
reduceWidgetOptionsWithDefaultValues,
|
||||
useServerDataFor,
|
||||
WidgetEditModal,
|
||||
} from "@homarr/widgets";
|
||||
|
||||
import { useRequiredBoard } from "~/app/[locale]/boards/_context";
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import { modalEvents } from "~/app/[locale]/modals";
|
||||
import { editModeAtom } from "../editMode";
|
||||
import { useItemActions } from "../items/item-actions";
|
||||
import type { UseGridstackRefs } from "./gridstack/use-gridstack";
|
||||
@@ -108,43 +109,38 @@ const BoardItem = ({ item, ...dimensions }: ItemProps) => {
|
||||
|
||||
const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
|
||||
const t = useScopedI18n("item");
|
||||
const { openModal } = useModalAction(WidgetEditModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const isEditMode = useAtomValue(editModeAtom);
|
||||
const { updateItemOptions, removeItem } = useItemActions();
|
||||
|
||||
if (!isEditMode) return null;
|
||||
|
||||
const openEditModal = () => {
|
||||
modalEvents.openManagedModal({
|
||||
title: t("edit.title"),
|
||||
modal: "widgetEditModal",
|
||||
innerProps: {
|
||||
kind: item.kind,
|
||||
value: {
|
||||
options: item.options,
|
||||
integrations: item.integrations.map(({ id }) => id),
|
||||
},
|
||||
onSuccessfulEdit: ({ options, integrations: _ }) => {
|
||||
updateItemOptions({
|
||||
itemId: item.id,
|
||||
newOptions: options,
|
||||
});
|
||||
},
|
||||
integrationData: [],
|
||||
integrationSupport: false,
|
||||
openModal({
|
||||
kind: item.kind,
|
||||
value: {
|
||||
options: item.options,
|
||||
integrations: item.integrations.map(({ id }) => id),
|
||||
},
|
||||
onSuccessfulEdit: ({ options, integrations: _ }) => {
|
||||
updateItemOptions({
|
||||
itemId: item.id,
|
||||
newOptions: options,
|
||||
});
|
||||
},
|
||||
integrationData: [],
|
||||
integrationSupport: false,
|
||||
});
|
||||
};
|
||||
|
||||
const openRemoveModal = () => {
|
||||
modalEvents.openConfirmModal({
|
||||
openConfirmModal({
|
||||
title: t("remove.title"),
|
||||
children: t("remove.message"),
|
||||
onConfirm: () => {
|
||||
removeItem({ itemId: item.id });
|
||||
},
|
||||
confirmProps: {
|
||||
color: "red",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ export const navigationCollapsedAtom = atom(true);
|
||||
export const ClientBurger = () => {
|
||||
const [collapsed, setCollapsed] = useAtom(navigationCollapsedAtom);
|
||||
|
||||
const toggle = useCallback(() => setCollapsed((c) => !c), [setCollapsed]);
|
||||
const toggle = useCallback(
|
||||
() => setCollapsed((collapsed) => !collapsed),
|
||||
[setCollapsed],
|
||||
);
|
||||
|
||||
return (
|
||||
<Burger opened={!collapsed} onClick={toggle} hiddenFrom="sm" size="sm" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ManagedModal } from "mantine-modal-manager";
|
||||
import { boardSchemas } from "node_modules/@homarr/validation/src/board";
|
||||
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Button, Group, Stack, TextInput } from "@homarr/ui";
|
||||
import { z } from "@homarr/validation";
|
||||
@@ -11,48 +11,51 @@ interface InnerProps {
|
||||
onSuccess: ({ name }: { name: string }) => Promise<void>;
|
||||
}
|
||||
|
||||
export const AddBoardModal: ManagedModal<InnerProps> = ({
|
||||
actions,
|
||||
innerProps,
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: "",
|
||||
},
|
||||
validate: zodResolver(
|
||||
z.object({
|
||||
name: boardSchemas.byName.shape.name.refine(
|
||||
(value) => !innerProps.boardNames.includes(value),
|
||||
),
|
||||
}),
|
||||
),
|
||||
validateInputOnBlur: true,
|
||||
validateInputOnChange: true,
|
||||
});
|
||||
export const AddBoardModal = createModal<InnerProps>(
|
||||
({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: "",
|
||||
},
|
||||
validate: zodResolver(
|
||||
z.object({
|
||||
name: boardSchemas.byName.shape.name.refine(
|
||||
(value) => !innerProps.boardNames.includes(value),
|
||||
),
|
||||
}),
|
||||
),
|
||||
validateInputOnBlur: true,
|
||||
validateInputOnChange: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
void innerProps.onSuccess(values);
|
||||
actions.closeModal();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t("management.page.board.modal.createBoard.field.name.label")}
|
||||
data-autofocus
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<Group justify="right">
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button disabled={!form.isValid()} type="submit" color="teal">
|
||||
{t("common.action.create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
void innerProps.onSuccess(values);
|
||||
actions.closeModal();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t(
|
||||
"management.page.board.modal.createBoard.field.name.label",
|
||||
)}
|
||||
data-autofocus
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<Group justify="right">
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button disabled={!form.isValid()} type="submit" color="teal">
|
||||
{t("common.action.create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
).withOptions({
|
||||
defaultTitle: (t) => t("management.page.board.button.create"),
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ export const env = createEnv({
|
||||
VERCEL_URL: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => (v ? `https://${v}` : undefined)),
|
||||
.transform((url) => (url ? `https://${url}` : undefined)),
|
||||
PORT: z.coerce.number().default(3000),
|
||||
},
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user