feat: reduce disruptive UI confirmations (#6688)

* feat: reduce disruptive UI confirmations

* fix: keep destructive confirmations red
This commit is contained in:
Thomas authored and GitHub committed 2026-08-27 12:01:36 +02:00
1 parent 9c441571bc
commit a002b91add
86 files changed
+3201 -2277

No files matched your search

@@ -0,0 +1,130 @@
"use client";
import { useState } from "react";
import type { ReactNode } from "react";
import { Button, Center, Combobox, Group, Loader, ScrollArea, useCombobox } from "@mantine/core";
import type { ButtonProps } from "@mantine/core";
export interface AccessPickerOption<T> {
value: string;
label: string;
keywords?: readonly string[];
content: ReactNode;
item: T;
}
export type AccessPickerTriggerProps = Omit<ButtonProps, "children" | "loading" | "onClick" | "type">;
interface AccessPickerProps<T> {
label: string;
searchPlaceholder: string;
emptyMessage: string;
triggerLabel: string;
options: AccessPickerOption<T>[];
isPending: boolean;
onSelect: (option: AccessPickerOption<T>) => void | Promise<void>;
triggerProps?: AccessPickerTriggerProps;
}
export const AccessPicker = <T,>({
label,
searchPlaceholder,
emptyMessage,
triggerLabel,
options,
isPending,
onSelect,
triggerProps,
}: AccessPickerProps<T>) => {
const [search, setSearch] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const combobox = useCombobox({
onDropdownClose: () => {
combobox.resetSelectedOption();
setSearch("");
requestAnimationFrame(() => combobox.focusTarget());
},
onDropdownOpen: () => {
combobox.selectFirstOption();
requestAnimationFrame(() => combobox.focusSearchInput());
},
});
const normalizedSearch = search.trim().toLowerCase();
const filteredOptions = options.filter((option) => {
if (!normalizedSearch) return true;
const searchableText = [option.label, ...(option.keywords ?? [])].join(" ").toLowerCase();
return searchableText.includes(normalizedSearch);
});
const visibleOptions = filteredOptions.slice(0, 5);
const handleSelect = (value: string) => {
const option = options.find((item) => item.value === value);
if (!option || isSubmitting) return;
combobox.closeDropdown();
setIsSubmitting(true);
const submit = async () => {
try {
await onSelect(option);
} finally {
setIsSubmitting(false);
}
};
void submit().catch(() => undefined);
};
return (
<Combobox store={combobox} onOptionSubmit={handleSelect} width="target" withinPortal>
<Combobox.Target targetType="button">
<Button
{...triggerProps}
onClick={() => combobox.toggleDropdown()}
rightSection={
<Group gap={4} wrap="nowrap">
{triggerProps?.rightSection}
<Combobox.Chevron />
</Group>
}
loading={isSubmitting}
disabled={isSubmitting || triggerProps?.disabled}
>
{triggerLabel}
</Button>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Search
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
combobox.updateSelectedOptionIndex();
}}
placeholder={searchPlaceholder}
aria-label={label}
/>
<ScrollArea.Autosize mah={300} type="auto">
<Combobox.Options>
{isPending && (
<Center p="md">
<LoaderPlaceholder />
</Center>
)}
{!isPending && filteredOptions.length === 0 && <Combobox.Empty>{emptyMessage}</Combobox.Empty>}
{!isPending &&
visibleOptions.map((option) => (
<Combobox.Option value={option.value} key={option.value}>
{option.content}
</Combobox.Option>
))}
</Combobox.Options>
</ScrollArea.Autosize>
</Combobox.Dropdown>
</Combobox>
);
};
const LoaderPlaceholder = () => <Loader size="sm" />;
@@ -2,7 +2,6 @@ import { useState } from "react";
import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
@@ -11,7 +10,7 @@ import { AccessSelectRow } from "./access-table-rows";
import { useAccessContext } from "./context";
import type { AccessFormType } from "./form";
import { FormProvider, useForm } from "./form";
import { GroupSelectModal } from "./group-select-modal";
import { GroupSelect } from "./group-select";
import type { FormProps } from "./user-access-form";
export const GroupAccessForm = <TPermission extends string>({
@@ -24,8 +23,6 @@ export const GroupAccessForm = <TPermission extends string>({
const [groups, setGroups] = useState<Map<string, AccessQueryData<string>["groups"][number]["group"]>>(
new Map(accessQueryData.groups.map(({ group }) => [group.id, group])),
);
const { openModal } = useModalAction(GroupSelectModal);
const tCommon = useI18n("common");
const tPermissions = useI18n("permission");
const form = useForm({
initialValues: {
@@ -36,23 +33,6 @@ export const GroupAccessForm = <TPermission extends string>({
},
});
const handleAddUser = () => {
openModal({
presentGroupIds: form.values.items.map(({ principalId: id }) => id),
onSelect: (group) => {
setGroups((prev) => new Map(prev).set(group.id, group));
form.setFieldValue("items", [
{
principalId: group.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
},
});
};
return (
<form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}>
<FormProvider form={form}>
@@ -79,9 +59,21 @@ export const GroupAccessForm = <TPermission extends string>({
</Table>
<Group justify="space-between">
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
{tCommon("action.add")}
</Button>
<GroupSelect
presentGroupIds={form.values.items.map(({ principalId: id }) => id)}
onSelect={(group) => {
setGroups((prev) => new Map(prev).set(group.id, group));
form.setFieldValue("items", [
{
principalId: group.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
}}
triggerProps={{ rightSection: <IconPlus size="1rem" />, variant: "light" }}
/>
<Button type="submit" loading={isPending}>
{tPermissions("action.saveGroup")}
</Button>
@@ -1,73 +0,0 @@
import { useState } from "react";
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface InnerProps {
withPermissions?: boolean;
presentGroupIds: string[];
onSelect: (props: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>;
confirmLabel?: string;
}
interface GroupSelectFormType {
groupId: string;
}
export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const tGroup = useI18n("group.action.select");
const tCommon = useI18n("common");
const { data: groups, isPending } = clientApi.group.selectable.useQuery({
withPermissions: innerProps.withPermissions,
});
const [loading, setLoading] = useState(false);
const form = useForm<GroupSelectFormType>();
const handleSubmitAsync = async (values: GroupSelectFormType) => {
const currentGroup = groups?.find((group) => group.id === values.groupId);
if (!currentGroup) return;
setLoading(true);
await innerProps.onSelect({
id: currentGroup.id,
name: currentGroup.name,
permissions: "permissions" in currentGroup ? (currentGroup.permissions as GroupPermissionKey[]) : undefined,
});
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? tCommon("action.add");
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack>
<Select
{...form.getInputProps("groupId")}
label={tGroup("label")}
clearable
searchable
leftSection={isPending ? <Loader size="xs" /> : undefined}
nothingFoundMessage={tGroup("notFound")}
limit={5}
data={groups
?.filter((group) => !innerProps.presentGroupIds.includes(group.id))
.map((group) => ({ value: group.id, label: group.name }))}
/>
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("permission.groupSelect.title"),
});
@@ -0,0 +1,66 @@
"use client";
import { Text } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { GroupPermissionKey } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import type { AccessPickerOption, AccessPickerTriggerProps } from "./access-picker";
import { AccessPicker } from "./access-picker";
type SelectableGroup = RouterOutputs["group"]["selectable"][number];
interface GroupSelectProps {
withPermissions?: boolean;
presentGroupIds: string[];
onSelect: (group: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>;
triggerLabel?: string;
triggerProps?: AccessPickerTriggerProps;
}
export const GroupSelect = ({
withPermissions,
presentGroupIds,
onSelect,
triggerLabel,
triggerProps,
}: GroupSelectProps) => {
const tGroup = useI18n("group.action.select");
const tCommon = useI18n("common");
const { data: groups, isPending } = clientApi.group.selectable.useQuery({ withPermissions });
const availableGroups = (groups ?? []).filter((group) => !presentGroupIds.includes(group.id));
const options: AccessPickerOption<SelectableGroup>[] = availableGroups.map((group) => ({
value: group.id,
label: group.name,
keywords: getGroupPermissions(group) ?? [],
item: group,
content: <Text size="sm">{group.name}</Text>,
}));
const handleSelect = ({ item: group }: AccessPickerOption<SelectableGroup>) =>
onSelect({
id: group.id,
name: group.name,
permissions: getGroupPermissions(group),
});
return (
<AccessPicker
label={tGroup("label")}
searchPlaceholder={tGroup("label")}
emptyMessage={tGroup("notFound")}
triggerLabel={triggerLabel ?? tCommon("action.add")}
triggerProps={triggerProps}
options={options}
isPending={isPending}
onSelect={handleSelect}
/>
);
};
const getGroupPermissions = (group: SelectableGroup) => {
if ("permissions" in group) return group.permissions as GroupPermissionKey[];
return undefined;
};
@@ -2,7 +2,6 @@ import { useState } from "react";
import { Anchor, Box, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { Link, UserAvatar } from "@homarr/ui";
@@ -11,7 +10,7 @@ import { AccessDisplayRow, AccessSelectRow } from "./access-table-rows";
import { useAccessContext } from "./context";
import type { AccessFormType, HandleCountChange } from "./form";
import { FormProvider, useForm } from "./form";
import { UserSelectModal } from "./user-select-modal";
import { UserSelect } from "./user-select";
export interface FormProps<TPermission extends string> {
entity: {
@@ -41,8 +40,6 @@ export const UsersAccessForm = <TPermission extends string>({
const [users, setUsers] = useState<Map<string, UserItemContentProps["user"]>>(
new Map(accessQueryData.users.map(({ user }) => [user.id, user])),
);
const { openModal } = useModalAction(UserSelectModal);
const tCommon = useI18n("common");
const tPermissions = useI18n("permission");
const form = useForm({
initialValues: {
@@ -52,25 +49,8 @@ export const UsersAccessForm = <TPermission extends string>({
})),
},
});
const handleAddUser = () => {
const presentUserIds = form.values.items.map(({ principalId: id }) => id);
openModal({
presentUserIds: entity.ownerId ? presentUserIds.concat(entity.ownerId) : presentUserIds,
onSelect: (user) => {
setUsers((prev) => new Map(prev).set(user.id, user));
form.setFieldValue("items", [
{
principalId: user.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
},
});
};
const presentUserIds = form.values.items.map(({ principalId: id }) => id);
if (entity.ownerId) presentUserIds.push(entity.ownerId);
return (
<form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}>
@@ -101,9 +81,21 @@ export const UsersAccessForm = <TPermission extends string>({
</Table>
<Group justify="space-between">
<Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}>
{tCommon("action.add")}
</Button>
<UserSelect
presentUserIds={presentUserIds}
onSelect={(user) => {
setUsers((prev) => new Map(prev).set(user.id, user));
form.setFieldValue("items", [
{
principalId: user.id,
permission: defaultPermission,
},
...form.values.items,
]);
handleCountChange((prev) => prev + 1);
}}
triggerProps={{ rightSection: <IconPlus size="1rem" />, variant: "light" }}
/>
<Button type="submit" loading={isPending}>
{tPermissions("action.saveUser")}
</Button>
@@ -1,103 +0,0 @@
import { useState } from "react";
import type { SelectProps } from "@mantine/core";
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
interface InnerProps {
presentUserIds: string[];
allowedProviders?: SupportedAuthProvider[];
onSelect: (props: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>;
confirmLabel?: string;
}
interface UserSelectFormType {
userId: string;
}
export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const tUser = useI18n("user.action.select");
const tCommon = useI18n("common");
const { data: users, isPending } = clientApi.user.selectable.useQuery({
providers: innerProps.allowedProviders,
});
const [loading, setLoading] = useState(false);
const form = useForm<UserSelectFormType>();
const handleSubmitAsync = async (values: UserSelectFormType) => {
const currentUser = users?.find((user) => user.id === values.userId);
if (!currentUser) return;
setLoading(true);
await innerProps.onSelect({
id: currentUser.id,
name: currentUser.name ?? "",
image: currentUser.image ?? "",
email: currentUser.email ?? null,
});
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? tCommon("action.add");
const currentUser = users?.find((user) => user.id === form.values.userId);
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack>
<Select
{...form.getInputProps("userId")}
label={tUser("label")}
searchable
clearable
leftSection={
isPending ? <Loader size="xs" /> : currentUser ? <UserAvatar user={currentUser} size="xs" /> : undefined
}
nothingFoundMessage={tUser("notFound")}
renderOption={createRenderOption(users ?? [])}
limit={5}
data={users
?.filter((user) => !innerProps.presentUserIds.includes(user.id))
.map((user) => ({ value: user.id, label: user.name ?? "" }))}
/>
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("permission.userSelect.title"),
});
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: "1rem",
};
const createRenderOption = (users: RouterOutputs["user"]["selectable"]): SelectProps["renderOption"] =>
function InnerRenderRoot({ option, checked }) {
const user = users.find((user) => user.id === option.value);
if (!user) return null;
return (
<Group flex="1" gap="xs">
<UserAvatar user={user} size="xs" />
{option.label}
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
</Group>
);
};
@@ -0,0 +1,75 @@
"use client";
import { Group, Stack, Text } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import type { AccessPickerOption, AccessPickerTriggerProps } from "./access-picker";
import { AccessPicker } from "./access-picker";
type SelectableUser = RouterOutputs["user"]["selectable"][number];
interface UserSelectProps {
presentUserIds: string[];
allowedProviders?: SupportedAuthProvider[];
onSelect: (user: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>;
triggerLabel?: string;
triggerProps?: AccessPickerTriggerProps;
}
export const UserSelect = ({
presentUserIds,
allowedProviders,
onSelect,
triggerLabel,
triggerProps,
}: UserSelectProps) => {
const tUser = useI18n("user.action.select");
const tCommon = useI18n("common");
const { data: users, isPending } = clientApi.user.selectable.useQuery({ providers: allowedProviders });
const availableUsers = (users ?? []).filter((user) => !presentUserIds.includes(user.id));
const options: AccessPickerOption<SelectableUser>[] = availableUsers.map((user) => ({
value: user.id,
label: user.name ?? "",
keywords: [user.email ?? ""],
item: user,
content: (
<Group wrap="nowrap" gap="xs">
<UserAvatar user={user} size="xs" />
<Stack gap={0}>
<Text size="sm">{user.name}</Text>
{user.email && (
<Text size="xs" c="dimmed">
{user.email}
</Text>
)}
</Stack>
</Group>
),
}));
const handleSelect = ({ item: user }: AccessPickerOption<SelectableUser>) =>
onSelect({
id: user.id,
name: user.name ?? "",
image: user.image ?? "",
email: user.email ?? null,
});
return (
<AccessPicker
label={tUser("label")}
searchPlaceholder={tUser("label")}
emptyMessage={tUser("notFound")}
triggerLabel={triggerLabel ?? tCommon("action.add")}
triggerProps={triggerProps}
options={options}
isPending={isPending}
onSelect={handleSelect}
/>
);
};
@@ -130,8 +130,8 @@ import { useTimeAgo } from "@homarr/common";
import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions";
import type { AssistantProvider } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useConfirmModal } from "@homarr/modals";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
import classes from "./assistant-panel.module.css";
import { getAssistantActivityState } from "./assistant-activity-state";
@@ -2001,7 +2001,6 @@ const ThreadListItem = () => {
const t = useI18n("assistant");
const actionT = useI18n("common.action");
const aui = useAui();
const { openConfirmModal } = useConfirmModal();
const onSelect = useContext(HistorySelectContext);
const remoteId = useAuiState((state) => state.threadListItem.remoteId);
const title = useAuiState((state) => state.threadListItem.title);
@@ -2054,23 +2053,15 @@ const ThreadListItem = () => {
}
};
const deleteConversation = () => {
openConfirmModal({
title: t("deleteConversation.title"),
children: t("deleteConversation.description", { title: title ?? t("newConversation") }),
confirmProps: { color: "red" },
labels: { confirm: actionT("delete"), cancel: actionT("cancel") },
onConfirm: async () => {
try {
await aui.threadListItem().delete();
} catch {
showErrorNotification({
title: t("deleteConversation.failedTitle"),
message: t("deleteConversation.failedDescription"),
});
}
},
});
const deleteConversation = async () => {
try {
await aui.threadListItem().delete();
} catch {
showErrorNotification({
title: t("deleteConversation.failedTitle"),
message: t("deleteConversation.failedDescription"),
});
}
};
if (renameOpened) {
@@ -2152,16 +2143,19 @@ const ThreadListItem = () => {
>
<IconFileExport size={14} />
</ActionIcon>
<ActionIcon
<InlineConfirmActionIcon
variant="subtle"
color="red"
size="sm"
title={actionT("delete")}
aria-label={actionT("delete")}
onClick={deleteConversation}
confirmLabel={t("deleteConversation.description", { title: title ?? t("newConversation") })}
confirmationAriaLabel={t("deleteConversation.title")}
confirmationChildren={<IconCheck size={14} />}
onConfirm={deleteConversation}
>
<IconTrash size={14} />
</ActionIcon>
</InlineConfirmActionIcon>
</Group>
)}
<ActionIcon
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useEditMode } from "@homarr/boards/edit-mode";
@@ -8,14 +8,14 @@ import { getWidgetName } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmMenuItem } from "@homarr/ui";
import type { WidgetDefinition } from "@homarr/widgets/definition";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-item";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { useOpenItemMoveModal } from "./item-move-modal";
import itemContentClasses from "./item-content.module.css";
import { useOpenItemMoveModal } from "./item-move-modal";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
interface BoardItemMenuProps {
@@ -142,11 +142,14 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
</Menu.Item>
)}
<Menu.Divider />
<BoardRemoveConfirmationMenuItem
label={tItem("action.remove")}
confirmationLabel={tItem("remove.message")}
<InlineConfirmMenuItem
color="red"
confirmLabel={tItem("remove.message")}
leftSection={<IconTrash size={16} />}
onConfirm={() => removeItem({ itemId: item.id })}
/>
>
{tItem("action.remove")}
</InlineConfirmMenuItem>
</Menu.Dropdown>
</Menu>
);
@@ -1,70 +0,0 @@
"use client";
import { Button, Group, Stack, TextInput } from "@mantine/core";
import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { boardRenameSchema } from "@homarr/validation/board";
interface InnerProps {
id: string;
previousName: string;
onSuccess?: (name: string) => void;
}
export const BoardRenameModal = createModal<InnerProps>(({ actions, innerProps }) => {
const utils = clientApi.useUtils();
const tCommon = useI18n("common");
const { mutate, isPending } = clientApi.board.renameBoard.useMutation({
onSettled() {
void utils.board.getBoardByName.invalidate({
name: innerProps.previousName,
});
void utils.board.getHomeBoard.invalidate();
void utils.board.getAllBoards.invalidate();
},
});
const form = useZodForm(boardRenameSchema.omit({ id: true }), {
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={tCommon("field.name")} {...form.getInputProps("name")} data-autofocus />
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{tCommon("action.confirm")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("board.setting.section.dangerZone.action.rename.label"),
});
type FormType = Omit<z.infer<typeof boardRenameSchema>, "id">;
@@ -1,12 +1,12 @@
import { ActionIcon, Menu } from "@mantine/core";
import { IconArrowsMove, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
import { IconArrowsMove, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmMenuItem } from "@homarr/ui";
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
import { BoardRemoveConfirmationMenuItem } from "../../remove-confirmation-menu-item";
import { useOpenItemMoveModal } from "../../items/item-move-modal";
import { useSectionContext } from "../section-context";
import { useContainerActions } from "./container-actions";
@@ -59,11 +59,14 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem
{tItem("action.moveResize")}
</Menu.Item>
<Menu.Divider />
<BoardRemoveConfirmationMenuItem
label={tContainer("action.remove")}
confirmationLabel={tContainer("remove.message")}
<InlineConfirmMenuItem
color="red"
confirmLabel={tContainer("remove.message")}
leftSection={<IconTrash size={16} />}
onConfirm={() => removeContainer({ id: section.id })}
/>
>
{tContainer("action.remove")}
</InlineConfirmMenuItem>
</Menu.Dropdown>
</Menu>
);
@@ -0,0 +1,99 @@
"use client";
import { useState } from "react";
import { Alert, Box, Button, CloseButton, Collapse, Group, Paper, Select, Stack, Text, Textarea } from "@mantine/core";
import { showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { WorkshopBackend } from "@homarr/workshop/backend";
import { useWorkshopReportMutation } from "@homarr/workshop/backend";
import type { WorkshopReport } from "@homarr/workshop/schema";
const categories = ["outdated", "malicious", "spam", "copyright", "inappropriate", "other"] as const;
interface WorkshopReportFormProps {
client: WorkshopBackend;
submissionId: string;
opened: boolean;
onClose(): void;
}
export function WorkshopReportForm({ client, submissionId, opened, onClose }: WorkshopReportFormProps) {
const t = useI18n("workshop");
const tCommon = useI18n("common");
const [category, setCategory] = useState<WorkshopReport["category"]>("other");
const [explanation, setExplanation] = useState("");
const report = useWorkshopReportMutation(client);
const close = () => {
setCategory("other");
setExplanation("");
report.reset();
onClose();
};
const submit = () =>
report.mutate(
{ submission: submissionId, category, explanation },
{
onSuccess: () => {
showSuccessNotification({
title: t("reportSent"),
message: t("reportSentDescription"),
});
close();
},
},
);
return (
<Collapse id="workshop-report-form" expanded={opened}>
<Paper p="md">
<form
onSubmit={(event) => {
event.preventDefault();
submit();
}}
>
<Stack>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box>
<Text fw={600}>{t("reportTitle")}</Text>
<Text size="sm" c="dimmed">
{t("reportExplanation")}
</Text>
</Box>
<CloseButton aria-label={tCommon("action.close")} onClick={close} />
</Group>
<Select
label={t("reportReason")}
value={category}
onChange={(value) => setCategory((value as WorkshopReport["category"] | null) ?? "other")}
data={categories.map((value) => ({
value,
label: t(`reportCategory.${value}`),
}))}
allowDeselect={false}
/>
<Textarea
label={t("reportExplanation")}
minRows={4}
autosize
value={explanation}
onChange={(event) => setExplanation(event.currentTarget.value)}
/>
{report.error && <Alert color="red">{report.error.message}</Alert>}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={close}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" color="red" loading={report.isPending} disabled={explanation.trim().length < 3}>
{t("reportSend")}
</Button>
</Group>
</Stack>
</form>
</Paper>
</Collapse>
);
}
@@ -1,80 +0,0 @@
"use client";
import { useState } from "react";
import { Alert, Button, Group, Modal, Select, Stack, Textarea } from "@mantine/core";
import { showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { WorkshopBackend } from "@homarr/workshop/backend";
import { useWorkshopReportMutation } from "@homarr/workshop/backend";
import type { WorkshopReport } from "@homarr/workshop/schema";
const categories = ["outdated", "malicious", "spam", "copyright", "inappropriate", "other"] as const;
interface WorkshopReportModalProps {
client: WorkshopBackend;
submissionId: string;
opened: boolean;
onClose(): void;
}
export function WorkshopReportModal({ client, submissionId, opened, onClose }: WorkshopReportModalProps) {
const t = useI18n("workshop");
const tCommon = useI18n("common");
const [category, setCategory] = useState<WorkshopReport["category"]>("other");
const [explanation, setExplanation] = useState("");
const report = useWorkshopReportMutation(client);
const close = () => {
setExplanation("");
report.reset();
onClose();
};
const submit = () =>
report.mutate(
{ submission: submissionId, category, explanation },
{
onSuccess: () => {
showSuccessNotification({
title: t("reportSent"),
message: t("reportSentDescription"),
});
close();
},
},
);
return (
<Modal opened={opened} onClose={close} title={t("reportTitle")} centered radius="md">
<Stack>
<Select
label={t("reportReason")}
value={category}
onChange={(value) => setCategory((value as WorkshopReport["category"] | null) ?? "other")}
data={categories.map((value) => ({
value,
label: t(`reportCategory.${value}`),
}))}
allowDeselect={false}
/>
<Textarea
label={t("reportExplanation")}
minRows={4}
autosize
value={explanation}
onChange={(event) => setExplanation(event.currentTarget.value)}
/>
{report.error && <Alert color="red">{report.error.message}</Alert>}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={close}>
{tCommon("action.cancel")}
</Button>
<Button color="red" loading={report.isPending} disabled={explanation.trim().length < 3} onClick={submit}>
{t("reportSend")}
</Button>
</Group>
</Stack>
</Modal>
);
}