feat: add board (#15)

* wip: Add gridstack board
* wip: Centralize board pages, Add board settings page
* fix: remove cyclic dependency and rename widget-sort to kind
* improve: Add header actions as parallel route
* feat: add item select modal, add category edit modal,
* feat: add edit item modal
* feat: add remove item modal
* wip: add category actions
* feat: add saving of board, wip: add app widget
* Merge branch 'main' into add-board
* chore: update turbo dependencies
* chore: update mantine dependencies
* chore: fix typescript errors, lint and format
* feat: add confirm modal to category removal, move items of removed category to above wrapper
* feat: remove app widget to continue in another branch
* feat: add loading spinner until board is initialized
* fix: issue with cellheight of gridstack items
* feat: add translations for board
* fix: issue with translation for settings page
* chore: address pull request feedback
This commit is contained in:
Meier Lukas
2024-02-03 22:26:12 +01:00
committed by GitHub
parent cfd1c14034
commit 9d520874f4
88 changed files with 3431 additions and 262 deletions

View File

@@ -2,6 +2,7 @@
import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import {
showErrorNotification,
showSuccessNotification,
@@ -9,7 +10,6 @@ import {
import { useScopedI18n } from "@homarr/translation/client";
import { ActionIcon, IconTrash } from "@homarr/ui";
import { api } from "~/trpc/react";
import { revalidatePathAction } from "../../../revalidatePathAction";
import { modalEvents } from "../../modals";
@@ -24,7 +24,7 @@ export const DeleteIntegrationActionButton = ({
}: DeleteIntegrationActionButtonProps) => {
const t = useScopedI18n("integration.page.delete");
const router = useRouter();
const { mutateAsync, isPending } = api.integration.delete.useMutation();
const { mutateAsync, isPending } = clientApi.integration.delete.useMutation();
return (
<ActionIcon

View File

@@ -3,6 +3,7 @@
import { useRef, useState } from "react";
import type { RouterInputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import {
showErrorNotification,
showSuccessNotification,
@@ -18,8 +19,6 @@ import {
Loader,
} from "@homarr/ui";
import { api } from "~/trpc/react";
interface UseTestConnectionDirtyProps {
defaultDirty: boolean;
initialFormValue: {
@@ -77,7 +76,7 @@ export const TestConnection = ({
}: TestConnectionProps) => {
const t = useScopedI18n("integration.testConnection");
const { mutateAsync, ...mutation } =
api.integration.testConnection.useMutation();
clientApi.integration.testConnection.useMutation();
return (
<Group>

View File

@@ -4,6 +4,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getSecretKinds } from "@homarr/definitions";
import { useForm, zodResolver } from "@homarr/form";
import {
@@ -16,7 +17,6 @@ import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { modalEvents } from "~/app/[locale]/modals";
import { api } from "~/trpc/react";
import { SecretCard } from "../../_integration-secret-card";
import { IntegrationSecretInput } from "../../_integration-secret-inputs";
import {
@@ -55,7 +55,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
),
onValuesChange,
});
const { mutateAsync, isPending } = api.integration.update.useMutation();
const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
const secretsMap = new Map(
integration.secrets.map((secret) => [secret.kind, secret]),

View File

@@ -3,6 +3,7 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import type { IntegrationKind } from "@homarr/definitions";
import { getSecretKinds } from "@homarr/definitions";
import { useForm, zodResolver } from "@homarr/form";
@@ -15,7 +16,6 @@ import { Button, Fieldset, Group, Stack, TextInput } from "@homarr/ui";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { api } from "~/trpc/react";
import { IntegrationSecretInput } from "../_integration-secret-inputs";
import {
TestConnection,
@@ -53,7 +53,7 @@ export const NewIntegrationForm = ({
validate: zodResolver(validation.integration.create.omit({ kind: true })),
onValuesChange,
});
const { mutateAsync, isPending } = api.integration.create.useMutation();
const { mutateAsync, isPending } = clientApi.integration.create.useMutation();
const handleSubmit = async (values: FormType) => {
if (isDirty) return;

View File

@@ -7,8 +7,9 @@ import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experime
import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client";
import superjson from "superjson";
import { clientApi } from "@homarr/api/client";
import { env } from "~/env.mjs";
import { api } from "~/trpc/react";
const getBaseUrl = () => {
if (typeof window !== "undefined") return ""; // browser should use relative url
@@ -33,7 +34,7 @@ export function TRPCReactProvider(props: {
);
const [trpcClient] = useState(() =>
api.createClient({
clientApi.createClient({
transformer: superjson,
links: [
loggerLink({
@@ -54,13 +55,13 @@ export function TRPCReactProvider(props: {
);
return (
<api.Provider client={trpcClient} queryClient={queryClient}>
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration transformer={superjson}>
{props.children}
</ReactQueryStreamedHydration>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</api.Provider>
</clientApi.Provider>
);
}

View File

@@ -1,7 +1,7 @@
import { getScopedI18n } from "@homarr/translation/server";
import { Card, Center, Stack, Text, Title } from "@homarr/ui";
import { LogoWithTitle } from "~/components/layout/logo";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { LoginForm } from "./_login-form";
export default async function Login() {
@@ -10,7 +10,7 @@ export default async function Login() {
return (
<Center>
<Stack align="center" mt="xl">
<LogoWithTitle size="lg" />
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t("title")}

View File

@@ -0,0 +1,3 @@
import headerActions from "../../[name]/@headeractions/page";
export default headerActions;

View File

@@ -0,0 +1,8 @@
import { api } from "~/trpc/server";
import { createBoardPage } from "../_creator";
export default createBoardPage<{ locale: string }>({
async getInitialBoard() {
return await api.board.default.query();
},
});

View File

@@ -0,0 +1,5 @@
import definition from "./_definition";
const { layout } = definition;
export default layout;

View File

@@ -0,0 +1,7 @@
import definition from "./_definition";
const { generateMetadata, page } = definition;
export default page;
export { generateMetadata };

View File

@@ -0,0 +1,141 @@
"use client";
import { useAtom, useAtomValue } from "jotai";
import { clientApi } from "@homarr/api/client";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import {
Group,
IconBox,
IconBoxAlignTop,
IconChevronDown,
IconPackageImport,
IconPencil,
IconPencilOff,
IconPlus,
IconSettings,
Menu,
} from "@homarr/ui";
import { modalEvents } from "~/app/[locale]/modals";
import { editModeAtom } from "~/components/board/editMode";
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
import { HeaderButton } from "~/components/layout/header/button";
import { useRequiredBoard } from "../../_context";
export default function BoardViewHeaderActions() {
const isEditMode = useAtomValue(editModeAtom);
const board = useRequiredBoard();
return (
<>
{isEditMode && <AddMenu />}
<EditModeMenu />
<HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
</HeaderButton>
</>
);
}
const AddMenu = () => {
const { addCategoryToEnd } = useCategoryActions();
const t = useI18n();
return (
<Menu position="bottom-end" withArrow>
<Menu.Target>
<HeaderButton w="auto" px={4}>
<Group gap={4} wrap="nowrap">
<IconPlus stroke={1.5} />
<IconChevronDown color="gray" size={16} />
</Group>
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item
leftSection={<IconBox size={20} />}
onClick={() =>
modalEvents.openManagedModal({
title: t("item.create.title"),
size: "xl",
modal: "itemSelectModal",
innerProps: {},
})
}
>
{t("item.action.create")}
</Menu.Item>
<Menu.Item leftSection={<IconPackageImport size={20} />}>
{t("item.action.import")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
leftSection={<IconBoxAlignTop size={20} />}
onClick={() =>
modalEvents.openManagedModal({
title: t("section.category.create.title"),
modal: "categoryEditModal",
innerProps: {
submitLabel: t("section.category.create.submit"),
category: {
id: "new",
name: "",
},
onSuccess({ name }) {
addCategoryToEnd({ name });
},
},
})
}
>
{t("section.category.action.create")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
const EditModeMenu = () => {
const [isEditMode, setEditMode] = useAtom(editModeAtom);
const board = useRequiredBoard();
const t = useScopedI18n("board.action.edit");
const { mutate, isPending } = clientApi.board.save.useMutation({
onSuccess() {
showSuccessNotification({
title: t("notification.success.title"),
message: t("notification.success.message"),
});
setEditMode(false);
},
onError() {
showErrorNotification({
title: t("notification.error.title"),
message: t("notification.error.message"),
});
},
});
const toggle = () => {
if (isEditMode) return mutate(board);
setEditMode(true);
};
return (
<HeaderButton onClick={toggle} loading={isPending}>
{isEditMode ? (
<IconPencilOff stroke={1.5} />
) : (
<IconPencil stroke={1.5} />
)}
</HeaderButton>
);
};

View File

@@ -0,0 +1,16 @@
"use client";
import { IconLayoutBoard } from "@homarr/ui";
import { HeaderButton } from "~/components/layout/header/button";
import { useRequiredBoard } from "../../../_context";
export default function BoardViewLayout() {
const board = useRequiredBoard();
return (
<HeaderButton href={`/boards/${board.name}`}>
<IconLayoutBoard stroke={1.5} />
</HeaderButton>
);
}

View File

@@ -0,0 +1,8 @@
import { api } from "~/trpc/server";
import { createBoardPage } from "../_creator";
export default createBoardPage<{ locale: string; name: string }>({
async getInitialBoard({ name }) {
return await api.board.byName.query({ name });
},
});

View File

@@ -0,0 +1,5 @@
import definition from "./_definition";
const { layout } = definition;
export default layout;

View File

@@ -0,0 +1,7 @@
import definition from "./_definition";
const { generateMetadata, page } = definition;
export default page;
export { generateMetadata };

View File

@@ -0,0 +1,115 @@
"use client";
import { useEffect } from "react";
import {
useDebouncedValue,
useDocumentTitle,
useFavicon,
} from "@mantine/hooks";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { Button, Grid, Group, Stack, TextInput } from "@homarr/ui";
import { useUpdateBoard } from "../../_client";
import type { Board } from "../../_types";
interface Props {
board: Board;
}
export const GeneralSettingsContent = ({ board }: Props) => {
const t = useI18n();
const { updateBoard } = useUpdateBoard();
const { mutate, isPending } =
clientApi.board.saveGeneralSettings.useMutation();
const form = useForm({
initialValues: {
pageTitle: board.pageTitle,
logoImageUrl: board.logoImageUrl,
metaTitle: board.metaTitle,
faviconImageUrl: board.faviconImageUrl,
},
onValuesChange({ pageTitle }) {
updateBoard((previous) => ({
...previous,
pageTitle,
}));
},
});
useMetaTitlePreview(form.values.metaTitle);
useFaviconPreview(form.values.faviconImageUrl);
useLogoPreview(form.values.logoImageUrl);
return (
<form
onSubmit={form.onSubmit((values) => {
mutate(values);
})}
>
<Stack>
<Grid>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.pageTitle.label")}
{...form.getInputProps("pageTitle")}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.metaTitle.label")}
{...form.getInputProps("metaTitle")}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.logoImageUrl.label")}
{...form.getInputProps("logoImageUrl")}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.faviconImageUrl.label")}
{...form.getInputProps("faviconImageUrl")}
/>
</Grid.Col>
</Grid>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
);
};
const useLogoPreview = (url: string | null) => {
const { updateBoard } = useUpdateBoard();
const [logoDebounced] = useDebouncedValue(url ?? "", 500);
useEffect(() => {
if (!logoDebounced.includes(".")) return;
updateBoard((previous) => ({
...previous,
logoImageUrl: logoDebounced,
}));
}, [logoDebounced, updateBoard]);
};
const useMetaTitlePreview = (title: string | null) => {
const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200);
useDocumentTitle(metaTitleDebounced);
};
const validFaviconExtensions = ["ico", "png", "svg", "gif"];
const isValidUrl = (url: string) =>
url.includes("/") &&
validFaviconExtensions.some((extension) => url.endsWith(`.${extension}`));
const useFaviconPreview = (url: string | null) => {
const [faviconDebounced] = useDebouncedValue(url ?? "", 500);
useFavicon(isValidUrl(faviconDebounced) ? faviconDebounced : "");
};

View File

@@ -0,0 +1,133 @@
import { capitalize } from "@homarr/common";
import { getScopedI18n } from "@homarr/translation/server";
import {
Accordion,
AccordionControl,
AccordionItem,
AccordionPanel,
Button,
Container,
Divider,
Group,
IconAlertTriangle,
IconBrush,
IconLayout,
IconSettings,
Stack,
Text,
Title,
} from "@homarr/ui";
import { api } from "~/trpc/server";
import { GeneralSettingsContent } from "./_general";
interface Props {
params: {
name: string;
};
}
export default async function BoardSettingsPage({ params }: Props) {
const board = await api.board.byName.query({ name: params.name });
const t = await getScopedI18n("board.setting");
return (
<Container>
<Stack>
<Title>{t("title", { boardName: capitalize(board.name) })}</Title>
<Accordion variant="separated" defaultValue="general">
<AccordionItem value="general">
<AccordionControl icon={<IconSettings />}>
<Text fw="bold" size="lg">
{t("section.general.title")}
</Text>
</AccordionControl>
<AccordionPanel>
<GeneralSettingsContent board={board} />
</AccordionPanel>
</AccordionItem>
<AccordionItem value="layout">
<AccordionControl icon={<IconLayout />}>
<Text fw="bold" size="lg">
{t("section.layout.title")}
</Text>
</AccordionControl>
<AccordionPanel></AccordionPanel>
</AccordionItem>
<AccordionItem value="appearance">
<AccordionControl icon={<IconBrush />}>
<Text fw="bold" size="lg">
{t("section.appearance.title")}
</Text>
</AccordionControl>
<AccordionPanel></AccordionPanel>
</AccordionItem>
<AccordionItem
value="danger"
styles={{
item: {
"--__item-border-color": "rgba(248,81,73,0.4)",
},
}}
>
<AccordionControl icon={<IconAlertTriangle />}>
<Text fw="bold" size="lg">
{t("section.dangerZone.title")}
</Text>
</AccordionControl>
<AccordionPanel
styles={{ content: { paddingRight: 0, paddingLeft: 0 } }}
>
<Stack gap="sm">
<Divider />
<Group justify="space-between" px="md">
<Stack gap={0}>
<Text fw="bold" size="sm">
{t("section.dangerZone.action.rename.label")}
</Text>
<Text size="sm">
{t("section.dangerZone.action.rename.description")}
</Text>
</Stack>
<Button variant="subtle" color="red">
{t("section.dangerZone.action.rename.button")}
</Button>
</Group>
<Divider />
<Group justify="space-between" px="md">
<Stack gap={0}>
<Text fw="bold" size="sm">
{t("section.dangerZone.action.visibility.label")}
</Text>
<Text size="sm">
{t(
"section.dangerZone.action.visibility.description.private",
)}
</Text>
</Stack>
<Button variant="subtle" color="red">
{t("section.dangerZone.action.visibility.button.private")}
</Button>
</Group>
<Divider />
<Group justify="space-between" px="md">
<Stack gap={0}>
<Text fw="bold" size="sm">
{t("section.dangerZone.action.delete.label")}
</Text>
<Text size="sm">
{t("section.dangerZone.action.delete.description")}
</Text>
</Stack>
<Button variant="subtle" color="red">
{t("section.dangerZone.action.delete.button")}
</Button>
</Group>
</Stack>
</AccordionPanel>
</AccordionItem>
</Accordion>
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import { useCallback, useRef } from "react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { Box, LoadingOverlay, Stack } from "@homarr/ui";
import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { useIsBoardReady, useRequiredBoard } from "./_context";
import type { CategorySection, EmptySection } from "./_types";
type UpdateCallback = (
prev: RouterOutputs["board"]["default"],
) => RouterOutputs["board"]["default"];
export const useUpdateBoard = () => {
const utils = clientApi.useUtils();
const updateBoard = useCallback(
(updaterWithoutUndefined: UpdateCallback) => {
utils.board.default.setData(undefined, (previous) =>
previous ? updaterWithoutUndefined(previous) : previous,
);
},
[utils],
);
return {
updateBoard,
};
};
export const ClientBoard = () => {
const board = useRequiredBoard();
const isReady = useIsBoardReady();
const sectionsWithoutSidebars = board.sections
.filter(
(section): section is CategorySection | EmptySection =>
section.kind !== "sidebar",
)
.sort((a, b) => a.position - b.position);
const ref = useRef<HTMLDivElement>(null);
return (
<Box h="100%" pos="relative">
<LoadingOverlay
visible={!isReady}
transitionProps={{ duration: 500 }}
loaderProps={{ size: "lg", variant: "bars" }}
h="calc(100dvh - var(--app-shell-header-offset, 0px) - var(--app-shell-padding) - var(--app-shell-footer-offset, 0px) - var(--app-shell-padding))"
/>
<Stack
ref={ref}
h="100%"
style={{ visibility: isReady ? "visible" : "hidden" }}
>
{sectionsWithoutSidebars.map((section) =>
section.kind === "empty" ? (
<BoardEmptySection
key={section.id}
section={section}
mainRef={ref}
/>
) : (
<BoardCategorySection
key={section.id}
section={section}
mainRef={ref}
/>
),
)}
</Stack>
</Box>
);
};

View File

@@ -0,0 +1,80 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useState } from "react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
const BoardContext = createContext<{
board: RouterOutputs["board"]["default"];
isReady: boolean;
markAsReady: (id: string) => void;
} | null>(null);
export const BoardProvider = ({
children,
initialBoard,
}: PropsWithChildren<{ initialBoard: RouterOutputs["board"]["default"] }>) => {
const [readySections, setReadySections] = useState<string[]>([]);
const { data } = clientApi.board.default.useQuery(undefined, {
initialData: initialBoard,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const markAsReady = useCallback((id: string) => {
setReadySections((previous) =>
previous.includes(id) ? previous : [...previous, id],
);
}, []);
return (
<BoardContext.Provider
value={{
board: data,
isReady: data.sections.length === readySections.length,
markAsReady,
}}
>
{children}
</BoardContext.Provider>
);
};
export const useMarkSectionAsReady = () => {
const context = useContext(BoardContext);
if (!context) {
throw new Error("Board is required");
}
return context.markAsReady;
};
export const useIsBoardReady = () => {
const context = useContext(BoardContext);
if (!context) {
throw new Error("Board is required");
}
return context.isReady;
};
export const useRequiredBoard = () => {
const optionalBoard = useOptionalBoard();
if (!optionalBoard) {
throw new Error("Board is required");
}
return optionalBoard;
};
export const useOptionalBoard = () => {
const context = useContext(BoardContext);
return context?.board;
};

View File

@@ -0,0 +1,66 @@
import type { PropsWithChildren, ReactNode } from "react";
import type { Metadata } from "next";
import { capitalize } from "@homarr/common";
import { AppShellMain } from "@homarr/ui";
import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell";
import { ClientBoard } from "./_client";
import { BoardProvider } from "./_context";
import type { Board } from "./_types";
// This is placed here because it's used in the layout and the page and because it's here it's not needed to load it everywhere
import "../../../styles/gridstack.scss";
type Params = Record<string, unknown>;
interface Props<TParams extends Params> {
getInitialBoard: (params: TParams) => Promise<Board>;
}
export const createBoardPage = <TParams extends Record<string, unknown>>({
getInitialBoard,
}: Props<TParams>) => {
return {
layout: async ({
params,
children,
headeractions,
}: PropsWithChildren<{ params: TParams; headeractions: ReactNode }>) => {
const initialBoard = await getInitialBoard(params);
return (
<BoardProvider initialBoard={initialBoard}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" />}
actions={headeractions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardProvider>
);
},
page: () => {
// TODO: Add check if board is private and user is not logged in
return <ClientBoard />;
},
generateMetadata: async ({
params,
}: {
params: TParams;
}): Promise<Metadata> => {
const board = await getInitialBoard(params);
return {
title: board.metaTitle ?? `${capitalize(board.name)} board | Homarr`,
icons: {
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
},
};
},
};
};

View File

@@ -0,0 +1,15 @@
import type { RouterOutputs } from "@homarr/api";
import type { WidgetKind } from "@homarr/definitions";
export type Board = RouterOutputs["board"]["default"];
export type Section = Board["sections"][number];
export type Item = Section["items"][number];
export type CategorySection = Extract<Section, { kind: "category" }>;
export type EmptySection = Extract<Section, { kind: "empty" }>;
export type SidebarSection = Extract<Section, { kind: "sidebar" }>;
export type ItemOfKind<TKind extends WidgetKind> = Extract<
Item,
{ kind: TKind }
>;

View File

@@ -2,6 +2,7 @@
import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import { useForm, zodResolver } from "@homarr/form";
import {
showErrorNotification,
@@ -12,12 +13,11 @@ import { Button, PasswordInput, Stack, TextInput } from "@homarr/ui";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { api } from "~/trpc/react";
export const InitUserForm = () => {
const router = useRouter();
const t = useScopedI18n("user");
const { mutateAsync, error, isPending } = api.user.initUser.useMutation();
const { mutateAsync, error, isPending } =
clientApi.user.initUser.useMutation();
const form = useForm<FormType>({
validate: zodResolver(validation.user.init),
validateInputOnBlur: true,

View File

@@ -4,7 +4,7 @@ import { db } from "@homarr/db";
import { getScopedI18n } from "@homarr/translation/server";
import { Card, Center, Stack, Text, Title } from "@homarr/ui";
import { LogoWithTitle } from "~/components/layout/logo";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { InitUserForm } from "./_init-user-form";
export default async function InitUser() {
@@ -23,7 +23,7 @@ export default async function InitUser() {
return (
<Center>
<Stack align="center" mt="xl">
<LogoWithTitle size="lg" />
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t("title")}

View File

@@ -4,6 +4,11 @@ import { createModalManager } from "mantine-modal-manager";
import { WidgetEditModal } from "@homarr/widgets";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
export const [ModalsManager, modalEvents] = createModalManager({
categoryEditModal: CategoryEditModal,
widgetEditModal: WidgetEditModal,
itemSelectModal: ItemSelectModal,
});

View File

@@ -3,9 +3,8 @@
import { useState } from "react";
import type { WidgetOptionDefinition } from "node_modules/@homarr/widgets/src/options";
import type { IntegrationKind } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { ActionIcon, Affix, IconPencil } from "@homarr/ui";
import type { WidgetSort } from "@homarr/widgets";
import {
loadWidgetDynamic,
reduceWidgetOptionsWithDefaultValues,
@@ -15,7 +14,7 @@ import {
import { modalEvents } from "../../modals";
interface WidgetPreviewPageContentProps {
sort: WidgetSort;
kind: WidgetKind;
integrationData: {
id: string;
name: string;
@@ -25,10 +24,10 @@ interface WidgetPreviewPageContentProps {
}
export const WidgetPreviewPageContent = ({
sort,
kind,
integrationData,
}: WidgetPreviewPageContentProps) => {
const currentDefinition = widgetImports[sort].definition;
const currentDefinition = widgetImports[kind].definition;
const options = currentDefinition.options as Record<
string,
WidgetOptionDefinition
@@ -37,11 +36,11 @@ export const WidgetPreviewPageContent = ({
options: Record<string, unknown>;
integrations: string[];
}>({
options: reduceWidgetOptionsWithDefaultValues(options),
options: reduceWidgetOptionsWithDefaultValues(kind, options),
integrations: [],
});
const Comp = loadWidgetDynamic(sort);
const Comp = loadWidgetDynamic(kind);
return (
<>
@@ -60,9 +59,11 @@ export const WidgetPreviewPageContent = ({
return modalEvents.openManagedModal({
modal: "widgetEditModal",
innerProps: {
sort,
definition: currentDefinition.options,
state: [state, setState],
kind,
value: state,
onSuccessfulEdit: (value) => {
setState(value);
},
integrationData: integrationData.filter(
(integration) =>
"supportedIntegrations" in currentDefinition &&

View File

@@ -10,7 +10,7 @@ const getLinks = () => {
return {
href: `/widgets/${key}`,
icon: value.definition.icon,
label: value.definition.sort,
label: value.definition.kind,
};
});
};

View File

@@ -1,17 +1,18 @@
import type { PropsWithChildren } from "react";
import { notFound } from "next/navigation";
import { db } from "@homarr/db";
import type { WidgetKind } from "@homarr/definitions";
import { Center } from "@homarr/ui";
import type { WidgetSort } from "@homarr/widgets";
import { widgetImports } from "@homarr/widgets";
import { WidgetPreviewPageContent } from "./_content";
type Props = PropsWithChildren<{ params: { sort: string } }>;
interface Props {
params: { kind: string };
}
export default async function WidgetPreview(props: Props) {
if (!(props.params.sort in widgetImports)) {
if (!(props.params.kind in widgetImports)) {
notFound();
}
@@ -24,11 +25,11 @@ export default async function WidgetPreview(props: Props) {
},
});
const sort = props.params.sort as WidgetSort;
const sort = props.params.kind as WidgetKind;
return (
<Center h="100vh">
<WidgetPreviewPageContent sort={sort} integrationData={integrationData} />
<WidgetPreviewPageContent kind={sort} integrationData={integrationData} />
</Center>
);
}