feat(groups): add home board settings (#2321)
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Group, Switch, Text } from "@mantine/core";
|
||||
import { IconLayoutDashboard } from "@tabler/icons-react";
|
||||
import { Switch, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { SelectWithCustomItems } from "@homarr/ui";
|
||||
|
||||
import { BoardSelect } from "~/components/board/board-select";
|
||||
import { CommonSettingsForm } from "./common-form";
|
||||
|
||||
export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => {
|
||||
@@ -18,42 +17,19 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
|
||||
<CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<SelectWithCustomItems
|
||||
<BoardSelect
|
||||
label={tBoard("homeBoard.label")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
data={selectableBoards.map((board) => ({
|
||||
value: board.id,
|
||||
label: board.name,
|
||||
image: board.logoImageUrl,
|
||||
}))}
|
||||
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
|
||||
<Group>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
|
||||
<Text fz="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<SelectWithCustomItems
|
||||
|
||||
<BoardSelect
|
||||
label={tBoard("homeBoard.mobileLabel")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
data={selectableBoards.map((board) => ({
|
||||
value: board.id,
|
||||
label: board.name,
|
||||
image: board.logoImageUrl,
|
||||
}))}
|
||||
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
|
||||
<Group>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
|
||||
<Text fz="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Select, Stack } from "@mantine/core";
|
||||
import { Button, Group, Stack } from "@mantine/core";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
@@ -11,9 +11,12 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import { BoardSelect } from "~/components/board/board-select";
|
||||
|
||||
interface ChangeHomeBoardFormProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
boardsData: { value: string; label: string }[];
|
||||
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
||||
}
|
||||
|
||||
export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormProps) => {
|
||||
@@ -54,16 +57,18 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
<BoardSelect
|
||||
label={t("management.page.user.setting.general.item.board.type.general")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
data={boardsData}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
<Select
|
||||
<BoardSelect
|
||||
label={t("management.page.user.setting.general.item.board.type.mobile")}
|
||||
clearable
|
||||
boards={boardsData}
|
||||
w="100%"
|
||||
data={boardsData}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
|
||||
|
||||
@@ -95,8 +95,9 @@ export default async function EditUserPage(props: Props) {
|
||||
<ChangeHomeBoardForm
|
||||
user={user}
|
||||
boardsData={boards.map((board) => ({
|
||||
value: board.id,
|
||||
label: board.name,
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
logoImageUrl: board.logoImageUrl,
|
||||
}))}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react";
|
||||
import { IconId, IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
@@ -42,6 +42,11 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
|
||||
<NavigationLink
|
||||
href={`/manage/users/groups/${params.id}`}
|
||||
label={tGroup("setting.general.title")}
|
||||
icon={<IconId size="1rem" stroke={1.5} />}
|
||||
/>
|
||||
<NavigationLink
|
||||
href={`/manage/users/groups/${params.id}/settings`}
|
||||
label={tGroup("setting.setting.title")}
|
||||
icon={<IconSettings size="1rem" stroke={1.5} />}
|
||||
/>
|
||||
<NavigationLink
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Stack } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { BoardSelect } from "~/components/board/board-select";
|
||||
|
||||
interface GroupHomeBoardsProps {
|
||||
homeBoardId: string | null;
|
||||
mobileHomeBoardId: string | null;
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
export const GroupHomeBoards = ({ homeBoardId, mobileHomeBoardId, groupId }: GroupHomeBoardsProps) => {
|
||||
const t = useI18n();
|
||||
const [availableBoards] = clientApi.board.getBoardsForGroup.useSuspenseQuery({ groupId });
|
||||
const form = useZodForm(validation.group.settings.pick({ homeBoardId: true, mobileHomeBoardId: true }), {
|
||||
initialValues: {
|
||||
homeBoardId,
|
||||
mobileHomeBoardId,
|
||||
},
|
||||
});
|
||||
const { mutateAsync, isPending } = clientApi.group.savePartialSettings.useMutation();
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
await mutateAsync(
|
||||
{
|
||||
id: groupId,
|
||||
settings: values,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
form.setInitialValues(values);
|
||||
showSuccessNotification({
|
||||
title: t("group.action.settings.board.notification.success.title"),
|
||||
message: t("group.action.settings.board.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("group.action.settings.board.notification.error.title"),
|
||||
message: t("group.action.settings.board.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<BoardSelect
|
||||
label={t("group.field.homeBoard.label")}
|
||||
description={t("group.field.homeBoard.description")}
|
||||
clearable
|
||||
boards={availableBoards}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
|
||||
<BoardSelect
|
||||
label={t("group.field.mobileBoard.label")}
|
||||
description={t("group.field.mobileBoard.description")}
|
||||
clearable
|
||||
boards={availableBoards}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" color="teal" loading={isPending}>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Alert, Stack, Title } from "@mantine/core";
|
||||
import { IconExclamationCircle } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { GroupHomeBoards } from "./_group-home-boards";
|
||||
|
||||
interface GroupSettingsPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function GroupPermissionsPage(props: GroupSettingsPageProps) {
|
||||
const params = await props.params;
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const group = await api.group.getById({ id: params.id });
|
||||
const t = await getI18n();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Title>{t("management.page.group.setting.setting.title")}</Title>
|
||||
|
||||
<Alert color="cyan" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
|
||||
{t("management.page.group.setting.setting.alert")}
|
||||
</Alert>
|
||||
|
||||
<Title order={3}>{t("management.page.group.setting.setting.board.title")}</Title>
|
||||
|
||||
<GroupHomeBoards homeBoardId={group.homeBoardId} mobileHomeBoardId={group.mobileHomeBoardId} groupId={group.id} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddGroupModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
|
||||
export const AddGroup = () => {
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(AddGroupModal);
|
||||
|
||||
const handleAddGroup = useCallback(() => {
|
||||
openModal();
|
||||
}, [openModal]);
|
||||
|
||||
return (
|
||||
<MobileAffixButton onClick={handleAddGroup} color="teal">
|
||||
{t("group.action.create.label")}
|
||||
</MobileAffixButton>
|
||||
);
|
||||
};
|
||||
65
apps/nextjs/src/app/[locale]/manage/users/groups/_client.tsx
Normal file
65
apps/nextjs/src/app/[locale]/manage/users/groups/_client.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Group, TextInput } from "@mantine/core";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddGroupModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { GroupsTable } from "./_groups-table";
|
||||
|
||||
interface GroupsListProps {
|
||||
groups: RouterOutputs["group"]["getAll"];
|
||||
}
|
||||
|
||||
export const GroupsList = ({ groups }: GroupsListProps) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const initialGroupIds = useMemo(
|
||||
() => groups.sort((groupA, groupB) => groupA.position - groupB.position).map((group) => group.id),
|
||||
[groups],
|
||||
);
|
||||
const filteredGroups = useMemo(
|
||||
() =>
|
||||
groups
|
||||
.filter((group) => group.name.toLowerCase().includes(search.toLowerCase()))
|
||||
.sort((groupA, groupB) => groupA.position - groupB.position),
|
||||
[groups, search],
|
||||
);
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between">
|
||||
<TextInput
|
||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
placeholder={`${t("group.search")}...`}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<AddGroup />
|
||||
</Group>
|
||||
|
||||
<GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AddGroup = () => {
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(AddGroupModal);
|
||||
|
||||
const handleAddGroup = useCallback(() => {
|
||||
openModal();
|
||||
}, [openModal]);
|
||||
|
||||
return (
|
||||
<MobileAffixButton onClick={handleAddGroup} color="teal">
|
||||
{t("group.action.create.label")}
|
||||
</MobileAffixButton>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { DragEndEvent, DraggableAttributes, DragStartEvent } from "@dnd-kit/core";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities";
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Group,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Transition,
|
||||
} from "@mantine/core";
|
||||
import { IconGripVertical } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { UserAvatarGroup } from "@homarr/ui";
|
||||
|
||||
interface GroupsTableProps {
|
||||
initialGroupIds: string[];
|
||||
groups: RouterOutputs["group"]["getAll"];
|
||||
hasFilter: boolean;
|
||||
}
|
||||
|
||||
export const GroupsTable = ({ groups, initialGroupIds, hasFilter }: GroupsTableProps) => {
|
||||
const t = useI18n();
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [groupIds, setGroupIds] = useState(groups.map((group) => group.id));
|
||||
const isDirty = useMemo(
|
||||
() => initialGroupIds.some((groupId, index) => groupIds.indexOf(groupId) !== index),
|
||||
[groupIds, initialGroupIds],
|
||||
);
|
||||
const { mutateAsync, isPending } = clientApi.group.savePositions.useMutation();
|
||||
const handleSavePositionsAsync = async () => {
|
||||
await mutateAsync(
|
||||
{ positions: groupIds },
|
||||
{
|
||||
async onSuccess() {
|
||||
showSuccessNotification({
|
||||
message: t("group.action.changePosition.notification.success.message"),
|
||||
});
|
||||
await revalidatePathActionAsync("/manage/users/groups");
|
||||
},
|
||||
onError() {
|
||||
showSuccessNotification({
|
||||
message: t("group.action.changePosition.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const sensors = useSensors(useSensor(MouseSensor, {}), useSensor(TouchSensor, {}), useSensor(KeyboardSensor, {}));
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) {
|
||||
setActiveId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setGroupIds((groupIds) => {
|
||||
const oldIndex = groupIds.indexOf(active.id as string);
|
||||
const newIndex = groupIds.indexOf(over.id as string);
|
||||
return arrayMove(groupIds, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
|
||||
function handleDragCancel() {
|
||||
setActiveId(null);
|
||||
}
|
||||
|
||||
const selectedRow = useMemo(() => {
|
||||
if (!activeId) return null;
|
||||
|
||||
const current = groups.find((group) => group.id === activeId);
|
||||
if (!current) return null;
|
||||
|
||||
return <Row group={current} handle={<DragHandle attributes={undefined} listeners={undefined} active />} />;
|
||||
}, [activeId, groups]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={handleDragStart}
|
||||
onDragCancel={handleDragCancel}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
id="groups-table"
|
||||
>
|
||||
<Table striped highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{t("group.field.name")}</TableTh>
|
||||
<TableTh>{t("group.field.members")}</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
<SortableContext items={groupIds} strategy={verticalListSortingStrategy}>
|
||||
{groupIds.map((groupId) => {
|
||||
const group = groups.find(({ id }) => id === groupId);
|
||||
if (!group) return null;
|
||||
|
||||
return <DraggableRow key={group.id} group={group} disabled={hasFilter} />;
|
||||
})}
|
||||
</SortableContext>
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<DragOverlay>
|
||||
{activeId && (
|
||||
<Table w="100%">
|
||||
<TableTbody>{selectedRow}</TableTbody>
|
||||
</Table>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<SaveAffix
|
||||
visible={isDirty}
|
||||
onDiscard={() => setGroupIds(initialGroupIds)}
|
||||
isPending={isPending}
|
||||
onSave={handleSavePositionsAsync}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface DraggableRowProps {
|
||||
group: RouterOutputs["group"]["getAll"][number];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const DraggableRow = ({ group, disabled }: DraggableRowProps) => {
|
||||
const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({
|
||||
id: group.id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
if (isDragging) {
|
||||
return (
|
||||
<TableTr ref={setNodeRef} style={style}>
|
||||
<TableTd colSpan={2}> </TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Row
|
||||
group={group}
|
||||
setNodeRef={setNodeRef}
|
||||
style={style}
|
||||
handle={<DragHandle attributes={attributes} listeners={listeners} active={false} disabled={disabled} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface RowProps {
|
||||
group: RouterOutputs["group"]["getAll"][number];
|
||||
handle?: ReactNode;
|
||||
setNodeRef?: (node: HTMLElement | null) => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const Row = ({ group, handle, setNodeRef, style }: RowProps) => {
|
||||
return (
|
||||
<TableTr ref={setNodeRef} style={style}>
|
||||
<TableTd>
|
||||
<Group>
|
||||
{handle}
|
||||
<Anchor component={Link} href={`/manage/users/groups/${group.id}`}>
|
||||
{group.name}
|
||||
</Anchor>
|
||||
</Group>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<UserAvatarGroup users={group.members} size="sm" limit={5} />
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
};
|
||||
|
||||
interface DragHandleProps {
|
||||
attributes: DraggableAttributes | undefined;
|
||||
listeners: SyntheticListenerMap | undefined;
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const DragHandle = ({ attributes, listeners, active, disabled }: DragHandleProps) => {
|
||||
if (disabled) {
|
||||
return <Box w={40} h="100%" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
h="100%"
|
||||
w={40}
|
||||
style={{ cursor: active ? "grabbing" : "grab" }}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<IconGripVertical size={18} stroke={1.5} />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
interface SaveAffixProps {
|
||||
visible: boolean;
|
||||
isPending: boolean;
|
||||
onDiscard: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const SaveAffix = ({ visible, isPending, onDiscard, onSave }: SaveAffixProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<div style={{ position: "sticky", bottom: 20 }}>
|
||||
<Transition transition="slide-up" mounted={visible}>
|
||||
{(transitionStyles) => (
|
||||
<Card style={transitionStyles} withBorder>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>{t("common.unsavedChanges")}</Text>
|
||||
<Group>
|
||||
<Button disabled={isPending} onClick={onDiscard}>
|
||||
{t("common.action.discard")}
|
||||
</Button>
|
||||
<Button color="teal" loading={isPending} onClick={onSave}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)}
|
||||
</Transition>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
.everyoneGroup {
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.everyoneGroup:hover {
|
||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
@@ -1,30 +1,19 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core";
|
||||
import { z } from "zod";
|
||||
import { Card, Group, Stack, Text, ThemeIcon, Title, UnstyledButton } from "@mantine/core";
|
||||
import { IconChevronRight, IconUsersGroup } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AddGroup } from "./_add-group";
|
||||
import { GroupsList } from "./_client";
|
||||
import classes from "./groups.module.css";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
|
||||
page: z.string().regex(/\d+/).transform(Number).catch(1),
|
||||
});
|
||||
|
||||
interface GroupsListPageProps {
|
||||
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||
}
|
||||
|
||||
export default async function GroupsListPage(props: GroupsListPageProps) {
|
||||
export default async function GroupsListPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
@@ -32,55 +21,38 @@ export default async function GroupsListPage(props: GroupsListPageProps) {
|
||||
}
|
||||
|
||||
const t = await getI18n();
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const { items: groups, totalCount } = await api.group.getPaginated(searchParams);
|
||||
const groups = await api.group.getAll();
|
||||
const dbEveryoneGroup = groups.find((group) => group.name === everyoneGroup);
|
||||
const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup);
|
||||
|
||||
return (
|
||||
<ManageContainer size="xl">
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title>{t("group.title")}</Title>
|
||||
<Group justify="space-between">
|
||||
<SearchInput placeholder={`${t("group.search")}...`} defaultValue={searchParams.search} />
|
||||
<AddGroup />
|
||||
</Group>
|
||||
<Table striped highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{t("group.field.name")}</TableTh>
|
||||
<TableTh>{t("group.field.members")}</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{groups.map((group) => (
|
||||
<Row key={group.id} group={group} />
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<Group justify="end">
|
||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||
</Group>
|
||||
{dbEveryoneGroup && (
|
||||
<UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}>
|
||||
<Card className={classes.everyoneGroup}>
|
||||
<Group align="center">
|
||||
<ThemeIcon radius="xl" variant="light">
|
||||
<IconUsersGroup size={16} />
|
||||
</ThemeIcon>
|
||||
|
||||
<Stack gap={0} flex={1}>
|
||||
<Text fw={500}>{t("group.defaultGroup.name")}</Text>
|
||||
<Text size="sm" c="gray.6">
|
||||
{t("group.defaultGroup.description", { name: everyoneGroup })}
|
||||
</Text>
|
||||
</Stack>
|
||||
<IconChevronRight size={20} />
|
||||
</Group>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
)}
|
||||
|
||||
<GroupsList groups={groupsWithoutEveryone} />
|
||||
</Stack>
|
||||
</ManageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
group: RouterOutputs["group"]["getPaginated"]["items"][number];
|
||||
}
|
||||
|
||||
const Row = ({ group }: RowProps) => {
|
||||
return (
|
||||
<TableTr>
|
||||
<TableTd>
|
||||
<Anchor component={Link} href={`/manage/users/groups/${group.id}`}>
|
||||
{group.name}
|
||||
</Anchor>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<UserAvatarGroup users={group.members} size="sm" limit={5} />
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
};
|
||||
|
||||
33
apps/nextjs/src/components/board/board-select.tsx
Normal file
33
apps/nextjs/src/components/board/board-select.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { IconLayoutDashboard } from "@tabler/icons-react";
|
||||
|
||||
import type { SelectWithCustomItemsProps } from "@homarr/ui";
|
||||
import { SelectWithCustomItems } from "@homarr/ui";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
|
||||
interface BoardSelectProps extends Omit<SelectWithCustomItemsProps<{ value: string; label: string }>, "data"> {
|
||||
boards: Pick<Board, "id" | "name" | "logoImageUrl">[];
|
||||
}
|
||||
|
||||
export const BoardSelect = ({ boards, ...props }: BoardSelectProps) => {
|
||||
return (
|
||||
<SelectWithCustomItems
|
||||
{...props}
|
||||
data={boards.map((board) => ({
|
||||
value: board.id,
|
||||
label: board.name,
|
||||
image: board.logoImageUrl,
|
||||
}))}
|
||||
SelectOption={({ label, image }: { value: string; label: string; image: string | null }) => (
|
||||
<Group>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{image ? <img width={16} height={16} src={image} alt={label} /> : <IconLayoutDashboard size={16} />}
|
||||
<Text fz="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user