Merge branch 'dev' into feat/inline-app-edit-modal
This commit is contained in:
115 files changed
+797
-657
No files matched your search
@@ -2,13 +2,14 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
|
||||
import { createTheme, DirectionProvider, MantineProvider } from "@mantine/core";
|
||||
import { DirectionProvider, MantineProvider } from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { parseCookies, setClientCookie } from "@homarr/common";
|
||||
import { colorSchemeCookieKey } from "@homarr/definitions";
|
||||
import { theme } from "@homarr/ui";
|
||||
|
||||
export const CustomMantineProvider = ({
|
||||
children,
|
||||
|
||||
@@ -59,7 +59,7 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
|
||||
{t("subtitle")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Card withBorder w={64 * 6} maw="90vw">
|
||||
<Card w={64 * 6} maw="90vw">
|
||||
<RegistrationForm invite={invite} />
|
||||
</Card>
|
||||
<Text size="xs" c="gray.5" ta="center">
|
||||
|
||||
@@ -37,7 +37,7 @@ export default async function Login(props: LoginProps) {
|
||||
{t("subtitle")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Card withBorder w={64 * 6} maw="90vw">
|
||||
<Card w={64 * 6} maw="90vw">
|
||||
<LoginForm
|
||||
providers={env.AUTH_PROVIDERS}
|
||||
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
|
||||
|
||||
@@ -109,7 +109,7 @@ const AddMenu = () => {
|
||||
}, [openAppSelectModal, createItem]);
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end" withArrow>
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
@@ -183,7 +183,7 @@ const SelectBoardsMenu = () => {
|
||||
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end" withArrow>
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<IconReplace stroke={1.5} />
|
||||
|
||||
@@ -15,7 +15,7 @@ export const InitFinish = async () => {
|
||||
const tFinish = await getScopedI18n("init.step.finish");
|
||||
|
||||
return (
|
||||
<Card w={64 * 6} maw="90vw" withBorder>
|
||||
<Card w={64 * 6} maw="90vw">
|
||||
<Stack>
|
||||
<Text>{tFinish("description")}</Text>
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export const InitGroup = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card w={64 * 6} maw="90vw" withBorder>
|
||||
<Card w={64 * 6} maw="90vw">
|
||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
|
||||
@@ -13,7 +13,7 @@ interface FileInfoCardProps {
|
||||
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
|
||||
const tFileInfo = useScopedI18n("init.step.import.fileInfo");
|
||||
return (
|
||||
<Card w={64 * 12 + 8} maw="90vw">
|
||||
<Card w={64 * 12 + 8} maw="90vw" withBorder={false}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group>
|
||||
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
|
||||
|
||||
@@ -20,7 +20,7 @@ export const InitImport = () => {
|
||||
|
||||
if (!file) {
|
||||
return (
|
||||
<Card w={64 * 12 + 8} maw="90vw" withBorder>
|
||||
<Card w={64 * 12 + 8} maw="90vw">
|
||||
<ImportDropZone
|
||||
loading={isPending}
|
||||
updateFile={(file) => {
|
||||
|
||||
@@ -30,7 +30,7 @@ export const InitSettings = () => {
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||
<Stack>
|
||||
<Card w={64 * 12 + 8} maw="90vw" withBorder>
|
||||
<Card w={64 * 12 + 8} maw="90vw">
|
||||
<Stack gap="sm">
|
||||
<Text fw={500}>{tSection("analytics.title")}</Text>
|
||||
|
||||
@@ -39,7 +39,7 @@ export const InitSettings = () => {
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Card w={64 * 12 + 8} maw="90vw" withBorder>
|
||||
<Card w={64 * 12 + 8} maw="90vw">
|
||||
<Stack gap="sm">
|
||||
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export const InitStart = async () => {
|
||||
const tStart = await getScopedI18n("init.step.start");
|
||||
|
||||
return (
|
||||
<Card w={64 * 6} maw="90vw" withBorder>
|
||||
<Card w={64 * 6} maw="90vw">
|
||||
<Stack>
|
||||
<Text>{tStart("description")}</Text>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { InitUserForm } from "./init-user-form";
|
||||
|
||||
export const InitUser = () => {
|
||||
return (
|
||||
<Card w={64 * 6} maw="90vw" withBorder>
|
||||
<Card w={64 * 6} maw="90vw">
|
||||
<InitUserForm />
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -44,13 +44,13 @@ const fontSans = Inter({
|
||||
export const generateMetadata = async (): Promise<Metadata> => ({
|
||||
title: "Homarr",
|
||||
description:
|
||||
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
||||
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
|
||||
openGraph: {
|
||||
title: "Homarr Dashboard",
|
||||
description:
|
||||
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
||||
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.",
|
||||
url: "https://homarr.dev",
|
||||
siteName: "Homarr Documentation",
|
||||
siteName: "Homarr",
|
||||
},
|
||||
icons: {
|
||||
icon: "/logo/logo.png",
|
||||
|
||||
@@ -45,7 +45,6 @@ export const HeroBanner = async () => {
|
||||
<Image
|
||||
key={`grid-column-${columnIndex}-scroll-1-${index}`}
|
||||
src={icon}
|
||||
radius="md"
|
||||
fit={"contain"}
|
||||
w={50}
|
||||
h={50}
|
||||
@@ -57,7 +56,6 @@ export const HeroBanner = async () => {
|
||||
<Image
|
||||
key={`grid-column-${columnIndex}-scroll-2-${index}`}
|
||||
src={icon}
|
||||
radius="md"
|
||||
fit={"contain"}
|
||||
w={50}
|
||||
h={50}
|
||||
|
||||
@@ -90,9 +90,7 @@ export default async function AboutPage() {
|
||||
<Group py="lg">
|
||||
<Image src={homarrLogoPath} width={100} height={100} alt="" />
|
||||
<Stack gap={0}>
|
||||
<Title order={1} tt="uppercase">
|
||||
Homarr
|
||||
</Title>
|
||||
<Title order={1}>Homarr</Title>
|
||||
<Title order={2}>{t("version", { version })}</Title>
|
||||
</Stack>
|
||||
</Group>
|
||||
@@ -195,9 +193,9 @@ export default async function AboutPage() {
|
||||
href={`https://www.npmjs.com/package/${name}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Card withBorder radius="md" p="xs" className={classes.dependencyCard}>
|
||||
<Card radius="md" p="xs" className={classes.dependencyCard}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" size="lg" radius="md">
|
||||
<ThemeIcon variant="light" size="lg">
|
||||
<IconPackage size="1.5rem" stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Fragment } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconBox, IconPencil } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -11,9 +11,8 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { AppDeleteButton } from "./_app-delete-button";
|
||||
|
||||
@@ -39,34 +38,31 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
|
||||
const t = await getScopedI18n("app");
|
||||
|
||||
return (
|
||||
<ManageContainer>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title>{t("page.list.title")}</Title>
|
||||
<Group justify="space-between" align="center">
|
||||
<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />
|
||||
{session.user.permissions.includes("app-create") && (
|
||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||
{t("page.create.title")}
|
||||
</MobileAffixButton>
|
||||
)}
|
||||
</Group>
|
||||
{apps.length === 0 && <AppNoResults />}
|
||||
{apps.length > 0 && (
|
||||
<Stack gap="sm">
|
||||
{apps.map((app) => (
|
||||
<AppCard key={app.id} app={app} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
const canCreate = session.user.permissions.includes("app-create");
|
||||
|
||||
{/* Added margin to not hide pagination behind affix-button */}
|
||||
<Group justify="end" mb={48}>
|
||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||
</Group>
|
||||
</Stack>
|
||||
</ManageContainer>
|
||||
return (
|
||||
<ManagePageLayout
|
||||
title={t("page.list.title")}
|
||||
primaryAction={
|
||||
canCreate ? (
|
||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||
{t("page.create.title")}
|
||||
</MobileAffixButton>
|
||||
) : undefined
|
||||
}
|
||||
toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />}
|
||||
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
|
||||
floatingPrimaryAction={canCreate}
|
||||
>
|
||||
{apps.length === 0 && <AppNoResults />}
|
||||
{apps.length > 0 && (
|
||||
<Stack gap="sm">
|
||||
{apps.map((app) => (
|
||||
<AppCard key={app.id} app={app} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,7 +75,7 @@ const AppCard = async ({ app }: AppCardProps) => {
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Card>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group align="top" justify="start" wrap="nowrap" style={{ flex: "1" }}>
|
||||
<Avatar
|
||||
|
||||
@@ -1,43 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { Affix, Button, Menu } from "@mantine/core";
|
||||
import { Button, Menu } from "@mantine/core";
|
||||
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
|
||||
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
||||
|
||||
export const CreateBoardButton = () => {
|
||||
const t = useI18n();
|
||||
const { openModal: openAddModal } = useModalAction(AddBoardModal);
|
||||
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
|
||||
|
||||
const buttonGroupContent = (
|
||||
<>
|
||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
|
||||
{t("management.page.board.action.new.label")}
|
||||
</Button>
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<Button px="xs" ms={1}>
|
||||
<IconChevronDown size="1rem" />
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
|
||||
{t("board.action.oldImport.label")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button.Group visibleFrom="md">{buttonGroupContent}</Button.Group>
|
||||
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
||||
<Button.Group>{buttonGroupContent}</Button.Group>
|
||||
</Affix>
|
||||
</>
|
||||
<ManageMobilePrimaryAction>
|
||||
<Button.Group>
|
||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
|
||||
{t("management.page.board.action.new.label")}
|
||||
</Button>
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<Button px="xs" ms={1}>
|
||||
<IconChevronDown size="1rem" />
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
|
||||
{t("board.action.oldImport.label")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Button.Group>
|
||||
</ManageMobilePrimaryAction>
|
||||
);
|
||||
};
|
||||
@@ -9,12 +9,17 @@ import {
|
||||
Group,
|
||||
Menu,
|
||||
MenuTarget,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconDeviceMobile, IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
|
||||
import {
|
||||
IconDeviceMobile,
|
||||
IconDotsVertical,
|
||||
IconHomeFilled,
|
||||
IconLayoutDashboard,
|
||||
IconLock,
|
||||
IconWorld,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
@@ -23,8 +28,8 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link, UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
||||
import { CreateBoardButton } from "./_components/create-board-button";
|
||||
|
||||
@@ -35,23 +40,22 @@ export default async function ManageBoardsPage() {
|
||||
const canCreateBoards = session?.user.permissions.includes("board-create");
|
||||
|
||||
return (
|
||||
<ManageContainer>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title mb="md">{t("title")}</Title>
|
||||
{canCreateBoards && <CreateBoardButton />}
|
||||
</Group>
|
||||
|
||||
<Grid mb={{ base: "xl", md: 0 }}>
|
||||
<ManagePageLayout
|
||||
title={t("title")}
|
||||
primaryAction={canCreateBoards ? <CreateBoardButton /> : undefined}
|
||||
floatingPrimaryAction={canCreateBoards}
|
||||
>
|
||||
{boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />}
|
||||
{boards.length > 0 && (
|
||||
<Grid>
|
||||
{boards.map((board) => (
|
||||
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
|
||||
<BoardCard board={board} />
|
||||
</GridCol>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
</ManageContainer>
|
||||
)}
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,16 +70,14 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
||||
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
|
||||
|
||||
return (
|
||||
<Card radius="lg" withBorder>
|
||||
<Card>
|
||||
<CardSection p="sm" withBorder>
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="sm">
|
||||
<Tooltip label={t(`visibility.${visibility}`)}>
|
||||
<VisibilityIcon size={20} stroke={1.5} />
|
||||
</Tooltip>
|
||||
<Text fw="bolder" tt="uppercase">
|
||||
{board.name}
|
||||
</Text>
|
||||
<Text fw={700}>{board.name}</Text>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
@@ -123,6 +125,7 @@ const BoardCard = async ({ board }: BoardCardProps) => {
|
||||
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label={t("action.settings.label")}
|
||||
>
|
||||
<IconDotsVertical size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
|
||||
+7
-2
@@ -33,7 +33,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
||||
const KindIcon = integrationSecretIcons[secret.kind];
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Card>
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
@@ -58,7 +58,12 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
|
||||
</Tooltip>
|
||||
)}
|
||||
{isPublic ? (
|
||||
<ActionIcon color="gray" variant="subtle" onClick={togglePublicSecretDisplay}>
|
||||
<ActionIcon
|
||||
color="gray"
|
||||
variant="subtle"
|
||||
onClick={togglePublicSecretDisplay}
|
||||
aria-label={t(publicSecretDisplayOpened ? "common.action.hide" : "common.action.show")}
|
||||
>
|
||||
<DisplayIcon size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
|
||||
const causeArray = useMemo(() => toCauseArray(error.cause), [error.cause]);
|
||||
|
||||
return (
|
||||
<Card withBorder style={{ borderColor: getMantineColor("red", 8) }}>
|
||||
<Card style={{ borderColor: getMantineColor("red", 8) }}>
|
||||
<Stack>
|
||||
<Stack gap="sm">
|
||||
<Text size="lg" fw={500} c="red.8">
|
||||
|
||||
+1
-1
@@ -216,7 +216,7 @@ export const CertificateDetailsCard = ({ certificate }: CertificateDetailsProps)
|
||||
const tCertificateField = useScopedI18n("certificate.field");
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Card>
|
||||
<Text fw={500}>{tDetails("title")}</Text>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
border: 1px solid transparent;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
transition: transform 150ms ease;
|
||||
transition: background-color 150ms ease;
|
||||
overflow: hidden;
|
||||
|
||||
&[data-first="true"] {
|
||||
@@ -20,11 +20,9 @@
|
||||
}
|
||||
|
||||
&[data-active] {
|
||||
transform: scale(1.01);
|
||||
z-index: 1;
|
||||
background-color: var(--mantine-color-body);
|
||||
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
border-radius: var(--mantine-radius-lg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,7 @@ import {
|
||||
AccordionPanel,
|
||||
ActionIcon,
|
||||
ActionIconGroup,
|
||||
Affix,
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
@@ -24,9 +22,8 @@ import {
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconChevronDown, IconChevronUp, IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||
import { IconChevronDown, IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
@@ -37,8 +34,8 @@ import { getIntegrationName } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { env } from "~/env";
|
||||
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
||||
@@ -66,38 +63,23 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
const canCreateIntegrations = session.user.permissions.includes("integration-create");
|
||||
|
||||
return (
|
||||
<ManageContainer>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Group justify="space-between" align="center">
|
||||
<Title>{t("page.list.title")}</Title>
|
||||
|
||||
{canCreateIntegrations && (
|
||||
<>
|
||||
<Box>
|
||||
<IntegrationSelectMenu>
|
||||
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
||||
<MenuTarget>
|
||||
<Button rightSection={<IconChevronUp size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
||||
</MenuTarget>
|
||||
</Affix>
|
||||
</IntegrationSelectMenu>
|
||||
</Box>
|
||||
|
||||
<Box visibleFrom="md">
|
||||
<IntegrationSelectMenu>
|
||||
<MenuTarget>
|
||||
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
||||
</MenuTarget>
|
||||
</IntegrationSelectMenu>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
||||
</Stack>
|
||||
</ManageContainer>
|
||||
<ManagePageLayout
|
||||
title={t("page.list.title")}
|
||||
primaryAction={
|
||||
canCreateIntegrations ? (
|
||||
<ManageMobilePrimaryAction>
|
||||
<IntegrationSelectMenu>
|
||||
<MenuTarget>
|
||||
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
||||
</MenuTarget>
|
||||
</IntegrationSelectMenu>
|
||||
</ManageMobilePrimaryAction>
|
||||
) : undefined
|
||||
}
|
||||
floatingPrimaryAction={canCreateIntegrations}
|
||||
>
|
||||
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,17 +4,15 @@ import {
|
||||
Anchor,
|
||||
Group,
|
||||
Image,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
import { IconExternalLink, IconPhoto } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
@@ -26,8 +24,9 @@ import { createLocalImageUrl } from "@homarr/icons/local";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { Link, SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { CopyMedia } from "./_actions/copy-media";
|
||||
import { DeleteMedia } from "./_actions/delete-media";
|
||||
import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
|
||||
@@ -48,7 +47,7 @@ interface MediaListPageProps {
|
||||
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
|
||||
}
|
||||
|
||||
export default async function GroupsListPage(props: MediaListPageProps) {
|
||||
export default async function MediaListPage(props: MediaListPageProps) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
@@ -58,22 +57,31 @@ export default async function GroupsListPage(props: MediaListPageProps) {
|
||||
const t = await getI18n();
|
||||
const searchParams = searchParamsSchema.parse(await props.searchParams);
|
||||
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
|
||||
const canUpload = session.user.permissions.includes("media-upload");
|
||||
|
||||
return (
|
||||
<ManageContainer>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title>{t("media.plural")}</Title>
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
|
||||
{session.user.permissions.includes("media-view-all") && (
|
||||
<IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{session.user.permissions.includes("media-upload") && <UploadMediaButton />}
|
||||
<ManagePageLayout
|
||||
title={t("media.plural")}
|
||||
primaryAction={
|
||||
canUpload ? (
|
||||
<ManageMobilePrimaryAction>
|
||||
<UploadMediaButton />
|
||||
</ManageMobilePrimaryAction>
|
||||
) : undefined
|
||||
}
|
||||
toolbar={
|
||||
<Group>
|
||||
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
|
||||
{session.user.permissions.includes("media-view-all") && (
|
||||
<IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
|
||||
floatingPrimaryAction={canUpload}
|
||||
>
|
||||
{medias.length === 0 && <NoResults icon={IconPhoto} title={t("media.noResults.title")} />}
|
||||
{medias.length > 0 && (
|
||||
<Table striped highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
@@ -90,13 +98,8 @@ export default async function GroupsListPage(props: MediaListPageProps) {
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
{/* Added margin to not hide pagination behind affix-button */}
|
||||
<Group justify="end" mb={48}>
|
||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||
</Group>
|
||||
</Stack>
|
||||
</ManageContainer>
|
||||
)}
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,7 @@ export default async function NotFound() {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Stack align="center">
|
||||
<Title order={1} tt="uppercase">
|
||||
{t("title")}
|
||||
</Title>
|
||||
<Title order={1}>{t("title")}</Title>
|
||||
<Text>{t("text")}</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
|
||||
@@ -29,20 +29,20 @@ export default async function ManagementPage() {
|
||||
<Space h="md" />
|
||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
|
||||
{statistics.map((statistic) => (
|
||||
<Card component={Link} href={statistic.path} key={statistic.path} radius="lg">
|
||||
<Card component={Link} href={statistic.path} key={statistic.path}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group wrap="nowrap">
|
||||
<Text size="2.4rem" fw="bolder">
|
||||
<Text size="xl" fw={700}>
|
||||
{statistic.count}
|
||||
</Text>
|
||||
<Stack gap={0}>
|
||||
<Text c="red" size="xs">
|
||||
<Text c="dimmed" size="xs">
|
||||
{t(`statisticLabel.${statistic.subtitleKey}`)}
|
||||
</Text>
|
||||
<Text fw="bold">{t(`statistic.${statistic.titleKey}`)}</Text>
|
||||
<Text fw={600}>{t(`statistic.${statistic.titleKey}`)}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<IconArrowRight />
|
||||
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" />
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconPencil, IconSearch } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -10,9 +10,8 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
|
||||
|
||||
@@ -38,34 +37,31 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
|
||||
|
||||
const tEngine = await getScopedI18n("search.engine");
|
||||
|
||||
return (
|
||||
<ManageContainer>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title>{tEngine("page.list.title")}</Title>
|
||||
<Group justify="space-between" align="center">
|
||||
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand />
|
||||
{session.user.permissions.includes("search-engine-create") && (
|
||||
<MobileAffixButton component={Link} href="/manage/search-engines/new">
|
||||
{tEngine("page.create.title")}
|
||||
</MobileAffixButton>
|
||||
)}
|
||||
</Group>
|
||||
{searchEngines.length === 0 && <SearchEngineNoResults />}
|
||||
{searchEngines.length > 0 && (
|
||||
<Stack gap="sm">
|
||||
{searchEngines.map((searchEngine) => (
|
||||
<SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
const canCreate = session.user.permissions.includes("search-engine-create");
|
||||
|
||||
{/* Added margin to not hide pagination behind affix-button */}
|
||||
<Group justify="end" mb={48}>
|
||||
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
|
||||
</Group>
|
||||
</Stack>
|
||||
</ManageContainer>
|
||||
return (
|
||||
<ManagePageLayout
|
||||
title={tEngine("page.list.title")}
|
||||
primaryAction={
|
||||
canCreate ? (
|
||||
<MobileAffixButton component={Link} href="/manage/search-engines/new">
|
||||
{tEngine("page.create.title")}
|
||||
</MobileAffixButton>
|
||||
) : undefined
|
||||
}
|
||||
toolbar={<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand />}
|
||||
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
|
||||
floatingPrimaryAction={canCreate}
|
||||
>
|
||||
{searchEngines.length === 0 && <SearchEngineNoResults />}
|
||||
{searchEngines.length > 0 && (
|
||||
<Stack gap="sm">
|
||||
{searchEngines.map((searchEngine) => (
|
||||
<SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,7 +74,7 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Card>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
|
||||
<Avatar
|
||||
|
||||
@@ -41,8 +41,8 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
||||
<>
|
||||
<Title order={2}>{t("title")}</Title>
|
||||
|
||||
<Card pos="relative" withBorder>
|
||||
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
|
||||
<Card pos="relative">
|
||||
<LoadingOverlay visible={isPending} />
|
||||
<Stack>
|
||||
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
|
||||
</Stack>
|
||||
|
||||
+2
-2
@@ -41,11 +41,11 @@ export const CrawlingAndIndexingSettings = ({ initialData }: CrawlingAndIndexing
|
||||
<>
|
||||
<Title order={2}>{t("title")}</Title>
|
||||
|
||||
<Card pos="relative" withBorder>
|
||||
<Card pos="relative">
|
||||
<Text c={"dimmed"} mb={"lg"}>
|
||||
{t("warning")}
|
||||
</Text>
|
||||
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
|
||||
<LoadingOverlay visible={isPending} />
|
||||
<Stack>
|
||||
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
||||
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
||||
|
||||
@@ -79,7 +79,7 @@ export default async function CertificatesPage({ params }: CertificatesPageProps
|
||||
|
||||
<SimpleGrid cols={{ sm: 1, lg: 2, xl: 3 }} spacing="lg">
|
||||
{x509Certificates.map((cert) => (
|
||||
<Card key={cert.fileName} withBorder>
|
||||
<Card key={cert.fileName}>
|
||||
<Group wrap="nowrap">
|
||||
{cert.isError ? (
|
||||
<IconAlertTriangle
|
||||
|
||||
@@ -202,7 +202,7 @@ const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) =>
|
||||
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
|
||||
<Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light" radius="md">
|
||||
<Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light">
|
||||
{t("addToHomarr.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -253,7 +253,6 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
|
||||
onClick={handleClickAsync}
|
||||
loading={isPending}
|
||||
variant="light"
|
||||
radius="md"
|
||||
>
|
||||
{t(`${props.action}.label`)}
|
||||
</Button>
|
||||
|
||||
+7
-19
@@ -4,28 +4,16 @@
|
||||
overflow: hidden;
|
||||
padding: var(--mantine-spacing-xs);
|
||||
padding-left: calc(var(--mantine-spacing-xs) * 2);
|
||||
|
||||
@mixin hover {
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 6px;
|
||||
background-image: linear-gradient(0, var(--mantine-color-blue-4), var(--mantine-color-blue-9));
|
||||
}
|
||||
border-left: 1px solid var(--mantine-color-blue-6);
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-blue-0),
|
||||
color-mix(in oklch, var(--mantine-color-blue-9), transparent 85%)
|
||||
);
|
||||
|
||||
@mixin rtl {
|
||||
padding-left: var(--mantine-spacing-xs);
|
||||
padding-right: calc(var(--mantine-spacing-xs) * 2);
|
||||
|
||||
&::before {
|
||||
left: unset;
|
||||
right: 0;
|
||||
}
|
||||
border-left: none;
|
||||
border-right: 1px solid var(--mantine-color-blue-6);
|
||||
}
|
||||
}
|
||||
-1
@@ -23,7 +23,6 @@ export function HeaderCard(props: HeaderCardProps) {
|
||||
<Flex align="center" justify={isRTL ? "space-between" : "flex-start"} gap="md" direction={"row"}>
|
||||
<ThemeIcon
|
||||
size="xl"
|
||||
radius="md"
|
||||
variant="gradient"
|
||||
gradient={{
|
||||
deg: 0,
|
||||
|
||||
+4
-2
@@ -8,6 +8,8 @@ import { useCurrentLocale, useI18n } from "@homarr/translation/client";
|
||||
import { ResourceIcon } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-icon";
|
||||
import classes from "./resource-gauge.module.css";
|
||||
|
||||
const KUBERNETES_BRAND_COLOR = "#326ce5";
|
||||
|
||||
interface KubernetesResourceGaugeProps {
|
||||
kubernetesCapacity: KubernetesCapacity;
|
||||
}
|
||||
@@ -18,8 +20,8 @@ export function ResourceGauge(props: KubernetesResourceGaugeProps) {
|
||||
const isRTL = Boolean(isLocaleRTL(currentLocale));
|
||||
|
||||
return (
|
||||
<Paper radius="md" withBorder className={classes.paper} mt={20}>
|
||||
<ThemeIcon className={classes.icon} size={60} radius={60} bg={"#326ce5"}>
|
||||
<Paper className={classes.paper} mt={20}>
|
||||
<ThemeIcon className={classes.icon} size={60} radius={60} bg={KUBERNETES_BRAND_COLOR}>
|
||||
<ResourceIcon type={props.kubernetesCapacity.type} />
|
||||
</ThemeIcon>
|
||||
|
||||
|
||||
+1
-6
@@ -17,12 +17,7 @@ interface ResourceTileProps {
|
||||
export function ResourceTile(props: ResourceTileProps) {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
component={Link}
|
||||
href={`/manage/tools/kubernetes/${props.label}`}
|
||||
className={classes.cardContainer}
|
||||
>
|
||||
<Card component={Link} href={`/manage/tools/kubernetes/${props.label}`} className={classes.cardContainer}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Image src={`/images/kubernetes/${props.label}.svg`} alt={props.label} width={64} height={64} />
|
||||
<Group gap="xs">
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
|
||||
|
||||
return (
|
||||
<Box pos="relative">
|
||||
<Menu opened={opened} keepMounted onChange={toggle} position="bottom-start" withArrow>
|
||||
<Menu opened={opened} keepMounted onChange={toggle} position="bottom-start">
|
||||
<Menu.Target>
|
||||
<UnstyledButton onClick={toggle}>
|
||||
<UserAvatar user={user} size={200} />
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Anchor, Button, Group, Text, Title, Tooltip } from "@mantine/core";
|
||||
import { Anchor, Group, Text, Tooltip } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||
import { MantineReactTable } from "mantine-react-table";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Link, UserAvatar } from "@homarr/ui";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
interface UserListComponentProps {
|
||||
initialUserList: RouterOutputs["user"]["getAll"];
|
||||
credentialsProviderEnabled: boolean;
|
||||
}
|
||||
|
||||
export const UserListComponent = ({ initialUserList, credentialsProviderEnabled }: UserListComponentProps) => {
|
||||
const tUserList = useScopedI18n("management.page.user.list");
|
||||
export const UserListComponent = ({ initialUserList }: UserListComponentProps) => {
|
||||
const t = useI18n();
|
||||
const { data, isLoading } = clientApi.user.getAll.useQuery(undefined, {
|
||||
initialData: initialUserList,
|
||||
@@ -69,21 +67,10 @@ export const UserListComponent = ({ initialUserList, credentialsProviderEnabled
|
||||
enableFullScreenToggle: false,
|
||||
layoutMode: "grid-no-grow",
|
||||
getRowId: (row) => row.id,
|
||||
renderTopToolbarCustomActions: () =>
|
||||
credentialsProviderEnabled ? (
|
||||
<Button component={Link} href="/manage/users/create">
|
||||
{t("management.page.user.create.title")}
|
||||
</Button>
|
||||
) : null,
|
||||
state: {
|
||||
isLoading,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title mb="md">{tUserList("title")}</Title>
|
||||
<MantineReactTable table={table} />
|
||||
</>
|
||||
);
|
||||
return <MantineReactTable table={table} />;
|
||||
};
|
||||
+6
-6
@@ -149,7 +149,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
|
||||
color={!generalForm.isValid() ? "red" : undefined}
|
||||
>
|
||||
<form>
|
||||
<Card p="xl" shadow="md" withBorder>
|
||||
<Card p="xl">
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={tUserField("username.label")}
|
||||
@@ -165,7 +165,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
|
||||
</Stepper.Step>
|
||||
<Stepper.Step label={t("step.security.label")} allowStepSelect={false} allowStepClick={false}>
|
||||
<form>
|
||||
<Card p="xl" shadow="md" withBorder>
|
||||
<Card p="xl">
|
||||
<Stack gap="md">
|
||||
<CustomPasswordInput
|
||||
withPasswordRequirements
|
||||
@@ -185,7 +185,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
|
||||
</form>
|
||||
</Stepper.Step>
|
||||
<Stepper.Step label={t("step.groups.label")} allowStepSelect={false} allowStepClick={false}>
|
||||
<Card p="xl" shadow="md" withBorder>
|
||||
<Card p="xl">
|
||||
<GroupsForm
|
||||
initialGroups={initialGroups}
|
||||
addGroup={(groupId) =>
|
||||
@@ -198,20 +198,20 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
|
||||
</Card>
|
||||
</Stepper.Step>
|
||||
<Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}>
|
||||
<Card p="xl" shadow="md" withBorder>
|
||||
<Card p="xl">
|
||||
<Stack maw={300} align="center" mx="auto">
|
||||
<UserAvatar
|
||||
size="xl"
|
||||
user={{ name: generalForm.values.username, email: generalForm.values.email ?? null, image: null }}
|
||||
/>
|
||||
<Text tt="uppercase" fw="bolder" size="xl">
|
||||
<Text fw={700} size="xl">
|
||||
{generalForm.values.username}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stepper.Step>
|
||||
<Stepper.Completed>
|
||||
<Card p="xl" shadow="md" withBorder>
|
||||
<Card p="xl">
|
||||
<Stack align="center" maw={300} mx="auto">
|
||||
<IconUserCheck size="3rem" />
|
||||
<Title order={2}>{t("step.completed.title")}</Title>
|
||||
|
||||
@@ -25,7 +25,7 @@ export const StepperNavigationComponent = ({
|
||||
}: StepperNavigationComponentProps) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Card shadow="md" withBorder>
|
||||
<Card>
|
||||
{!isComplete ? (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Button
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ export const SaveAffix = ({ groupId }: SaveAffixProps) => {
|
||||
<div style={{ position: "sticky", bottom: 20 }}>
|
||||
<Transition transition="slide-up" mounted={form.isDirty()}>
|
||||
{(transitionStyles) => (
|
||||
<Card style={transitionStyles} withBorder>
|
||||
<Card style={transitionStyles}>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>{tForm("unsavedChanges")}</Text>
|
||||
<Group>
|
||||
|
||||
@@ -68,7 +68,6 @@ const PermissionCard = async ({ group, isDanger }: PermissionCardProps) => {
|
||||
return (
|
||||
<Card
|
||||
p="md"
|
||||
withBorder
|
||||
style={{
|
||||
borderColor: isDanger ? "var(--mantine-color-red-8)" : undefined,
|
||||
}}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Group, TextInput } from "@mantine/core";
|
||||
import { useMemo, useState } from "react";
|
||||
import { TextInput } from "@mantine/core";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
@@ -33,29 +33,20 @@ export const GroupsList = ({ groups }: GroupsListProps) => {
|
||||
|
||||
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>
|
||||
|
||||
<TextInput
|
||||
leftSection={<IconSearch size={20} stroke={1.5} />}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
placeholder={`${t("group.search")}...`}
|
||||
/>
|
||||
<GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AddGroup = () => {
|
||||
export const AddGroupButton = () => {
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(AddGroupModal);
|
||||
|
||||
const handleAddGroup = useCallback(() => {
|
||||
openModal();
|
||||
}, [openModal]);
|
||||
|
||||
return <MobileAffixButton onClick={handleAddGroup}>{t("group.action.create.label")}</MobileAffixButton>;
|
||||
return <MobileAffixButton onClick={() => openModal()}>{t("group.action.create.label")}</MobileAffixButton>;
|
||||
};
|
||||
@@ -259,7 +259,7 @@ const SaveAffix = ({ visible, isPending, onDiscard, onSave }: SaveAffixProps) =>
|
||||
<div style={{ position: "sticky", bottom: 20 }}>
|
||||
<Transition transition="slide-up" mounted={visible}>
|
||||
{(transitionStyles) => (
|
||||
<Card style={transitionStyles} withBorder>
|
||||
<Card style={transitionStyles}>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>{t("common.unsavedChanges")}</Text>
|
||||
<Group>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Card, Group, Stack, Text, ThemeIcon, Title, UnstyledButton } from "@mantine/core";
|
||||
import { Card, Group, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
|
||||
import { IconChevronRight, IconUsersGroup } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
@@ -8,9 +8,8 @@ import { everyoneGroup } from "@homarr/definitions";
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { GroupsList } from "./_client";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { AddGroupButton, GroupsList } from "./_client";
|
||||
import classes from "./groups.module.css";
|
||||
|
||||
export default async function GroupsListPage() {
|
||||
@@ -26,11 +25,8 @@ export default async function GroupsListPage() {
|
||||
const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup);
|
||||
|
||||
return (
|
||||
<ManageContainer size="xl">
|
||||
<DynamicBreadcrumb />
|
||||
<ManagePageLayout title={t("group.title")} primaryAction={<AddGroupButton />} floatingPrimaryAction>
|
||||
<Stack>
|
||||
<Title>{t("group.title")}</Title>
|
||||
|
||||
{dbEveryoneGroup && (
|
||||
<UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}>
|
||||
<Card className={classes.everyoneGroup}>
|
||||
@@ -53,6 +49,6 @@ export default async function GroupsListPage() {
|
||||
|
||||
<GroupsList groups={groupsWithoutEveryone} />
|
||||
</Stack>
|
||||
</ManageContainer>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@mantine/core";
|
||||
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { InviteCreateModal } from "@homarr/modals-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { MANAGE_ACTION_BUTTON_MIN_WIDTH } from "~/components/manage/manage-page.constants";
|
||||
|
||||
export const InviteCreateButton = () => {
|
||||
const t = useScopedI18n("management.page.user.invite");
|
||||
const { openModal } = useModalAction(InviteCreateModal);
|
||||
|
||||
return (
|
||||
<Button miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} onClick={() => openModal()}>
|
||||
{t("action.new.title")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { ActionIcon, Button, Title } from "@mantine/core";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
@@ -10,8 +10,7 @@ import { MantineReactTable } from "mantine-react-table";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { InviteCreateModal } from "@homarr/modals-collection";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
@@ -65,7 +64,6 @@ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps
|
||||
enableFullScreenToggle: false,
|
||||
layoutMode: "grid-no-grow",
|
||||
getRowId: (row) => row.id,
|
||||
renderTopToolbarCustomActions: RenderTopToolbarCustomActions,
|
||||
state: {
|
||||
isLoading,
|
||||
},
|
||||
@@ -74,22 +72,7 @@ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title mb="md">{t("title")}</Title>
|
||||
<MantineReactTable table={table} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const RenderTopToolbarCustomActions = () => {
|
||||
const t = useScopedI18n("management.page.user.invite");
|
||||
const { openModal } = useModalAction(InviteCreateModal);
|
||||
const handleNewInvite = useCallback(() => {
|
||||
openModal();
|
||||
}, [openModal]);
|
||||
|
||||
return <Button onClick={handleNewInvite}>{t("action.new.title")}</Button>;
|
||||
return <MantineReactTable table={table} />;
|
||||
};
|
||||
|
||||
const RenderRowActions = ({ row }: { row: MRT_Row<RouterOutputs["invite"]["getAll"][number]> }) => {
|
||||
|
||||
@@ -3,8 +3,11 @@ import { notFound } from "next/navigation";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { InviteCreateButton } from "./_components/invite-create-button";
|
||||
import { InviteListComponent } from "./_components/invite-list";
|
||||
|
||||
export default async function InvitesOverviewPage() {
|
||||
@@ -17,11 +20,20 @@ export default async function InvitesOverviewPage() {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const t = await getScopedI18n("management.page.user.invite");
|
||||
const initialInvites = await api.invite.getAll();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<ManagePageLayout
|
||||
title={t("title")}
|
||||
primaryAction={
|
||||
<ManageMobilePrimaryAction>
|
||||
<InviteCreateButton />
|
||||
</ManageMobilePrimaryAction>
|
||||
}
|
||||
floatingPrimaryAction
|
||||
>
|
||||
<InviteListComponent initialInvites={initialInvites} />
|
||||
</>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { UserListComponent } from "./_components/user-list";
|
||||
|
||||
@@ -27,13 +29,23 @@ export default async function UsersPage() {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const t = await getScopedI18n("management.page.user");
|
||||
const userList = await api.user.getAll();
|
||||
const credentialsProviderEnabled = isProviderEnabled("credentials");
|
||||
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<UserListComponent initialUserList={userList} credentialsProviderEnabled={credentialsProviderEnabled} />
|
||||
</>
|
||||
<ManagePageLayout
|
||||
title={t("list.title")}
|
||||
primaryAction={
|
||||
credentialsProviderEnabled ? (
|
||||
<MobileAffixButton component={Link} href="/manage/users/create">
|
||||
{t("create.title")}
|
||||
</MobileAffixButton>
|
||||
) : undefined
|
||||
}
|
||||
floatingPrimaryAction={credentialsProviderEnabled}
|
||||
>
|
||||
<UserListComponent initialUserList={userList} />
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card withBorder w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
|
||||
<Card w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
@@ -123,17 +123,35 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
</QueryErrorResetBoundary>
|
||||
</Card>
|
||||
<Affix bottom={12} right={72}>
|
||||
<ActionIcon size={48} variant="default" radius="xl" onClick={handleOpenEditWidgetModal}>
|
||||
<ActionIcon
|
||||
size={48}
|
||||
variant="default"
|
||||
radius="xl"
|
||||
onClick={handleOpenEditWidgetModal}
|
||||
aria-label={t("common.action.edit")}
|
||||
>
|
||||
<IconPencil size={24} />
|
||||
</ActionIcon>
|
||||
</Affix>
|
||||
<Affix bottom={12} right={72 + 60}>
|
||||
<ActionIcon size={48} variant="default" radius="xl" onClick={toggleEditMode}>
|
||||
<ActionIcon
|
||||
size={48}
|
||||
variant="default"
|
||||
radius="xl"
|
||||
onClick={toggleEditMode}
|
||||
aria-label={editMode ? t("widgetPreview.toggle.disabled") : t("widgetPreview.toggle.enabled")}
|
||||
>
|
||||
{editMode ? <IconToggleLeft size={24} /> : <IconToggleRight size={24} />}
|
||||
</ActionIcon>
|
||||
</Affix>
|
||||
<Affix bottom={12} right={72 + 120}>
|
||||
<ActionIcon size={48} variant="default" radius="xl" onClick={openDimensionsModal}>
|
||||
<ActionIcon
|
||||
size={48}
|
||||
variant="default"
|
||||
radius="xl"
|
||||
onClick={openDimensionsModal}
|
||||
aria-label={t("widgetPreview.dimensions.title")}
|
||||
>
|
||||
<IconDimensions size={24} />
|
||||
</ActionIcon>
|
||||
</Affix>
|
||||
|
||||
@@ -42,7 +42,6 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
withBorder
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
|
||||
@@ -106,9 +106,17 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
|
||||
<Menu withinPortal position="right-start" arrowPosition="center">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={offset} right={offset} style={{ zIndex: 10 }}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius={"xl"}
|
||||
pos="absolute"
|
||||
top={offset}
|
||||
right={offset}
|
||||
style={{ zIndex: 10 }}
|
||||
aria-label={tItem("menu.label.settings")}
|
||||
>
|
||||
<IconDotsVertical size={"1rem"} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
@@ -145,7 +145,7 @@ const WidgetItem = ({
|
||||
</Text>
|
||||
</Stack>
|
||||
<SupportedIntegrations mt="auto" integrations={item.supportedIntegrations} />
|
||||
<Button onClick={onSelect} variant="light" size="xs" radius="md" fullWidth>
|
||||
<Button onClick={onSelect} variant="light" size="xs" fullWidth>
|
||||
{t(`item.create.addToBoard`)}
|
||||
</Button>
|
||||
</Stack>
|
||||
@@ -170,7 +170,7 @@ const SupportedIntegrations = ({ integrations, mt }: SupportedIntegrationsProps)
|
||||
|
||||
return (
|
||||
<Center mt={mt}>
|
||||
<Tooltip.Group openDelay={300} closeDelay={100}>
|
||||
<Tooltip.Group closeDelay={100}>
|
||||
<Group gap={2}>
|
||||
{integrations.slice(0, countToShow).map((integration) => (
|
||||
<Tooltip key={integration} label={getIntegrationName(integration)} withArrow>
|
||||
|
||||
@@ -45,13 +45,7 @@ export const BoardCategorySection = ({ section }: Props) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{ "--opacity": board.opacity / 100 }}
|
||||
radius={board.itemRadius}
|
||||
withBorder
|
||||
p={0}
|
||||
className={classes.itemCard}
|
||||
>
|
||||
<Card style={{ "--opacity": board.opacity / 100 }} radius={board.itemRadius} p={0} className={classes.itemCard}>
|
||||
<Stack>
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<UnstyledButton w="100%" p="sm" onClick={toggle}>
|
||||
|
||||
@@ -30,9 +30,9 @@ export const CategoryMenu = ({ category }: Props) => {
|
||||
const t = useScopedI18n("section.category");
|
||||
|
||||
return (
|
||||
<Menu withArrow>
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<ActionIcon mr="sm" variant="transparent">
|
||||
<ActionIcon mr="sm" variant="transparent" aria-label={t("menu.label.settings")}>
|
||||
<IconDotsVertical size={20} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
@@ -28,7 +28,6 @@ export const BoardDynamicSection = ({ section }: Props) => {
|
||||
className={combineClasses(classes.itemCard, section.options.customCssClasses.join(" "))}
|
||||
w="100%"
|
||||
h="100%"
|
||||
withBorder
|
||||
styles={{
|
||||
root: {
|
||||
overflow: "visible",
|
||||
|
||||
@@ -43,9 +43,17 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
|
||||
<Menu withinPortal position="right-start" arrowPosition="center">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={4} right={4} style={{ zIndex: 10 }}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
radius={"xl"}
|
||||
pos="absolute"
|
||||
top={4}
|
||||
right={4}
|
||||
style={{ zIndex: 10 }}
|
||||
aria-label={tItem("menu.label.settings")}
|
||||
>
|
||||
<IconDotsVertical size={"1rem"} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
@@ -12,16 +12,7 @@ export const CommonNavLink = (props: ClientNavigationLink) =>
|
||||
const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
const pathname = usePathname();
|
||||
return props.external ? (
|
||||
<NavLink
|
||||
component="a"
|
||||
label={props.label}
|
||||
leftSection={props.icon}
|
||||
href={props.href}
|
||||
style={{
|
||||
borderRadius: 5,
|
||||
}}
|
||||
target="_blank"
|
||||
/>
|
||||
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
|
||||
) : (
|
||||
<NavLink
|
||||
component={Link}
|
||||
@@ -29,9 +20,6 @@ const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
leftSection={props.icon}
|
||||
href={props.href}
|
||||
active={pathname === props.href}
|
||||
style={{
|
||||
borderRadius: 5,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -40,14 +28,7 @@ const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
||||
const pathname = usePathname();
|
||||
const isActive = props.items.some((item) => item.href === pathname);
|
||||
return (
|
||||
<NavLink
|
||||
style={{
|
||||
borderRadius: 5,
|
||||
}}
|
||||
label={props.label}
|
||||
leftSection={props.icon}
|
||||
defaultOpened={isActive}
|
||||
>
|
||||
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
|
||||
{props.items.map((item) => (
|
||||
<NavLinkHref key={item.label} {...item} />
|
||||
))}
|
||||
|
||||
@@ -15,7 +15,7 @@ export const DangerZoneRoot = async ({ children }: DangerZoneRootProps) => {
|
||||
<Title c="red.8" order={2}>
|
||||
{t("common.dangerZone")}
|
||||
</Title>
|
||||
<Card withBorder style={{ borderColor: "var(--mantine-color-red-8)", borderWidth: 3 }}>
|
||||
<Card style={{ borderColor: "var(--mantine-color-red-8)", borderWidth: 3 }}>
|
||||
<Stack gap="sm">
|
||||
{Array.isArray(children)
|
||||
? children.map((child, index) => (
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Affix, Box } from "@mantine/core";
|
||||
|
||||
import { MANAGE_ACTION_BUTTON_MIN_WIDTH, MANAGE_AFFIX_POSITION } from "./manage-page.constants";
|
||||
|
||||
export const ManageMobilePrimaryAction = ({ children }: PropsWithChildren) => (
|
||||
<>
|
||||
<Box visibleFrom="md" miw={MANAGE_ACTION_BUTTON_MIN_WIDTH}>
|
||||
{children}
|
||||
</Box>
|
||||
<Affix hiddenFrom="md" position={MANAGE_AFFIX_POSITION}>
|
||||
{children}
|
||||
</Affix>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { MantineSize } from "@mantine/core";
|
||||
import { Group, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { ManageContainer } from "./manage-container";
|
||||
import { MANAGE_FLOATING_ACTION_BOTTOM_OFFSET } from "./manage-page.constants";
|
||||
|
||||
interface ManagePageLayoutProps {
|
||||
title: ReactNode;
|
||||
primaryAction?: ReactNode;
|
||||
toolbar?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
floatingPrimaryAction?: boolean;
|
||||
size?: MantineSize;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ManagePageLayout = ({
|
||||
title,
|
||||
primaryAction,
|
||||
toolbar,
|
||||
footer,
|
||||
floatingPrimaryAction,
|
||||
size,
|
||||
children,
|
||||
}: ManagePageLayoutProps) => {
|
||||
const titleNode = typeof title === "string" ? <Title>{title}</Title> : title;
|
||||
|
||||
return (
|
||||
<ManageContainer size={size}>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack pb={floatingPrimaryAction ? { base: MANAGE_FLOATING_ACTION_BOTTOM_OFFSET, md: 0 } : undefined}>
|
||||
<Group justify="space-between" align="center">
|
||||
{titleNode}
|
||||
{primaryAction}
|
||||
</Group>
|
||||
{toolbar}
|
||||
{children}
|
||||
{footer && <Group justify="end">{footer}</Group>}
|
||||
</Stack>
|
||||
</ManageContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export const MANAGE_AFFIX_POSITION = { bottom: 20, right: 20 } as const;
|
||||
|
||||
export const MANAGE_FLOATING_ACTION_BOTTOM_OFFSET = 48;
|
||||
|
||||
export const MANAGE_ACTION_BUTTON_MIN_WIDTH = 160;
|
||||
@@ -2,13 +2,15 @@ import { forwardRef } from "react";
|
||||
import type { ButtonProps } from "@mantine/core";
|
||||
import { Affix, Button, createPolymorphicComponent } from "@mantine/core";
|
||||
|
||||
import { MANAGE_ACTION_BUTTON_MIN_WIDTH, MANAGE_AFFIX_POSITION } from "./manage-page.constants";
|
||||
|
||||
type MobileAffixButtonProps = Omit<ButtonProps, "visibleFrom" | "hiddenFrom">;
|
||||
|
||||
export const MobileAffixButton = createPolymorphicComponent<"button", MobileAffixButtonProps>(
|
||||
forwardRef<HTMLButtonElement, MobileAffixButtonProps>((props, ref) => (
|
||||
<>
|
||||
<Button ref={ref} visibleFrom="md" {...props} />
|
||||
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
||||
<Button ref={ref} visibleFrom="md" miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} {...props} />
|
||||
<Affix hiddenFrom="md" position={MANAGE_AFFIX_POSITION}>
|
||||
<Button ref={ref} {...props} />
|
||||
</Affix>
|
||||
</>
|
||||
|
||||
@@ -14,13 +14,13 @@ interface NoResultsProps {
|
||||
|
||||
export const NoResults = ({ icon: Icon, title, action }: NoResultsProps) => {
|
||||
return (
|
||||
<Card withBorder bg="transparent">
|
||||
<Card bg="transparent" withBorder={false}>
|
||||
<Stack align="center" gap="sm">
|
||||
<Icon size="2rem" />
|
||||
<Text fw={500} size="lg">
|
||||
{title}
|
||||
</Text>
|
||||
{!action?.hidden && <Anchor href={action?.href}>{action?.label}</Anchor>}
|
||||
{action && !action.hidden && <Anchor href={action.href}>{action.label}</Anchor>}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -52,7 +52,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
|
||||
|
||||
return (
|
||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||
<Menu width={300} withArrow withinPortal keepMounted>
|
||||
<Menu width={300} withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<AvailableUpdatesMenuItem availableUpdatesPromise={availableUpdatesPromise} />
|
||||
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
|
||||
|
||||
Reference in new issue
Block a user