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:
@@ -0,0 +1,3 @@
|
||||
import headerActions from "../../[name]/@headeractions/page";
|
||||
|
||||
export default headerActions;
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
5
apps/nextjs/src/app/[locale]/boards/(default)/layout.tsx
Normal file
5
apps/nextjs/src/app/[locale]/boards/(default)/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import definition from "./_definition";
|
||||
|
||||
const { layout } = definition;
|
||||
|
||||
export default layout;
|
||||
7
apps/nextjs/src/app/[locale]/boards/(default)/page.tsx
Normal file
7
apps/nextjs/src/app/[locale]/boards/(default)/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import definition from "./_definition";
|
||||
|
||||
const { generateMetadata, page } = definition;
|
||||
|
||||
export default page;
|
||||
|
||||
export { generateMetadata };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
5
apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx
Normal file
5
apps/nextjs/src/app/[locale]/boards/[name]/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import definition from "./_definition";
|
||||
|
||||
const { layout } = definition;
|
||||
|
||||
export default layout;
|
||||
7
apps/nextjs/src/app/[locale]/boards/[name]/page.tsx
Normal file
7
apps/nextjs/src/app/[locale]/boards/[name]/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import definition from "./_definition";
|
||||
|
||||
const { generateMetadata, page } = definition;
|
||||
|
||||
export default page;
|
||||
|
||||
export { generateMetadata };
|
||||
115
apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx
Normal file
115
apps/nextjs/src/app/[locale]/boards/[name]/settings/_general.tsx
Normal 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 : "");
|
||||
};
|
||||
133
apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx
Normal file
133
apps/nextjs/src/app/[locale]/boards/[name]/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
apps/nextjs/src/app/[locale]/boards/_client.tsx
Normal file
79
apps/nextjs/src/app/[locale]/boards/_client.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
80
apps/nextjs/src/app/[locale]/boards/_context.tsx
Normal file
80
apps/nextjs/src/app/[locale]/boards/_context.tsx
Normal 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;
|
||||
};
|
||||
66
apps/nextjs/src/app/[locale]/boards/_creator.tsx
Normal file
66
apps/nextjs/src/app/[locale]/boards/_creator.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
15
apps/nextjs/src/app/[locale]/boards/_types.ts
Normal file
15
apps/nextjs/src/app/[locale]/boards/_types.ts
Normal 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 }
|
||||
>;
|
||||
Reference in New Issue
Block a user