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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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 }
|
||||
>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 &&
|
||||
@@ -10,7 +10,7 @@ const getLinks = () => {
|
||||
return {
|
||||
href: `/widgets/${key}`,
|
||||
icon: value.definition.icon,
|
||||
label: value.definition.sort,
|
||||
label: value.definition.kind,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
3
apps/nextjs/src/components/board/editMode.ts
Normal file
3
apps/nextjs/src/components/board/editMode.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const editModeAtom = atom(false);
|
||||
201
apps/nextjs/src/components/board/items/item-actions.tsx
Normal file
201
apps/nextjs/src/components/board/items/item-actions.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { createId } from "@homarr/db/client";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
import { useUpdateBoard } from "~/app/[locale]/boards/_client";
|
||||
import type { EmptySection, Item } from "~/app/[locale]/boards/_types";
|
||||
|
||||
interface MoveAndResizeItem {
|
||||
itemId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
interface MoveItemToSection {
|
||||
itemId: string;
|
||||
sectionId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
interface RemoveItem {
|
||||
itemId: string;
|
||||
}
|
||||
|
||||
interface UpdateItemOptions {
|
||||
itemId: string;
|
||||
newOptions: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface CreateItem {
|
||||
kind: WidgetKind;
|
||||
}
|
||||
|
||||
export const useItemActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const createItem = useCallback(
|
||||
({ kind }: CreateItem) => {
|
||||
updateBoard((previous) => {
|
||||
const lastSection = previous.sections
|
||||
.filter((s): s is EmptySection => s.kind === "empty")
|
||||
.sort((a, b) => b.position - a.position)[0];
|
||||
|
||||
if (!lastSection) return previous;
|
||||
|
||||
const widget = {
|
||||
id: createId(),
|
||||
kind,
|
||||
options: {},
|
||||
width: 1,
|
||||
height: 1,
|
||||
integrations: [],
|
||||
} satisfies Omit<Item, "kind" | "yOffset" | "xOffset"> & {
|
||||
kind: WidgetKind;
|
||||
};
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
// Return same section if item is not in it
|
||||
if (section.id !== lastSection.id) return section;
|
||||
return {
|
||||
...section,
|
||||
items: section.items.concat(widget as unknown as Item),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const updateItemOptions = useCallback(
|
||||
({ itemId, newOptions }: UpdateItemOptions) => {
|
||||
updateBoard((previous) => {
|
||||
if (!previous) return previous;
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
// Return same section if item is not in it
|
||||
if (!section.items.some((item) => item.id === itemId))
|
||||
return section;
|
||||
return {
|
||||
...section,
|
||||
items: section.items.map((item) => {
|
||||
// Return same item if item is not the one we're moving
|
||||
if (item.id !== itemId) return item;
|
||||
return {
|
||||
...item,
|
||||
options: newOptions,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const moveAndResizeItem = useCallback(
|
||||
({ itemId, ...positionProps }: MoveAndResizeItem) => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
// Return same section if item is not in it
|
||||
if (!section.items.some((item) => item.id === itemId)) return section;
|
||||
return {
|
||||
...section,
|
||||
items: section.items.map((item) => {
|
||||
// Return same item if item is not the one we're moving
|
||||
if (item.id !== itemId) return item;
|
||||
return {
|
||||
...item,
|
||||
...positionProps,
|
||||
} satisfies Item;
|
||||
}),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const moveItemToSection = useCallback(
|
||||
({ itemId, sectionId, ...positionProps }: MoveItemToSection) => {
|
||||
updateBoard((previous) => {
|
||||
const currentSection = previous.sections.find((section) =>
|
||||
section.items.some((item) => item.id === itemId),
|
||||
);
|
||||
|
||||
// If item is in the same section (on initial loading) don't do anything
|
||||
if (!currentSection) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const currentItem = currentSection.items.find(
|
||||
(item) => item.id === itemId,
|
||||
);
|
||||
if (!currentItem) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
if (currentSection.id === sectionId && currentItem.xOffset) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
// Return sections without item if not section where it is moved to
|
||||
if (section.id !== sectionId)
|
||||
return {
|
||||
...section,
|
||||
items: section.items.filter((item) => item.id !== itemId),
|
||||
};
|
||||
|
||||
// Return section and add item to it
|
||||
return {
|
||||
...section,
|
||||
items: section.items
|
||||
.filter((item) => item.id !== itemId)
|
||||
.concat({
|
||||
...currentItem,
|
||||
...positionProps,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const removeItem = useCallback(
|
||||
({ itemId }: RemoveItem) => {
|
||||
updateBoard((previous) => {
|
||||
return {
|
||||
...previous,
|
||||
// Filter removed item out of items array
|
||||
sections: previous.sections.map((section) => ({
|
||||
...section,
|
||||
items: section.items.filter((item) => item.id !== itemId),
|
||||
})),
|
||||
};
|
||||
});
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
return {
|
||||
moveAndResizeItem,
|
||||
moveItemToSection,
|
||||
removeItem,
|
||||
updateItemOptions,
|
||||
createItem,
|
||||
};
|
||||
};
|
||||
84
apps/nextjs/src/components/board/items/item-select-modal.tsx
Normal file
84
apps/nextjs/src/components/board/items/item-select-modal.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { ManagedModal } from "mantine-modal-manager";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Button, Card, Center, Grid, Stack, Text } from "@homarr/ui";
|
||||
|
||||
import { objectEntries } from "../../../../../../packages/common/src";
|
||||
import { widgetImports } from "../../../../../../packages/widgets/src";
|
||||
import type { WidgetDefinition } from "../../../../../../packages/widgets/src/definition";
|
||||
import { useItemActions } from "./item-actions";
|
||||
|
||||
export const ItemSelectModal: ManagedModal<Record<string, never>> = ({
|
||||
actions,
|
||||
}) => {
|
||||
return (
|
||||
<Grid>
|
||||
{objectEntries(widgetImports).map(([key, value]) => {
|
||||
return (
|
||||
<WidgetItem
|
||||
key={key}
|
||||
kind={key}
|
||||
definition={value.definition}
|
||||
closeModal={actions.closeModal}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
const WidgetItem = ({
|
||||
kind,
|
||||
definition,
|
||||
closeModal,
|
||||
}: {
|
||||
kind: WidgetKind;
|
||||
definition: WidgetDefinition;
|
||||
closeModal: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const { createItem } = useItemActions();
|
||||
const handleAdd = (kind: WidgetKind) => {
|
||||
createItem({ kind });
|
||||
closeModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid.Col span={{ xs: 12, sm: 4, md: 3 }}>
|
||||
<Card h="100%">
|
||||
<Stack justify="space-between" h="100%">
|
||||
<Stack gap="xs">
|
||||
<Center>
|
||||
<definition.icon />
|
||||
</Center>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
|
||||
{t(`widget.${kind}.name`)}
|
||||
</Text>
|
||||
<Text
|
||||
lh={1.2}
|
||||
style={{ whiteSpace: "normal" }}
|
||||
size="xs"
|
||||
ta="center"
|
||||
c="dimmed"
|
||||
>
|
||||
{t(`widget.${kind}.description`)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
onClick={() => {
|
||||
handleAdd(kind);
|
||||
}}
|
||||
variant="light"
|
||||
size="xs"
|
||||
mt="auto"
|
||||
radius="md"
|
||||
fullWidth
|
||||
>
|
||||
{t(`item.create.addToBoard`)}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { RefObject } from "react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
|
||||
import {
|
||||
Card,
|
||||
Collapse,
|
||||
Group,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
Stack,
|
||||
Title,
|
||||
UnstyledButton,
|
||||
} from "@homarr/ui";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
import { CategoryMenu } from "./category/category-menu";
|
||||
import { SectionContent } from "./content";
|
||||
import { useGridstack } from "./gridstack/use-gridstack";
|
||||
|
||||
interface Props {
|
||||
section: CategorySection;
|
||||
mainRef: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const BoardCategorySection = ({ section, mainRef }: Props) => {
|
||||
const { refs } = useGridstack({ section, mainRef });
|
||||
const [opened, { toggle }] = useDisclosure(false);
|
||||
|
||||
return (
|
||||
<Card withBorder p={0}>
|
||||
<Stack>
|
||||
<Group wrap="nowrap" gap="sm">
|
||||
<UnstyledButton w="100%" p="sm" onClick={toggle}>
|
||||
<Group wrap="nowrap">
|
||||
{opened ? (
|
||||
<IconChevronUp size={20} />
|
||||
) : (
|
||||
<IconChevronDown size={20} />
|
||||
)}
|
||||
<Title order={3}>{section.name}</Title>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<CategoryMenu category={section} />
|
||||
</Group>
|
||||
<Collapse in={opened} p="sm" pt={0}>
|
||||
<div
|
||||
className="grid-stack grid-stack-category"
|
||||
data-category
|
||||
data-section-id={section.id}
|
||||
ref={refs.wrapper}
|
||||
>
|
||||
<SectionContent items={section.items} refs={refs} />
|
||||
</div>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { createId } from "@homarr/db/client";
|
||||
|
||||
import { useUpdateBoard } from "~/app/[locale]/boards/_client";
|
||||
import type {
|
||||
CategorySection,
|
||||
EmptySection,
|
||||
Section,
|
||||
} from "~/app/[locale]/boards/_types";
|
||||
|
||||
interface AddCategory {
|
||||
name: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
interface RenameCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface MoveCategory {
|
||||
id: string;
|
||||
direction: "up" | "down";
|
||||
}
|
||||
|
||||
interface RemoveCategory {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const useCategoryActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const addCategory = useCallback(
|
||||
({ name, position }: AddCategory) => {
|
||||
if (position <= -1) {
|
||||
return;
|
||||
}
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: [
|
||||
// Ignore sidebar sections
|
||||
...previous.sections.filter((section) => section.kind === "sidebar"),
|
||||
// Place sections before the new category
|
||||
...previous.sections.filter(
|
||||
(section) =>
|
||||
(section.kind === "category" || section.kind === "empty") &&
|
||||
section.position < position,
|
||||
),
|
||||
{
|
||||
id: createId(),
|
||||
name,
|
||||
kind: "category",
|
||||
position,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
kind: "empty",
|
||||
position: position + 1,
|
||||
items: [],
|
||||
},
|
||||
// Place sections after the new category
|
||||
...previous.sections
|
||||
.filter(
|
||||
(section): section is CategorySection | EmptySection =>
|
||||
(section.kind === "category" || section.kind === "empty") &&
|
||||
section.position >= position,
|
||||
)
|
||||
.map((section) => ({
|
||||
...section,
|
||||
position: section.position + 2,
|
||||
})),
|
||||
],
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const addCategoryToEnd = useCallback(
|
||||
({ name }: { name: string }) => {
|
||||
updateBoard((previous) => {
|
||||
const lastSection = previous.sections
|
||||
.filter(
|
||||
(x): x is CategorySection | EmptySection =>
|
||||
x.kind === "empty" || x.kind === "category",
|
||||
)
|
||||
.sort((a, b) => b.position - a.position)
|
||||
.at(0);
|
||||
|
||||
if (!lastSection) return previous;
|
||||
const lastPosition = lastSection.position;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: [
|
||||
...previous.sections,
|
||||
{
|
||||
id: createId(),
|
||||
name,
|
||||
kind: "category",
|
||||
position: lastPosition + 1,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
kind: "empty",
|
||||
position: lastPosition + 2,
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const renameCategory = useCallback(
|
||||
({ id: categoryId, name }: RenameCategory) => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
if (section.kind !== "category") return section;
|
||||
if (section.id !== categoryId) return section;
|
||||
return {
|
||||
...section,
|
||||
name,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const moveCategory = useCallback(
|
||||
({ id, direction }: MoveCategory) => {
|
||||
updateBoard((previous) => {
|
||||
const currentCategory = previous.sections.find(
|
||||
(section): section is CategorySection =>
|
||||
section.kind === "category" && section.id === id,
|
||||
);
|
||||
if (!currentCategory) return previous;
|
||||
if (currentCategory?.position === 1 && direction === "up")
|
||||
return previous;
|
||||
if (
|
||||
currentCategory?.position === previous.sections.length - 2 &&
|
||||
direction === "down"
|
||||
)
|
||||
return previous;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
if (section.kind !== "category" && section.kind !== "empty")
|
||||
return section;
|
||||
const offset = direction === "up" ? -2 : 2;
|
||||
// Move category and empty section
|
||||
if (
|
||||
section.position === currentCategory.position ||
|
||||
section.position - 1 === currentCategory.position
|
||||
) {
|
||||
return {
|
||||
...section,
|
||||
position: section.position + offset,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
direction === "up" &&
|
||||
(section.position === currentCategory.position - 2 ||
|
||||
section.position === currentCategory.position - 1)
|
||||
) {
|
||||
return {
|
||||
...section,
|
||||
position: section.position + 2,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
direction === "down" &&
|
||||
(section.position === currentCategory.position + 2 ||
|
||||
section.position === currentCategory.position + 3)
|
||||
) {
|
||||
return {
|
||||
...section,
|
||||
position: section.position - 2,
|
||||
};
|
||||
}
|
||||
|
||||
return section;
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
const removeCategory = useCallback(
|
||||
({ id: categoryId }: RemoveCategory) => {
|
||||
updateBoard((previous) => {
|
||||
const currentCategory = previous.sections.find(
|
||||
(section): section is CategorySection =>
|
||||
section.kind === "category" && section.id === categoryId,
|
||||
);
|
||||
if (!currentCategory) return previous;
|
||||
|
||||
const aboveWrapper = previous.sections.find(
|
||||
(section): section is EmptySection =>
|
||||
section.kind === "empty" &&
|
||||
section.position === currentCategory.position - 1,
|
||||
);
|
||||
|
||||
const removedWrapper = previous.sections.find(
|
||||
(section): section is EmptySection =>
|
||||
section.kind === "empty" &&
|
||||
section.position === currentCategory.position + 1,
|
||||
);
|
||||
|
||||
if (!aboveWrapper || !removedWrapper) return previous;
|
||||
|
||||
// Calculate the yOffset for the items in the currentCategory and removedWrapper to add them with the same offset to the aboveWrapper
|
||||
const aboveYOffset = calculateYHeightWithOffset(aboveWrapper);
|
||||
const categoryYOffset = calculateYHeightWithOffset(currentCategory);
|
||||
|
||||
const previousCategoryItems = currentCategory.items.map((item) => ({
|
||||
...item,
|
||||
yOffset: item.yOffset + aboveYOffset,
|
||||
}));
|
||||
const previousBelowWrapperItems = removedWrapper.items.map((item) => ({
|
||||
...item,
|
||||
yOffset: item.yOffset + aboveYOffset + categoryYOffset,
|
||||
}));
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: [
|
||||
...previous.sections.filter(
|
||||
(section) => section.kind === "sidebar",
|
||||
),
|
||||
...previous.sections.filter(
|
||||
(section) =>
|
||||
(section.kind === "category" || section.kind === "empty") &&
|
||||
section.position < currentCategory.position - 1,
|
||||
),
|
||||
{
|
||||
...aboveWrapper,
|
||||
items: [
|
||||
...aboveWrapper.items,
|
||||
...previousCategoryItems,
|
||||
...previousBelowWrapperItems,
|
||||
],
|
||||
},
|
||||
...previous.sections
|
||||
.filter(
|
||||
(section): section is CategorySection | EmptySection =>
|
||||
(section.kind === "category" || section.kind === "empty") &&
|
||||
section.position >= currentCategory.position + 2,
|
||||
)
|
||||
.map((section) => ({
|
||||
...section,
|
||||
position: section.position - 2,
|
||||
})),
|
||||
],
|
||||
};
|
||||
});
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
return {
|
||||
addCategory,
|
||||
addCategoryToEnd,
|
||||
renameCategory,
|
||||
moveCategory,
|
||||
removeCategory,
|
||||
};
|
||||
};
|
||||
|
||||
const calculateYHeightWithOffset = (section: Section) =>
|
||||
section.items.reduce((acc, item) => {
|
||||
const yHeightWithOffset = item.yOffset + item.height;
|
||||
if (yHeightWithOffset > acc) return yHeightWithOffset;
|
||||
return acc;
|
||||
}, 0);
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ManagedModal } from "mantine-modal-manager";
|
||||
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Button, Group, Stack, TextInput } from "@homarr/ui";
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface InnerProps {
|
||||
submitLabel: string;
|
||||
category: Category;
|
||||
onSuccess: (category: Category) => void;
|
||||
}
|
||||
|
||||
export const CategoryEditModal: ManagedModal<InnerProps> = ({
|
||||
actions,
|
||||
innerProps,
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: innerProps.category.name,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((v) => {
|
||||
void innerProps.onSuccess({
|
||||
...innerProps.category,
|
||||
name: v.name,
|
||||
});
|
||||
actions.closeModal();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t("section.category.field.name.label")}
|
||||
data-autofocus
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<Group justify="right">
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" color="teal">
|
||||
{innerProps.submitLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { createId } from "@homarr/db/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
import { modalEvents } from "~/app/[locale]/modals";
|
||||
import { useCategoryActions } from "./category-actions";
|
||||
|
||||
export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
const { addCategory, moveCategory, removeCategory, renameCategory } =
|
||||
useCategoryActions();
|
||||
const t = useI18n();
|
||||
|
||||
const createCategoryAtPosition = useCallback(
|
||||
(position: number) => {
|
||||
modalEvents.openManagedModal({
|
||||
title: t("section.category.create.title"),
|
||||
modal: "categoryEditModal",
|
||||
innerProps: {
|
||||
category: {
|
||||
id: createId(),
|
||||
name: t("section.category.create.title"),
|
||||
},
|
||||
onSuccess: (category) => {
|
||||
addCategory({
|
||||
name: category.name,
|
||||
position,
|
||||
});
|
||||
},
|
||||
submitLabel: t("section.category.create.submit"),
|
||||
},
|
||||
});
|
||||
},
|
||||
[addCategory, t],
|
||||
);
|
||||
|
||||
// creates a new category above the current
|
||||
const addCategoryAbove = useCallback(() => {
|
||||
const abovePosition = category.position;
|
||||
createCategoryAtPosition(abovePosition);
|
||||
}, [category.position, createCategoryAtPosition]);
|
||||
|
||||
// creates a new category below the current
|
||||
const addCategoryBelow = useCallback(() => {
|
||||
const belowPosition = category.position + 2;
|
||||
createCategoryAtPosition(belowPosition);
|
||||
}, [category.position, createCategoryAtPosition]);
|
||||
|
||||
const moveCategoryUp = useCallback(() => {
|
||||
moveCategory({
|
||||
id: category.id,
|
||||
direction: "up",
|
||||
});
|
||||
}, [category.id, moveCategory]);
|
||||
|
||||
const moveCategoryDown = useCallback(() => {
|
||||
moveCategory({
|
||||
id: category.id,
|
||||
direction: "down",
|
||||
});
|
||||
}, [category.id, moveCategory]);
|
||||
|
||||
// Removes the current category
|
||||
const remove = useCallback(() => {
|
||||
modalEvents.openConfirmModal({
|
||||
title: t("section.category.remove.title"),
|
||||
children: t("section.category.remove.message", {
|
||||
name: category.name,
|
||||
}),
|
||||
onConfirm: () => {
|
||||
removeCategory({
|
||||
id: category.id,
|
||||
});
|
||||
},
|
||||
confirmProps: {
|
||||
color: "red",
|
||||
},
|
||||
});
|
||||
}, [category.id, category.name, removeCategory, t]);
|
||||
|
||||
const edit = () => {
|
||||
modalEvents.openManagedModal({
|
||||
modal: "categoryEditModal",
|
||||
title: t("section.category.edit.title"),
|
||||
innerProps: {
|
||||
category,
|
||||
submitLabel: t("section.category.edit.submit"),
|
||||
onSuccess: (category) => {
|
||||
renameCategory({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
addCategoryAbove,
|
||||
addCategoryBelow,
|
||||
moveCategoryUp,
|
||||
moveCategoryDown,
|
||||
remove,
|
||||
edit,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIconsProps } from "@homarr/ui";
|
||||
import {
|
||||
ActionIcon,
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconRowInsertBottom,
|
||||
IconRowInsertTop,
|
||||
IconTransitionBottom,
|
||||
IconTransitionTop,
|
||||
IconTrash,
|
||||
Menu,
|
||||
} from "@homarr/ui";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
import { editModeAtom } from "../../editMode";
|
||||
import { useCategoryMenuActions } from "./category-menu-actions";
|
||||
|
||||
interface Props {
|
||||
category: CategorySection;
|
||||
}
|
||||
|
||||
export const CategoryMenu = ({ category }: Props) => {
|
||||
const actions = useActions(category);
|
||||
const t = useScopedI18n("section.category");
|
||||
|
||||
if (actions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Menu withArrow>
|
||||
<Menu.Target>
|
||||
<ActionIcon mr="sm" variant="transparent">
|
||||
<IconDotsVertical size={20} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{actions.map((action) => (
|
||||
<React.Fragment key={action.label}>
|
||||
{"group" in action && <Menu.Label>{t(action.group)}</Menu.Label>}
|
||||
<Menu.Item
|
||||
leftSection={<action.icon size="1rem" />}
|
||||
onClick={action.onClick}
|
||||
color={"color" in action ? action.color : undefined}
|
||||
>
|
||||
{t(action.label)}
|
||||
</Menu.Item>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const useActions = (category: CategorySection) => {
|
||||
const isEditMode = useAtomValue(editModeAtom);
|
||||
const editModeActions = useEditModeActions(category);
|
||||
const nonEditModeActions = useNonEditModeActions(category);
|
||||
|
||||
return useMemo(
|
||||
() => (isEditMode ? editModeActions : nonEditModeActions),
|
||||
[isEditMode, editModeActions, nonEditModeActions],
|
||||
);
|
||||
};
|
||||
|
||||
const useEditModeActions = (category: CategorySection) => {
|
||||
const {
|
||||
addCategoryAbove,
|
||||
addCategoryBelow,
|
||||
moveCategoryUp,
|
||||
moveCategoryDown,
|
||||
edit,
|
||||
remove,
|
||||
} = useCategoryMenuActions(category);
|
||||
|
||||
return [
|
||||
{
|
||||
icon: IconEdit,
|
||||
label: "action.edit",
|
||||
onClick: edit,
|
||||
},
|
||||
{
|
||||
icon: IconTrash,
|
||||
color: "red",
|
||||
label: "action.remove",
|
||||
onClick: remove,
|
||||
},
|
||||
{
|
||||
group: "menu.label.changePosition",
|
||||
icon: IconTransitionTop,
|
||||
label: "action.moveUp",
|
||||
onClick: moveCategoryUp,
|
||||
},
|
||||
{
|
||||
icon: IconTransitionBottom,
|
||||
label: "action.moveDown",
|
||||
onClick: moveCategoryDown,
|
||||
},
|
||||
{
|
||||
group: "menu.label.create",
|
||||
icon: IconRowInsertTop,
|
||||
label: "action.createAbove",
|
||||
onClick: addCategoryAbove,
|
||||
},
|
||||
{
|
||||
icon: IconRowInsertBottom,
|
||||
label: "action.createBelow",
|
||||
onClick: addCategoryBelow,
|
||||
},
|
||||
] as const satisfies ActionDefinition[];
|
||||
};
|
||||
|
||||
// TODO: once apps are added we can use this for the open many apps action
|
||||
const useNonEditModeActions = (_category: CategorySection) => {
|
||||
return [] as const satisfies ActionDefinition[];
|
||||
};
|
||||
|
||||
interface ActionDefinition {
|
||||
icon: (props: TablerIconsProps) => JSX.Element;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
color?: string;
|
||||
group?: string;
|
||||
}
|
||||
153
apps/nextjs/src/components/board/sections/content.tsx
Normal file
153
apps/nextjs/src/components/board/sections/content.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
/* eslint-disable react/no-unknown-property */
|
||||
// Ignored because of gridstack attributes
|
||||
|
||||
import type { RefObject } from "react";
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import {
|
||||
ActionIcon,
|
||||
Card,
|
||||
IconDotsVertical,
|
||||
IconLayoutKanban,
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
Menu,
|
||||
} from "@homarr/ui";
|
||||
import {
|
||||
loadWidgetDynamic,
|
||||
reduceWidgetOptionsWithDefaultValues,
|
||||
} from "@homarr/widgets";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import { modalEvents } from "~/app/[locale]/modals";
|
||||
import { editModeAtom } from "../editMode";
|
||||
import { useItemActions } from "../items/item-actions";
|
||||
import type { UseGridstackRefs } from "./gridstack/use-gridstack";
|
||||
|
||||
interface Props {
|
||||
items: Item[];
|
||||
refs: UseGridstackRefs;
|
||||
}
|
||||
|
||||
export const SectionContent = ({ items, refs }: Props) => {
|
||||
return (
|
||||
<>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="grid-stack-item"
|
||||
data-id={item.id}
|
||||
gs-x={item.xOffset}
|
||||
gs-y={item.yOffset}
|
||||
gs-w={item.width}
|
||||
gs-h={item.height}
|
||||
gs-min-w={1}
|
||||
gs-min-h={1}
|
||||
gs-max-w={4}
|
||||
gs-max-h={4}
|
||||
ref={refs.items.current[item.id] as RefObject<HTMLDivElement>}
|
||||
>
|
||||
<Card className="grid-stack-item-content" withBorder>
|
||||
<BoardItem item={item} />
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ItemProps {
|
||||
item: Item;
|
||||
}
|
||||
|
||||
const BoardItem = ({ item }: ItemProps) => {
|
||||
const Comp = loadWidgetDynamic(item.kind);
|
||||
const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options);
|
||||
const newItem = { ...item, options };
|
||||
return (
|
||||
<>
|
||||
<ItemMenu offset={8} item={newItem} />
|
||||
<Comp options={options as never} integrations={item.integrations} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
|
||||
const t = useScopedI18n("item");
|
||||
const isEditMode = useAtomValue(editModeAtom);
|
||||
const { updateItemOptions, removeItem } = useItemActions();
|
||||
|
||||
if (!isEditMode) return null;
|
||||
|
||||
const openEditModal = () => {
|
||||
modalEvents.openManagedModal({
|
||||
title: t("edit.title"),
|
||||
modal: "widgetEditModal",
|
||||
innerProps: {
|
||||
kind: item.kind,
|
||||
value: {
|
||||
options: item.options,
|
||||
integrations: item.integrations.map(({ id }) => id),
|
||||
},
|
||||
onSuccessfulEdit: ({ options, integrations: _ }) => {
|
||||
updateItemOptions({
|
||||
itemId: item.id,
|
||||
newOptions: options,
|
||||
});
|
||||
},
|
||||
integrationData: [],
|
||||
integrationSupport: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openRemoveModal = () => {
|
||||
modalEvents.openConfirmModal({
|
||||
title: t("remove.title"),
|
||||
children: t("remove.message"),
|
||||
onConfirm: () => {
|
||||
removeItem({ itemId: item.id });
|
||||
},
|
||||
confirmProps: {
|
||||
color: "red",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
pos="absolute"
|
||||
top={offset}
|
||||
right={offset}
|
||||
>
|
||||
<IconDotsVertical />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
<Menu.Label>{t("menu.label.settings")}</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconPencil size={16} />}
|
||||
onClick={openEditModal}
|
||||
>
|
||||
{t("action.edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconLayoutKanban size={16} />}>
|
||||
{t("action.move")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.6">{t("menu.label.dangerZone")}</Menu.Label>
|
||||
<Menu.Item
|
||||
c="red.6"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={openRemoveModal}
|
||||
>
|
||||
{t("action.remove")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
35
apps/nextjs/src/components/board/sections/empty-section.tsx
Normal file
35
apps/nextjs/src/components/board/sections/empty-section.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { RefObject } from "react";
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { editModeAtom } from "../editMode";
|
||||
import { SectionContent } from "./content";
|
||||
import { useGridstack } from "./gridstack/use-gridstack";
|
||||
|
||||
interface Props {
|
||||
section: EmptySection;
|
||||
mainRef: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const defaultClasses = "grid-stack grid-stack-empty min-row";
|
||||
|
||||
export const BoardEmptySection = ({ section, mainRef }: Props) => {
|
||||
const { refs } = useGridstack({ section, mainRef });
|
||||
const isEditMode = useAtomValue(editModeAtom);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
section.items.length > 0 || isEditMode
|
||||
? defaultClasses
|
||||
: `${defaultClasses} gridstack-empty-wrapper`
|
||||
}
|
||||
style={{ transitionDuration: "0s" }}
|
||||
data-empty
|
||||
data-section-id={section.id}
|
||||
ref={refs.wrapper}
|
||||
>
|
||||
<SectionContent items={section.items} refs={refs} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { MutableRefObject, RefObject } from "react";
|
||||
import type { GridItemHTMLElement } from "fily-publish-gridstack";
|
||||
import { GridStack } from "fily-publish-gridstack";
|
||||
|
||||
import type { Section } from "~/app/[locale]/boards/_types";
|
||||
|
||||
interface InitializeGridstackProps {
|
||||
section: Section;
|
||||
refs: {
|
||||
wrapper: RefObject<HTMLDivElement>;
|
||||
items: MutableRefObject<Record<string, RefObject<GridItemHTMLElement>>>;
|
||||
gridstack: MutableRefObject<GridStack | undefined>;
|
||||
};
|
||||
sectionColumnCount: number;
|
||||
}
|
||||
|
||||
export const initializeGridstack = ({
|
||||
section,
|
||||
refs,
|
||||
sectionColumnCount,
|
||||
}: InitializeGridstackProps) => {
|
||||
if (!refs.wrapper.current) return false;
|
||||
// calculates the currently available count of columns
|
||||
const columnCount = section.kind === "sidebar" ? 2 : sectionColumnCount;
|
||||
const minRow =
|
||||
section.kind !== "sidebar"
|
||||
? 1
|
||||
: Math.floor(refs.wrapper.current.offsetHeight / 128);
|
||||
// initialize gridstack
|
||||
const newGrid = refs.gridstack;
|
||||
newGrid.current = GridStack.init(
|
||||
{
|
||||
column: columnCount,
|
||||
margin: section.kind === "sidebar" ? 5 : 10,
|
||||
cellHeight: 128,
|
||||
float: true,
|
||||
alwaysShowResizeHandle: true,
|
||||
acceptWidgets: true,
|
||||
disableOneColumnMode: true,
|
||||
staticGrid: true,
|
||||
minRow,
|
||||
animate: false,
|
||||
styleInHead: true,
|
||||
},
|
||||
// selector of the gridstack item (it's eather category or wrapper)
|
||||
`.grid-stack-${section.kind}[data-section-id='${section.id}']`,
|
||||
);
|
||||
const grid = newGrid.current;
|
||||
if (!grid) return false;
|
||||
// Must be used to update the column count after the initialization
|
||||
grid.column(columnCount, "none");
|
||||
|
||||
grid.batchUpdate();
|
||||
grid.removeAll(false);
|
||||
section.items.forEach(({ id }) => {
|
||||
const ref = refs.items.current[id]?.current;
|
||||
ref && grid.makeWidget(ref);
|
||||
});
|
||||
grid.batchUpdate(false);
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { MutableRefObject, RefObject } from "react";
|
||||
import { createRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type {
|
||||
GridItemHTMLElement,
|
||||
GridStack,
|
||||
GridStackNode,
|
||||
} from "fily-publish-gridstack";
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
import {
|
||||
useMarkSectionAsReady,
|
||||
useRequiredBoard,
|
||||
} from "~/app/[locale]/boards/_context";
|
||||
import type { Section } from "~/app/[locale]/boards/_types";
|
||||
import { editModeAtom } from "../../editMode";
|
||||
import { useItemActions } from "../../items/item-actions";
|
||||
import { initializeGridstack } from "./init-gridstack";
|
||||
|
||||
export interface UseGridstackRefs {
|
||||
wrapper: RefObject<HTMLDivElement>;
|
||||
items: MutableRefObject<Record<string, RefObject<GridItemHTMLElement>>>;
|
||||
gridstack: MutableRefObject<GridStack | undefined>;
|
||||
}
|
||||
|
||||
interface UseGristackReturnType {
|
||||
refs: UseGridstackRefs;
|
||||
}
|
||||
|
||||
interface UseGridstackProps {
|
||||
section: Section;
|
||||
mainRef?: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const useGridstack = ({
|
||||
section,
|
||||
mainRef,
|
||||
}: UseGridstackProps): UseGristackReturnType => {
|
||||
const isEditMode = useAtomValue(editModeAtom);
|
||||
const markAsReady = useMarkSectionAsReady();
|
||||
const { moveAndResizeItem, moveItemToSection } = useItemActions();
|
||||
// define reference for wrapper - is used to calculate the width of the wrapper
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
// references to the diffrent items contained in the gridstack
|
||||
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement>>>({});
|
||||
// reference of the gridstack object for modifications after initialization
|
||||
const gridRef = useRef<GridStack>();
|
||||
|
||||
useCssVariableConfiguration({ section, mainRef, gridRef });
|
||||
|
||||
const sectionColumnCount = useSectionColumnCount(section.kind);
|
||||
|
||||
const items = useMemo(() => section.items, [section.items]);
|
||||
|
||||
// define items in itemRefs for easy access and reference to items
|
||||
if (Object.keys(itemRefs.current).length !== items.length) {
|
||||
items.forEach(({ id }: { id: keyof typeof itemRefs.current }) => {
|
||||
itemRefs.current[id] = itemRefs.current[id] ?? createRef();
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
gridRef.current?.setStatic(!isEditMode);
|
||||
}, [isEditMode]);
|
||||
|
||||
const onChange = useCallback(
|
||||
(changedNode: GridStackNode) => {
|
||||
const itemId = changedNode.el?.getAttribute("data-id");
|
||||
if (!itemId) return;
|
||||
|
||||
// Updates the react-query state
|
||||
moveAndResizeItem({
|
||||
itemId,
|
||||
xOffset: changedNode.x!,
|
||||
yOffset: changedNode.y!,
|
||||
width: changedNode.w!,
|
||||
height: changedNode.h!,
|
||||
});
|
||||
},
|
||||
[moveAndResizeItem],
|
||||
);
|
||||
const onAdd = useCallback(
|
||||
(addedNode: GridStackNode) => {
|
||||
const itemId = addedNode.el?.getAttribute("data-id");
|
||||
if (!itemId) return;
|
||||
|
||||
// Updates the react-query state
|
||||
moveItemToSection({
|
||||
itemId,
|
||||
sectionId: section.id,
|
||||
xOffset: addedNode.x!,
|
||||
yOffset: addedNode.y!,
|
||||
width: addedNode.w!,
|
||||
height: addedNode.h!,
|
||||
});
|
||||
},
|
||||
[moveItemToSection, section.id],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditMode) return;
|
||||
const currentGrid = gridRef.current;
|
||||
// Add listener for moving items around in a wrapper
|
||||
currentGrid?.on("change", (_, nodes) => {
|
||||
(nodes as GridStackNode[]).forEach(onChange);
|
||||
});
|
||||
|
||||
// Add listener for moving items in config from one wrapper to another
|
||||
currentGrid?.on("added", (_, el) => {
|
||||
const nodes = el as GridStackNode[];
|
||||
nodes.forEach((node) => onAdd(node));
|
||||
});
|
||||
|
||||
return () => {
|
||||
currentGrid?.off("change");
|
||||
currentGrid?.off("added");
|
||||
};
|
||||
}, [isEditMode, onAdd, onChange]);
|
||||
|
||||
// initialize the gridstack
|
||||
useEffect(() => {
|
||||
const isReady = initializeGridstack({
|
||||
section,
|
||||
refs: {
|
||||
items: itemRefs,
|
||||
wrapper: wrapperRef,
|
||||
gridstack: gridRef,
|
||||
},
|
||||
sectionColumnCount,
|
||||
});
|
||||
|
||||
if (isReady) {
|
||||
markAsReady(section.id);
|
||||
}
|
||||
|
||||
// Only run this effect when the section items change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [items.length, section.items.length]);
|
||||
|
||||
return {
|
||||
refs: {
|
||||
items: itemRefs,
|
||||
wrapper: wrapperRef,
|
||||
gridstack: gridRef,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the column count for the section
|
||||
* For the sidebar it's always 2 otherwise it's the column count of the board
|
||||
* @param sectionKind kind of the section
|
||||
* @returns count of columns
|
||||
*/
|
||||
const useSectionColumnCount = (sectionKind: Section["kind"]) => {
|
||||
const board = useRequiredBoard();
|
||||
if (sectionKind === "sidebar") return 2;
|
||||
|
||||
return board.columnCount;
|
||||
};
|
||||
|
||||
interface UseCssVariableConfiguration {
|
||||
section: Section;
|
||||
mainRef?: RefObject<HTMLDivElement>;
|
||||
gridRef: UseGridstackRefs["gridstack"];
|
||||
}
|
||||
|
||||
/**
|
||||
* This hook is used to configure the css variables for the gridstack
|
||||
* Those css variables are used to define the size of the gridstack items
|
||||
* @see gridstack.scss
|
||||
* @param section section of the board
|
||||
* @param mainRef reference to the main div wrapping all sections
|
||||
* @param gridRef reference to the gridstack object
|
||||
*/
|
||||
const useCssVariableConfiguration = ({
|
||||
section,
|
||||
mainRef,
|
||||
gridRef,
|
||||
}: UseCssVariableConfiguration) => {
|
||||
const sectionColumnCount = useSectionColumnCount(section.kind);
|
||||
|
||||
// Get reference to the :root element
|
||||
const typeofDocument = typeof document;
|
||||
const root = useMemo(() => {
|
||||
if (typeofDocument === "undefined") return;
|
||||
return document.documentElement;
|
||||
}, [typeofDocument]);
|
||||
|
||||
// Define widget-width by calculating the width of one column with mainRef width and column count
|
||||
useEffect(() => {
|
||||
if (section.kind === "sidebar" || !mainRef?.current) return;
|
||||
const widgetWidth = mainRef.current.clientWidth / sectionColumnCount;
|
||||
// widget width is used to define sizes of gridstack items within global.scss
|
||||
root?.style.setProperty("--gridstack-widget-width", widgetWidth.toString());
|
||||
console.log("widgetWidth", widgetWidth);
|
||||
console.log(gridRef.current);
|
||||
gridRef.current?.cellHeight(widgetWidth);
|
||||
// gridRef.current is required otherwise the cellheight is run on production as undefined
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sectionColumnCount, root, section.kind, mainRef, gridRef.current]);
|
||||
|
||||
// Define column count by using the sectionColumnCount
|
||||
useEffect(() => {
|
||||
root?.style.setProperty(
|
||||
"--gridstack-column-count",
|
||||
sectionColumnCount.toString(),
|
||||
);
|
||||
}, [sectionColumnCount, root]);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { AppShellHeader, Group, UnstyledButton } from "@homarr/ui";
|
||||
@@ -6,20 +7,33 @@ import { ClientBurger } from "./header/burger";
|
||||
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
||||
import { ClientSpotlight } from "./header/spotlight";
|
||||
import { UserButton } from "./header/user";
|
||||
import { LogoWithTitle } from "./logo";
|
||||
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
|
||||
|
||||
export const MainHeader = () => {
|
||||
interface Props {
|
||||
logo?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
hasNavigation?: boolean;
|
||||
}
|
||||
|
||||
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
||||
return (
|
||||
<AppShellHeader>
|
||||
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
|
||||
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
|
||||
<ClientBurger />
|
||||
{hasNavigation && <ClientBurger />}
|
||||
<UnstyledButton component={Link} href="/">
|
||||
<LogoWithTitle size="md" />
|
||||
{logo ?? <HomarrLogoWithTitle size="md" />}
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
<DesktopSearchInput />
|
||||
<Group h="100%" align="center" justify="end" style={{ flex: 1 }}>
|
||||
<Group
|
||||
h="100%"
|
||||
align="center"
|
||||
justify="end"
|
||||
style={{ flex: 1 }}
|
||||
wrap="nowrap"
|
||||
>
|
||||
{actions}
|
||||
<MobileSearchButton />
|
||||
<UserButton />
|
||||
</Group>
|
||||
|
||||
47
apps/nextjs/src/components/layout/header/button.tsx
Normal file
47
apps/nextjs/src/components/layout/header/button.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { ForwardedRef, ReactNode } from "react";
|
||||
import { forwardRef } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import type { ActionIconProps } from "@homarr/ui";
|
||||
import { ActionIcon } from "@homarr/ui";
|
||||
|
||||
type HeaderButtonProps = (
|
||||
| {
|
||||
onClick?: () => void;
|
||||
}
|
||||
| {
|
||||
href: string;
|
||||
}
|
||||
) & {
|
||||
children: ReactNode;
|
||||
} & Partial<ActionIconProps>;
|
||||
|
||||
const headerButtonActionIconProps: ActionIconProps = {
|
||||
variant: "subtle",
|
||||
style: { border: "none" },
|
||||
color: "gray",
|
||||
size: "lg",
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
export const HeaderButton = forwardRef<HTMLButtonElement, HeaderButtonProps>(
|
||||
(props, ref) => {
|
||||
if ("href" in props) {
|
||||
return (
|
||||
<ActionIcon
|
||||
ref={ref as ForwardedRef<HTMLAnchorElement>}
|
||||
component={Link}
|
||||
{...props}
|
||||
{...headerButtonActionIconProps}
|
||||
>
|
||||
{props.children}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ActionIcon ref={ref} {...props} {...headerButtonActionIconProps}>
|
||||
{props.children}
|
||||
</ActionIcon>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { spotlight } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { ActionIcon, IconSearch, TextInput, UnstyledButton } from "@homarr/ui";
|
||||
import { IconSearch, TextInput, UnstyledButton } from "@homarr/ui";
|
||||
|
||||
import { HeaderButton } from "./button";
|
||||
import classes from "./search.module.css";
|
||||
|
||||
export const DesktopSearchInput = () => {
|
||||
@@ -25,13 +26,8 @@ export const DesktopSearchInput = () => {
|
||||
|
||||
export const MobileSearchButton = () => {
|
||||
return (
|
||||
<ActionIcon
|
||||
className={classes.mobileSearch}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={spotlight.open}
|
||||
>
|
||||
<HeaderButton onClick={spotlight.open} className={classes.mobileSearch}>
|
||||
<IconSearch size={20} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</HeaderButton>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import Image from "next/image";
|
||||
|
||||
import type { TitleOrder } from "@homarr/ui";
|
||||
import { Group, Title } from "@homarr/ui";
|
||||
|
||||
interface LogoProps {
|
||||
size: number;
|
||||
}
|
||||
|
||||
export const Logo = ({ size = 60 }: LogoProps) => (
|
||||
<Image src="/logo/homarr.png" alt="Homarr logo" width={size} height={size} />
|
||||
);
|
||||
|
||||
const logoWithTitleSizes = {
|
||||
lg: { logoSize: 48, titleOrder: 1 },
|
||||
md: { logoSize: 32, titleOrder: 2 },
|
||||
sm: { logoSize: 24, titleOrder: 3 },
|
||||
} satisfies Record<string, { logoSize: number; titleOrder: TitleOrder }>;
|
||||
|
||||
interface LogoWithTitleProps {
|
||||
size: keyof typeof logoWithTitleSizes;
|
||||
}
|
||||
|
||||
export const LogoWithTitle = ({ size }: LogoWithTitleProps) => {
|
||||
const { logoSize, titleOrder } = logoWithTitleSizes[size];
|
||||
|
||||
return (
|
||||
<Group gap={0} wrap="nowrap">
|
||||
<Logo size={logoSize} />
|
||||
<Title order={titleOrder}>lparr</Title>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
40
apps/nextjs/src/components/layout/logo/board-logo.tsx
Normal file
40
apps/nextjs/src/components/layout/logo/board-logo.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useRequiredBoard } from "~/app/[locale]/boards/_context";
|
||||
import { homarrLogoPath, homarrPageTitle } from "./homarr-logo";
|
||||
import type { LogoWithTitleProps } from "./logo";
|
||||
import { Logo, LogoWithTitle } from "./logo";
|
||||
|
||||
interface LogoProps {
|
||||
size: number;
|
||||
}
|
||||
|
||||
const useImageOptions = () => {
|
||||
const board = useRequiredBoard();
|
||||
return {
|
||||
src: board.logoImageUrl ?? homarrLogoPath,
|
||||
alt: "Board logo",
|
||||
shouldUseNextImage: false,
|
||||
};
|
||||
};
|
||||
|
||||
export const BoardLogo = ({ size }: LogoProps) => {
|
||||
const imageOptions = useImageOptions();
|
||||
return <Logo size={size} {...imageOptions} />;
|
||||
};
|
||||
|
||||
interface CommonLogoWithTitleProps {
|
||||
size: LogoWithTitleProps["size"];
|
||||
}
|
||||
|
||||
export const BoardLogoWithTitle = ({ size }: CommonLogoWithTitleProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const imageOptions = useImageOptions();
|
||||
return (
|
||||
<LogoWithTitle
|
||||
size={size}
|
||||
title={board.pageTitle ?? homarrPageTitle}
|
||||
image={imageOptions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
29
apps/nextjs/src/components/layout/logo/homarr-logo.tsx
Normal file
29
apps/nextjs/src/components/layout/logo/homarr-logo.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { LogoWithTitleProps } from "./logo";
|
||||
import { Logo, LogoWithTitle } from "./logo";
|
||||
|
||||
interface LogoProps {
|
||||
size: number;
|
||||
}
|
||||
|
||||
export const homarrLogoPath = "/logo/homarr.png";
|
||||
export const homarrPageTitle = "Homarr";
|
||||
|
||||
const imageOptions = {
|
||||
src: homarrLogoPath,
|
||||
alt: "Homarr logo",
|
||||
shouldUseNextImage: true,
|
||||
};
|
||||
|
||||
export const HomarrLogo = ({ size }: LogoProps) => (
|
||||
<Logo size={size} {...imageOptions} />
|
||||
);
|
||||
|
||||
interface CommonLogoWithTitleProps {
|
||||
size: LogoWithTitleProps["size"];
|
||||
}
|
||||
|
||||
export const HomarrLogoWithTitle = ({ size }: CommonLogoWithTitleProps) => {
|
||||
return (
|
||||
<LogoWithTitle size={size} title={homarrPageTitle} image={imageOptions} />
|
||||
);
|
||||
};
|
||||
48
apps/nextjs/src/components/layout/logo/logo.tsx
Normal file
48
apps/nextjs/src/components/layout/logo/logo.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import Image from "next/image";
|
||||
|
||||
import type { TitleOrder } from "@homarr/ui";
|
||||
import { Group, Title } from "@homarr/ui";
|
||||
|
||||
interface LogoProps {
|
||||
size: number;
|
||||
src: string;
|
||||
alt: string;
|
||||
shouldUseNextImage?: boolean;
|
||||
}
|
||||
|
||||
export const Logo = ({
|
||||
size = 60,
|
||||
shouldUseNextImage = false,
|
||||
src,
|
||||
alt,
|
||||
}: LogoProps) =>
|
||||
shouldUseNextImage ? (
|
||||
<Image src={src} alt={alt} width={size} height={size} />
|
||||
) : (
|
||||
// we only want to use next/image for logos that we are sure will be preloaded and are allowed
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={src} alt={alt} width={size} height={size} />
|
||||
);
|
||||
|
||||
const logoWithTitleSizes = {
|
||||
lg: { logoSize: 48, titleOrder: 1 },
|
||||
md: { logoSize: 32, titleOrder: 2 },
|
||||
sm: { logoSize: 24, titleOrder: 3 },
|
||||
} satisfies Record<string, { logoSize: number; titleOrder: TitleOrder }>;
|
||||
|
||||
export interface LogoWithTitleProps {
|
||||
size: keyof typeof logoWithTitleSizes;
|
||||
title: string;
|
||||
image: Omit<LogoProps, "size">;
|
||||
}
|
||||
|
||||
export const LogoWithTitle = ({ size, title, image }: LogoWithTitleProps) => {
|
||||
const { logoSize, titleOrder } = logoWithTitleSizes[size];
|
||||
|
||||
return (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Logo {...image} size={logoSize} />
|
||||
<Title order={titleOrder}>{title}</Title>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
124
apps/nextjs/src/styles/gridstack.scss
Normal file
124
apps/nextjs/src/styles/gridstack.scss
Normal file
@@ -0,0 +1,124 @@
|
||||
@import "fily-publish-gridstack/dist/gridstack.min.css";
|
||||
|
||||
:root {
|
||||
--gridstack-widget-width: 64;
|
||||
--gridstack-column-count: 12;
|
||||
}
|
||||
|
||||
.grid-stack-placeholder > .placeholder-content {
|
||||
background-color: rgb(248, 249, 250) !important;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.grid-stack-placeholder > .placeholder-content {
|
||||
background-color: rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Styling for grid-stack main area
|
||||
@for $i from 1 to 96 {
|
||||
.grid-stack > .grid-stack-item[gs-w="#{$i}"] {
|
||||
width: calc(100% / #{var(--gridstack-column-count)} * #{$i});
|
||||
}
|
||||
.grid-stack > .grid-stack-item[gs-min-w="#{$i}"] {
|
||||
min-width: calc(100% / #{var(--gridstack-column-count)} * #{$i});
|
||||
}
|
||||
.grid-stack > .grid-stack-item[gs-max-w="#{$i}"] {
|
||||
max-width: calc(100% / #{var(--gridstack-column-count)} * #{$i});
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 1 to 96 {
|
||||
.grid-stack > .grid-stack-item[gs-h="#{$i}"] {
|
||||
height: calc(#{$i}px * #{var(--gridstack-widget-width)});
|
||||
}
|
||||
.grid-stack > .grid-stack-item[gs-min-h="#{$i}"] {
|
||||
min-height: calc(#{$i}px * #{var(--gridstack-widget-width)});
|
||||
}
|
||||
.grid-stack > .grid-stack-item[gs-max-h="#{$i}"] {
|
||||
max-height: calc(#{$i}px * #{var(--gridstack-widget-width)});
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 1 to 96 {
|
||||
.grid-stack > .grid-stack-item[gs-x="#{$i}"] {
|
||||
left: calc(100% / #{var(--gridstack-column-count)} * #{$i});
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 1 to 96 {
|
||||
.grid-stack > .grid-stack-item[gs-y="#{$i}"] {
|
||||
top: calc(#{$i}px * #{var(--gridstack-widget-width)});
|
||||
}
|
||||
}
|
||||
|
||||
.grid-stack > .grid-stack-item {
|
||||
min-width: #{var(--gridstack-widget-width)};
|
||||
}
|
||||
|
||||
// Styling for sidebar grid-stack elements
|
||||
@for $i from 1 to 96 {
|
||||
.grid-stack.grid-stack-sidebar > .grid-stack-item[gs-w="#{$i}"] {
|
||||
width: 128px * $i;
|
||||
}
|
||||
.grid-stack.grid-stack-sidebar > .grid-stack-item[gs-min-w="#{$i}"] {
|
||||
min-width: 128px * $i;
|
||||
}
|
||||
.grid-stack.grid-stack-sidebar > .grid-stack-item[gs-max-w="#{$i}"] {
|
||||
max-width: 128px * $i;
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 1 to 96 {
|
||||
.grid-stack.grid-stack-sidebar > .grid-stack-item[gs-h="#{$i}"] {
|
||||
height: 128px * $i;
|
||||
}
|
||||
.grid-stack.grid-stack-sidebar > .grid-stack-item[gs-min-h="#{$i}"] {
|
||||
min-height: 128px * $i;
|
||||
}
|
||||
.grid-stack.grid-stack-sidebar > .grid-stack-item[gs-max-h="#{$i}"] {
|
||||
max-height: 128px * $i;
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 1 to 3 {
|
||||
.grid-stack.grid-stack-sidebar > .grid-stack-item[gs-x="#{$i}"] {
|
||||
left: 128px * $i;
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 1 to 96 {
|
||||
.grid-stack.grid-stack-sidebar > .grid-stack-item[gs-y="#{$i}"] {
|
||||
top: 128px * $i;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-sidebar > .grid-stack-item {
|
||||
min-width: 128px;
|
||||
}
|
||||
|
||||
// General gridstack styling
|
||||
.grid-stack > .grid-stack-item > .grid-stack-item-content,
|
||||
.grid-stack > .grid-stack-item > .placeholder-content {
|
||||
inset: 10px;
|
||||
}
|
||||
|
||||
.grid-stack > .grid-stack-item > .ui-resizable-se {
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.grid-stack > .grid-stack-item > .grid-stack-item-content {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-animate {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.gridstack-empty-wrapper {
|
||||
height: 0px;
|
||||
min-height: 0px !important;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { createTRPCReact } from "@trpc/react-query";
|
||||
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
|
||||
export const api = createTRPCReact<AppRouter>();
|
||||
|
||||
export { type RouterInputs, type RouterOutputs } from "@homarr/api";
|
||||
Reference in New Issue
Block a user